Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 10 additions & 15 deletions apps/daemon/src/origin-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,21 +225,16 @@ export function isLocalSameOrigin(

const localHostAllowed = isAllowedBrowserHost(host, ports, bindHost, ipOnlyExtraOrigins);
if (origin == null || origin === '') {
if (localHostAllowed) return true;
// Browsers (Firefox, Chrome) omit Origin on same-origin GET subresource
// requests per the Fetch spec, which made hostname entries in
// OD_ALLOWED_ORIGINS unreachable for legitimate same-origin GETs
// through a reverse proxy. Sec-Fetch-Site is set by the user agent and
// cannot be modified by JavaScript, so a value of "same-origin"
// attests that the request originated from the same origin as the
// target — a cross-site `<img>`/`<script>` exploit would carry
// "cross-site" instead. Only consult the broader allow-list once that
// signal is present.
const fetchSite = headerValue(req.headers?.['sec-fetch-site']);
if (fetchSite === 'same-origin') {
return isAllowedBrowserHost(host, ports, bindHost, extraAllowedOrigins);
}
return false;
// Only a loopback / private-LAN host (or an explicitly configured
// IP-literal origin) may omit the Origin header. Sec-Fetch-Site is
// intentionally NOT consulted here (issue #7041): browsers set it and
// JavaScript cannot modify it, but any non-browser HTTP client (curl,
// scripts) can forge it, so treating `Sec-Fetch-Site: same-origin` as
// an authorization signal would let a forged header reach the full
// OD_ALLOWED_ORIGINS allow-list on the non-loopback path. Reverse-proxy
// deployments whose public hostname is listed in OD_ALLOWED_ORIGINS
// must send an Origin header (or use bearer auth) instead.
return localHostAllowed;
}
// Reverse-proxy deployments (e.g. Nginx in front of the daemon) terminate
// the browser connection at the proxy and open a fresh upstream
Expand Down
26 changes: 15 additions & 11 deletions apps/daemon/tests/origin-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,15 +701,16 @@ describe('isLocalSameOrigin: OD_ALLOWED_ORIGINS bypass for reverse-proxy deploym
});
});

