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:
1 parent
83b32d8d86
commit
37582e8205
6 files changed
+69
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user