diff --git a/agents/launchpad/work/queue-192/BUILD.md b/agents/launchpad/work/queue-192/BUILD.md index 8a01b9a61..44ffcabee 100644 --- a/agents/launchpad/work/queue-192/BUILD.md +++ b/agents/launchpad/work/queue-192/BUILD.md @@ -1,12 +1,12 @@ # Row 192: Q175 phase 1, the Console verifies sign-in itself (`--sso`) Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1690. Brief -`docs/plans/2026-10-20_sso-phase1.md`. Design `agents/sage/work/q175-sso/DESIGN.md` (blob eba861f5d2bb). +`docs/plans/2026-10-20_sso-phase1.md`. Design `agents/sage/work/q175-sso/DESIGN.md` (blob 9e3eadb15c80). Rulings sage-1340 (F1 to F6) and sage-1347 (F7, F8). Plan launchpad-0001. -Base 53bb59462 (`base.txt`). Candidate: one commit on branch `launchpad/row192`. `candidate-manifest.sha256` -lists the 18 changed code, test and doc paths; the packet files are not in it. Drift check at commit time: -`git log 53bb59462..origin/refactor` (origin/refactor aa1bd209e) touches none of the 18 paths. +Base 53bb59462 (`base.txt`). Candidate: branch `launchpad/row192`, r1 83b32d8d8 and the round 2 commit on it. +`candidate-manifest.sha256` lists the 18 changed code, test and doc paths; the packet files are not in it. Drift +check at the r2 commit: `git log 53bb59462..origin/refactor` (origin/refactor ae3c36f24) touches none of the 18 paths. No request in any test goes to auth.diversecanvas.com, mosaic.woltje.com or 127.0.0.1:7332. Every provider is the fake OP in `tests/helpers/fake-oidc.mjs`: https on 127.0.0.1 with a throwaway self-signed certificate, @@ -102,6 +102,8 @@ checked by sha256. Final run on the final tree: `/mnt/storage/scratch/launchpad- | 19 | Sign out shown without the sso condition (F7) | sso-browser 2 | KILLED | | 20 | CSP form-action without the end-session origins | sso 13 CSP | KILLED | | 21 | import oauth4webapi at module load | pinned-copy | KILLED | +| 22 | restore the r1 relative returnPath (round 2, B1) | sso 14 | KILLED | +| 23 | drop the second origin check (round 2, sage-1403) | sso 14 | SURVIVED, equivalent (see Round 2) | Mutant 16 survived its first run: test 1 had no `getJSON` path. The History click on `/api/conversations` was added and it is now killed. @@ -113,6 +115,11 @@ Mutant 16 survived its first run: test 1 had no `getJSON` path. The History clic - **F7 (sage-1347).** A Sign out form (`POST /auth/logout`) in the command bar, hidden unless `/api/config` says `sso`. Tested in sso-browser 2, mutant 19. +- **Proof header on 7333 under `--sso` (webby-0019 N2).** `cli.mjs` passes `testerProofHeader: null` under + `--sso`, since `--sso` refuses the proof pair and there is no header name to refuse. A request to 7333 that + still carries the old proof header is not refused for it. It gains nothing: under `--sso` no listener + reads a proof header (`proof` is null in `startServer`). Left as is. + ## Assumptions and choices - **F8 (sage-1347).** Under `--sso` the tester listener refuses a request carrying `Authorization`, either @@ -137,6 +144,32 @@ Mutant 16 survived its first run: test 1 had no `getJSON` path. The History clic - **Test 22**: a Host of bare `localhost` without a port is not in the table. It hits the older `non-local Host refused` check before the sso check; a comment in the test says so. +## Round 2 (webby-0019) + +- **B1, open redirect after sign-in.** `returnPath` took any URL on `publicOrigin`, and dot segments can + normalize to a pathname of `//evil.example`, which a browser reads as scheme-relative in a relative + Location. Fix per sage-1403: `returnURL` (renamed) builds an absolute URL, `publicOrigin + pathname + + search`, checks its origin again, and returns its href. Anything else is `publicOrigin/`. The callback's + Location is now always absolute on the Console. Test 14 asserts the exact Location and its origin for each + case. It adds Webby's three rows (`/.//evil.example`, `/x/..//evil.example`, + `https://console.example.test//evil.example`), each now `https://console.example.test//evil.example`, a path + on the Console. It also adds `/board/./x`, which lands on `/board/x`. The positive test expects + `https://console.example.test/board?x=1`. +- **Mutant 22** restores the r1 relative `returnPath`; test 14 kills it. +- **Mutant 23** drops the second origin check. It SURVIVES, and I read it as equivalent: `checkConsole` + holds `publicOrigin` to a bare https origin, and a parsed https pathname always starts with `/`, so + `publicOrigin + pathname` can't parse to another origin. The check stays because sage-1403 asks for it. + Either the check goes, or it stays as a guard no test can reach. That is Sage's call. +- **N1.** The design cite is now blob 9e3eadb15c80 (`git rev-parse 83b32d8d8:agents/sage/work/q175-sso/DESIGN.md`). +- **N2.** Listed under Deviations. +- **N3.** `SIGN_IN_COOKIE` has its own comment, above the row 177 comment and `TESTER_CHAT_WRITE`. +- **N4.** Row 200 (#1698) covers it: `stack-release.sh` refuses `config-console-key` before any stop when the + target's loader refuses the key, on activate and rollback. +- **N5.** No change. + +Round 2 runs, this worktree, final tree: `sso.test.mjs` 28/28 (`/mnt/storage/scratch/launchpad-r192/sso-r2.log`); +mutants 22 and 23 (`/mnt/storage/scratch/launchpad-r192/mutants-r2.log`). + ## Not done here - Row 200 (#1698, `stack-release.sh activate --sso`) is chained on this candidate and starts only after the diff --git a/agents/launchpad/work/queue-192/candidate-manifest.sha256 b/agents/launchpad/work/queue-192/candidate-manifest.sha256 index 2d52a3e67..d8cd98f0f 100644 --- a/agents/launchpad/work/queue-192/candidate-manifest.sha256 +++ b/agents/launchpad/work/queue-192/candidate-manifest.sha256 @@ -6,13 +6,13 @@ f7b6b68906f13a644854b8083287dfa2936e8defc6207d05739e89763cc91440 packages/webui 41ee8798c34ee491b80ae1b42c3e96cd5d12fb1603b1ab88ac2dd10b5fb27964 packages/webui/src/public/app.js e6c8f838eb7cdc2ae556c04451ca4c8f2d433117a17f285a144cfc7dff03e2a4 packages/webui/src/public/bus.js f276fa440ec938638cb3e0f1b812fafa929460a6b96f1dab4cf1076a7c0c18ea packages/webui/src/public/index.html -b506bb352ec71524799e276ead340437876cd9380ccceb266d9e4ad7b3ef400a packages/webui/src/serve.mjs +333d474b1c7e753938aedc639c08f9db0e787faf56aa8068d013fd304a0b3b7e packages/webui/src/serve.mjs 37b01ab0479d5f2a7ab9aac790931c6d88c790465392d9c0b65a105513f3e36b packages/webui/src/sso-config.mjs -285dea6be3d7895ab4f22c9a7cff88dcd0618004708d8f7489574cd1a8997334 packages/webui/src/sso.mjs +b624f044d361c648cefa161abe5f2b96e0cd8a83bc7816c9d501a2cd840988ca packages/webui/src/sso.mjs 84e31d4beba0de5ed6d192921bd7b016296c8b2a42b3fcf1176663e182320d9e packages/webui/tests/helpers/fake-oidc.mjs ac01037743fcbf1efa641794d3878f113b6206033d2f66cde072a52ccfae1a51 packages/webui/tests/service.test.mjs 9358e3755e174b61532e2c528e733ffcdb48941cbadb825f1b0254bc55d1920e packages/webui/tests/sso-browser.test.mjs -4a1645d9202690ad3e6911bb71ada379cbf5c46f67bd82bbc80c01c4a07330ed packages/webui/tests/sso.test.mjs +3f8527fd7ae1ffb2bffce3450971f125bd616954b534577ef54249c87f36e248 packages/webui/tests/sso.test.mjs f8307d4bd998634467fbed8748157754132b76affac051f320196161ce385e4a scripts/mosaic-config.mjs 3301c1fabf616bb961d62f6decd0a46dadb0f399b8ba7f3c2ca60023d2b24c78 scripts/test-config.sh 7678bb6b45861c3dc6d775fc99269b1db7350ced60b89517d6ebeb4cc161781c scripts/webui-service.sh diff --git a/agents/launchpad/work/queue-192/mutants.py b/agents/launchpad/work/queue-192/mutants.py index ba5fbc9fc..830e0a105 100644 --- a/agents/launchpad/work/queue-192/mutants.py +++ b/agents/launchpad/work/queue-192/mutants.py @@ -28,6 +28,8 @@ M = [ ('Sign out shown without the sso condition (F7)', F+'app.js', [("if (config.sso === true) { sso = true; $('sign-out').hidden = out(); }", "{ $('sign-out').hidden = out(); }")], P, '^the Sign out'), ('form-action without the end-session origins (CSP)', B, [("form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`", "form-action 'self'`")], T, '^13: the CSP'), ('import oauth4webapi at module load (copies)', S, [("let oauth;\n", "import * as oauth from 'oauth4webapi';\n"), (" oauth ??= await import('oauth4webapi');\n", "")], 'tests/pinned-copy.test.mjs', '^serve runs from a copy'), + ('restore the r1 relative returnPath (14, webby-0019 B1)', S, [(" if (u.origin !== publicOrigin) return home;\n const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);\n return out.origin === publicOrigin ? out.href : home;", " return u.origin === publicOrigin ? u.pathname + u.search : '/';")], T, '^14:'), + ('drop the second origin check (14, sage-1403)', S, [(" return out.origin === publicOrigin ? out.href : home;", " return `${publicOrigin}${u.pathname}${u.search}`;")], T, '^14:'), ] sha = lambda b: hashlib.sha256(b).hexdigest() only = sys.argv[2:] diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index fa0d07a38..d46b70eff 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -61,8 +61,9 @@ async function body(req, max = 4096) { // Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback. const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes']; const DECISION_LISTS = ['roles', 'recent']; -// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes. +// Q175 phase 1 (#1690, F8): either sign-in cookie, which the tester listener refuses under --sso. const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i; +// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes. const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i; async function busRead(res, bus, verb, search) { let arg; diff --git a/packages/webui/src/sso.mjs b/packages/webui/src/sso.mjs index d8b70e743..2d01e4412 100644 --- a/packages/webui/src/sso.mjs +++ b/packages/webui/src/sso.mjs @@ -196,13 +196,18 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo map.set(key, value); }; - // B2: only a path on publicOrigin itself; anything else, or nothing, is `/`. - function returnPath(ret) { + // B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The + // Location is absolute and its origin checked again: dot segments can leave a pathname of `//host`, + // which as a relative Location a browser reads as another origin (webby-0019 B1, sage-1403). + function returnURL(ret) { + const home = `${publicOrigin}/`; try { - if (typeof ret !== 'string' || !ret) return '/'; + if (typeof ret !== 'string' || !ret) return home; const u = new URL(ret, publicOrigin); - return u.origin === publicOrigin ? u.pathname + u.search : '/'; - } catch { return '/'; } + if (u.origin !== publicOrigin) return home; + const out = new URL(`${publicOrigin}${u.pathname}${u.search}`); + return out.origin === publicOrigin ? out.href : home; + } catch { return home; } } async function start(res, entry, ret) { @@ -281,7 +286,7 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo // Step 7: the session, then the return path. const id = randomBytes(32).toString('base64url'), now = Date.now(); remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS); - return redirect(res, returnPath(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]); + return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]); } // The session for this request, or null. `touch` is true only for a page load or a write (Q-D). @@ -333,7 +338,7 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo } return { - publicOrigin, publicHost, sessions, logins, handle, session, returnPath, + publicOrigin, publicHost, sessions, logins, handle, session, returnURL, health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])), formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))], summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })), diff --git a/packages/webui/tests/sso.test.mjs b/packages/webui/tests/sso.test.mjs index 8c43a04cf..eb3797ca8 100644 --- a/packages/webui/tests/sso.test.mjs +++ b/packages/webui/tests/sso.test.mjs @@ -109,7 +109,7 @@ test('positive: an RS256 provider signs in and sends one message, and the sessio assert.notEqual(env.port, 7332); const { r, session } = await signIn(env, { ret: '/board?x=1' }); assert.equal(r.status, 303); - assert.equal(r.headers.location, '/board?x=1'); + assert.equal(r.headers.location, `${ORIGIN}/board?x=1`); assert.ok(session); const send = await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG }); assert.equal(send.status, 200, JSON.stringify(send.body)); @@ -459,17 +459,23 @@ test('13: the CSP lets the Sign out form reach the provider\'s end-session origi test('14: open redirects land on /, and a path on the Console lands there', async t => { const env = await setup(t); for (const [query, where] of [ - ['return=%2F%2Fevil.example', '/'], - ['return=https%3A%2F%2Fevil.example', '/'], - ['return=%2F%5Cevil.example', '/'], - ['return=/%09/evil.example', '/'], - ['return=%2Fboard%3Fx%3D1', '/board?x=1'], - ['', '/'], + ['return=%2F%2Fevil.example', `${ORIGIN}/`], + ['return=https%3A%2F%2Fevil.example', `${ORIGIN}/`], + ['return=%2F%5Cevil.example', `${ORIGIN}/`], + ['return=/%09/evil.example', `${ORIGIN}/`], + // Dot segments that leave a pathname of //evil.example stay on the Console (webby-0019 B1). + ['return=%2F.%2F%2Fevil.example', `${ORIGIN}//evil.example`], + ['return=%2Fx%2F..%2F%2Fevil.example', `${ORIGIN}//evil.example`], + [`return=${encodeURIComponent(`${ORIGIN}//evil.example`)}`, `${ORIGIN}//evil.example`], + ['return=%2Fboard%2F.%2Fx', `${ORIGIN}/board/x`], + ['return=%2Fboard%3Fx%3D1', `${ORIGIN}/board?x=1`], + ['', `${ORIGIN}/`], ]) { const begun = await begin(env, { query }); const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code }); assert.equal(r.status, 303); assert.equal(r.headers.location, where, query); + assert.equal(new URL(r.headers.location).origin, ORIGIN, query); PRODUCED.add(sessionOf(r).split('=')[1]); } });