// Firefox and Chrome omit the Origin header on same-origin GET requests per
// the Fetch spec. When the daemon runs behind a remote-access proxy whose
// public hostname is listed in OD_ALLOWED_ORIGINS, those legitimate
// same-origin GETs (e.g. /api/app-config) get rejected by the no-Origin
// host check because hostname entries in OD_ALLOWED_ORIGINS are only
// honored via the IP-literal subset in that branch. Sec-Fetch-Site is set
// by the browser and cannot be modified by JavaScript, so a value of
// "same-origin" is a trustworthy substitute for the missing Origin header.
describe('isLocalSameOrigin: Sec-Fetch-Site fallback for no-Origin same-origin GETs', () => {
// Issue #7041 — Sec-Fetch-Site must not act as an authorization signal.
// Browsers set Sec-Fetch-Site and JavaScript cannot modify it, but any
// non-browser HTTP client (curl, scripts) can forge it trivially. For
// no-Origin requests, the only acceptable authorization is a loopback /
// private-LAN host (or an explicitly configured IP-literal origin); a
// client-supplied Sec-Fetch-Site header never widens that. Reverse-proxy
// deployments whose public hostname is listed in OD_ALLOWED_ORIGINS must
// send an Origin header (or use bearer auth) instead — see #2477 for the
// earlier behavior this fix intentionally closes.
describe('isLocalSameOrigin: Sec-Fetch-Site is not an authorization signal (issue #7041)', () => {
const ALLOWED = 'https://nas.example.ts.net';
const previousAllowedOrigins = process.env.OD_ALLOWED_ORIGINS;
const env: NodeJS.ProcessEnv = {
Expand All @@ -726,14 +727,17 @@ describe('isLocalSameOrigin: Sec-Fetch-Site fallback for no-Origin same-origin G
else process.env.OD_ALLOWED_ORIGINS = previousAllowedOrigins;
});

it('accepts a no-Origin request whose Host matches OD_ALLOWED_ORIGINS when Sec-Fetch-Site is same-origin', () => {
it('rejects a no-Origin request even when Host matches OD_ALLOWED_ORIGINS and Sec-Fetch-Site is same-origin (forged header cannot authorize)', () => {
// Issue #7041 repro: a non-browser client can forge Sec-Fetch-Site, so
// it must never substitute for the Origin header / bearer auth on the
// non-loopback path.
const req = {
headers: {
host: 'nas.example.ts.net',
'sec-fetch-site': 'same-origin',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(true);
expect(isLocalSameOrigin(req, 7456, env)).toBe(false);
});

it('still rejects a no-Origin request whose Host matches the allow-list but Sec-Fetch-Site is cross-site', () => {
Expand Down
55 changes: 47 additions & 8 deletions packages/dsh-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,13 +258,35 @@ async function execute(
onHandle: (handle: AgentHandle | undefined) => void,
signal: AbortSignal,
): Promise<void> {
const defaultSelection = ctx.agentDefaultModel.currentSelection();
const baseSelection = request.model
? { provider: request.model.provider, model: request.model.id }
: defaultSelection;
const selection = request.reasoning_effort
? { ...baseSelection, reasoningEffort: ReasoningEffortId(request.reasoning_effort) }
: baseSelection;
// Model selection derivation (provider/id/resume-id → resolved selection)
// runs before the try below, so a host-supplied value that the harness
// rejects (e.g. an unknown reasoning_effort) would throw OUTSIDE the
// handler's error path and reject the task promise with no result frame —
// `serve` would then exit the process silently and the host would wait on
// a response that never comes. Derive inside its own guard and emit an
// explicit failed frame so the protocol contract (every execute gets
// exactly one result) holds even for malformed selections.
let selection: ModelSelectionRef['current'];
try {
const defaultSelection = ctx.agentDefaultModel.currentSelection();
const baseSelection = request.model
? { provider: request.model.provider, model: request.model.id }
: defaultSelection;
selection = request.reasoning_effort
? { ...baseSelection, reasoningEffort: ReasoningEffortId(request.reasoning_effort) }
: baseSelection;
} catch (error: unknown) {
writeFrame(output, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this new selection-error branch bypasses cancellation semantics. serve aborts taskAbort as soon as it handles a cancel, and the existing execute catches explicitly check signal.aborted before emitting a terminal frame. If currentSelection() throws while that signal is already aborted (for example, a settings/default-model service is unavailable during an immediate cancel), this branch emits DSH_PROFILE_INVALID_MODEL_SELECTION instead of cancelled, so a user cancellation is reported as a failed run. Check signal.aborted before writing this failed frame and add a serve-level execute-plus-cancel regression test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

v: 1,
type: 'result',
request_id: request.request_id,
status: 'failed',
session_id: String(SessionId(request.resume_session_id ?? `od-${randomUUID()}`)),
resume_rejected: false,
error: errorFacts(error, 'DSH_PROFILE_INVALID_MODEL_SELECTION'),
});
return;
}
const sessionId = SessionId(request.resume_session_id ?? `od-${randomUUID()}`);
let handle: AgentHandle | undefined;
let firstSeq = Number.POSITIVE_INFINITY;
Expand Down Expand Up @@ -439,7 +461,24 @@ async function serve(
(nextHandle) => { handle = nextHandle; },
taskAbort.signal,
);
void task.finally(settle);
// Defense in depth: `execute` reports every failure via a result frame
// and resolves, so a rejection here would mean a bug in that contract.
// Still convert it into an explicit failed frame instead of an
// unhandled rejection (which would make the process exit silently and
// leave the host waiting on a result that never arrives).
void task
.catch((error: unknown) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this fallback unconditionally synthesizes another terminal result whenever execute rejects, but execute can reject after it has already written its terminal result: the outer try writes the result before the finally, where disposeEvent() and cleanup still run. A disposer/cleanup error therefore produces two result frames (the original terminal frame plus this fallback). The DSH protocol requires exactly one terminal result, and the daemon marks any frame after finished as fatal, so a successful or failed run can be turned into a fatal protocol error. Track a terminal-frame-written state shared with execute, or make cleanup errors non-rejecting, and emit this fallback only when no result was written; add a serve-level cleanup-error test that asserts one result.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

writeFrame(output, {
v: 1,
type: 'result',
request_id: requestId ?? command.request_id,
status: 'failed',
session_id: `od-${randomUUID()}`,
resume_rejected: false,
error: errorFacts(error, 'DSH_PROFILE_EXECUTION_FAILED'),
});
})
.finally(settle);
});
lines.on('close', () => {
if (!task) settle();
Expand Down
32 changes: 32 additions & 0 deletions packages/dsh-runtime/tests/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,4 +334,36 @@ describe('@open-design/dsh-runtime protocol', () => {
assert.equal(frames.at(-1)?.status, 'cancelled');
assert.equal(frames.at(-1)?.error, undefined);
});

test('emits a failed result frame when model selection derivation throws (no silent exit)', async () => {
// Model selection (provider/id → resolved selection) is derived before
// the run itself. If that derivation throws — e.g. the default-model
// service is unavailable, or a host-supplied value is rejected — the
// execute must still settle the protocol contract: exactly one result
// frame, status failed. Before the fix the derivation sat outside the
// error path, so a throw rejected the task promise with no frame at all
// and the serve loop exited the process silently, leaving the host
// waiting on a result that never arrived.
const chunks: string[] = [];
const ctx = {
agentDefaultModel: {
currentSelection: () => {
throw new Error('default model unavailable');
},
},
};
await internals.execute(ctx as never, {
v: 1,
type: 'execute',
request_id: 'run-bad-selection',
cwd: '/project',
prompt: 'hi',
mcp_servers: [],
}, { write: (chunk: string) => chunks.push(chunk) }, () => {}, new AbortController().signal);

const frames = chunks.map((chunk) => JSON.parse(chunk) as { type: string; status?: string; error?: { code?: string } });
assert.equal(frames.filter((frame) => frame.type === 'result').length, 1);
assert.equal(frames.at(-1)?.status, 'failed');
assert.equal(frames.at(-1)?.error?.code, 'DSH_PROFILE_INVALID_MODEL_SELECTION');
});
});
Loading