webui: Q175 phase 1 round 2, absolute post-sign-in Location checked on publicOrigin (row 192, #1690)

webby-0019 B1, sage-1403: returnURL builds the Location from publicOrigin plus the
parsed pathname and search, checks the origin again and returns the href, so a
dot-segment `//host` path stays on the Console. Test 14 covers Webby's three cases
and asserts each Location's origin; mutant 22 restores the r1 returnPath (killed).
N1 design blob cite, N2 listed under Deviations, N3 SIGN_IN_COOKIE comment.
This commit is contained in:
jason.woltje committed 2026-10-20 14:54:43 -05:00
1 parent 83b32d8d86
commit 37582e8205
6 files changed
+69 -22

No files matched your search

+37 -4
View File
@@ -1,12 +1,12 @@
# Row 192: Q175 phase 1, the Console verifies sign-in itself (`--sso`) # 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 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. 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` Base 53bb59462 (`base.txt`). Candidate: branch `launchpad/row192`, r1 83b32d8d8 and the round 2 commit on it.
lists the 18 changed code, test and doc paths; the packet files are not in it. Drift check at commit time: `candidate-manifest.sha256` lists the 18 changed code, test and doc paths; the packet files are not in it. Drift
`git log 53bb59462..origin/refactor` (origin/refactor aa1bd209e) touches none of the 18 paths. 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 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, 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 | | 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 | | 20 | CSP form-action without the end-session origins | sso 13 CSP | KILLED |
| 21 | import oauth4webapi at module load | pinned-copy | 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 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. `/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` - **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. 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 ## Assumptions and choices
- **F8 (sage-1347).** Under `--sso` the tester listener refuses a request carrying `Authorization`, either - **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 - **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. `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 ## Not done here
- Row 200 (#1698, `stack-release.sh activate --sso`) is chained on this candidate and starts only after the - Row 200 (#1698, `stack-release.sh activate --sso`) is chained on this candidate and starts only after the
@@ -6,13 +6,13 @@ f7b6b68906f13a644854b8083287dfa2936e8defc6207d05739e89763cc91440 packages/webui
41ee8798c34ee491b80ae1b42c3e96cd5d12fb1603b1ab88ac2dd10b5fb27964 packages/webui/src/public/app.js 41ee8798c34ee491b80ae1b42c3e96cd5d12fb1603b1ab88ac2dd10b5fb27964 packages/webui/src/public/app.js
e6c8f838eb7cdc2ae556c04451ca4c8f2d433117a17f285a144cfc7dff03e2a4 packages/webui/src/public/bus.js e6c8f838eb7cdc2ae556c04451ca4c8f2d433117a17f285a144cfc7dff03e2a4 packages/webui/src/public/bus.js
f276fa440ec938638cb3e0f1b812fafa929460a6b96f1dab4cf1076a7c0c18ea packages/webui/src/public/index.html 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 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 84e31d4beba0de5ed6d192921bd7b016296c8b2a42b3fcf1176663e182320d9e packages/webui/tests/helpers/fake-oidc.mjs
ac01037743fcbf1efa641794d3878f113b6206033d2f66cde072a52ccfae1a51 packages/webui/tests/service.test.mjs ac01037743fcbf1efa641794d3878f113b6206033d2f66cde072a52ccfae1a51 packages/webui/tests/service.test.mjs
9358e3755e174b61532e2c528e733ffcdb48941cbadb825f1b0254bc55d1920e packages/webui/tests/sso-browser.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 f8307d4bd998634467fbed8748157754132b76affac051f320196161ce385e4a scripts/mosaic-config.mjs
3301c1fabf616bb961d62f6decd0a46dadb0f399b8ba7f3c2ca60023d2b24c78 scripts/test-config.sh 3301c1fabf616bb961d62f6decd0a46dadb0f399b8ba7f3c2ca60023d2b24c78 scripts/test-config.sh
7678bb6b45861c3dc6d775fc99269b1db7350ced60b89517d6ebeb4cc161781c scripts/webui-service.sh 7678bb6b45861c3dc6d775fc99269b1db7350ced60b89517d6ebeb4cc161781c scripts/webui-service.sh
@@ -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'), ('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'), ('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'), ('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() sha = lambda b: hashlib.sha256(b).hexdigest()
only = sys.argv[2:] only = sys.argv[2:]
+2 -1
View File
@@ -61,8 +61,9 @@ async function body(req, max = 4096) {
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback. // 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 BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
const DECISION_LISTS = ['roles', 'recent']; 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; 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; 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) { async function busRead(res, bus, verb, search) {
let arg; let arg;
+12 -7
View File
@@ -196,13 +196,18 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
map.set(key, value); map.set(key, value);
}; };
// B2: only a path on publicOrigin itself; anything else, or nothing, is `/`. // B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The
function returnPath(ret) { // 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 { try {
if (typeof ret !== 'string' || !ret) return '/'; if (typeof ret !== 'string' || !ret) return home;
const u = new URL(ret, publicOrigin); const u = new URL(ret, publicOrigin);
return u.origin === publicOrigin ? u.pathname + u.search : '/'; if (u.origin !== publicOrigin) return home;
} catch { return '/'; } 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) { 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. // Step 7: the session, then the return path.
const id = randomBytes(32).toString('base64url'), now = Date.now(); 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); 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). // 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 { 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])), 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))], 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 })), summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
+13 -7
View File
@@ -109,7 +109,7 @@ test('positive: an RS256 provider signs in and sends one message, and the sessio
assert.notEqual(env.port, 7332); assert.notEqual(env.port, 7332);
const { r, session } = await signIn(env, { ret: '/board?x=1' }); const { r, session } = await signIn(env, { ret: '/board?x=1' });
assert.equal(r.status, 303); 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); assert.ok(session);
const send = await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG }); 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)); 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 => { test('14: open redirects land on /, and a path on the Console lands there', async t => {
const env = await setup(t); const env = await setup(t);
for (const [query, where] of [ for (const [query, where] of [
['return=%2F%2Fevil.example', '/'], ['return=%2F%2Fevil.example', `${ORIGIN}/`],
['return=https%3A%2F%2Fevil.example', '/'], ['return=https%3A%2F%2Fevil.example', `${ORIGIN}/`],
['return=%2F%5Cevil.example', '/'], ['return=%2F%5Cevil.example', `${ORIGIN}/`],
['return=/%09/evil.example', '/'], ['return=/%09/evil.example', `${ORIGIN}/`],
['return=%2Fboard%3Fx%3D1', '/board?x=1'], // 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 begun = await begin(env, { query });
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code }); const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
assert.equal(r.status, 303); assert.equal(r.status, 303);
assert.equal(r.headers.location, where, query); assert.equal(r.headers.location, where, query);
assert.equal(new URL(r.headers.location).origin, ORIGIN, query);
PRODUCED.add(sessionOf(r).split('=')[1]); PRODUCED.add(sessionOf(r).split('=')[1]);
} }
}); });