Skip to content

Commit 1e052bc

Browse files
committed
Redirect www to the canonical host, never serve it
Serving the board on both www and the apex means two origins for one board, and that is not merely untidy: a session cookie set on one is not sent to the other, so a magic link opened on www signs you in to a host you then leave — and a passkey registered on the apex cannot be asserted on www at all. Redirecting before anything else runs means no credential is ever minted against a hostname the board does not consider its own. 308 rather than 301 or 302 because only 308 requires the method and body to be preserved. A form POST to www has to arrive at the apex as the same POST, not as a GET that silently drops what somebody had just written. 88 tests, 0 type errors.
1 parent 43bc38d commit 1e052bc

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

‎apps/server/src/app.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,38 @@ export function createApp(registry: Registry, baseUrl: string): Hono<AppEnv> {
4747
});
4848
});
4949

50+
/*
51+
* One canonical host.
52+
*
53+
* www is redirected, never served. Serving both means two origins for the
54+
* same board, and that is not merely untidy: a session cookie set on one is
55+
* not sent to the other, so a magic link opened on www signs you in to a host
56+
* you then leave — and a passkey registered on the apex cannot be asserted on
57+
* www at all. Redirecting first means no credential is ever minted against a
58+
* hostname the board does not consider its own.
59+
*
60+
* 308 rather than 301/302 because a 308 requires the method and body to be
61+
* preserved: a form POST to www must arrive at the apex as the same POST, not
62+
* silently downgraded to a GET that loses what somebody just wrote.
63+
*/
64+
const canonicalHost = (() => {
65+
try {
66+
return new URL(baseUrl).host;
67+
} catch {
68+
return null;
69+
}
70+
})();
71+
72+
app.use('*', async (c, next) => {
73+
const url = new URL(c.req.url);
74+
if (canonicalHost && url.host !== canonicalHost && url.host === `www.${canonicalHost}`) {
75+
url.host = canonicalHost;
76+
url.protocol = new URL(baseUrl).protocol;
77+
return c.redirect(url.toString(), 308);
78+
}
79+
await next();
80+
});
81+
5082
app.use('*', async (c, next) => {
5183
// A bearer token identifies API clients (the TUI); a cookie identifies
5284
// browsers. A token never confers admin, whatever it was minted with.

‎test/e2e.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,27 @@ describe('a board end to end', () => {
373373
}
374374
});
375375

376+
it('redirects www to the canonical host with a 308, method intact', async () => {
377+
// Serving both hosts would mean two origins for one board: a cookie set on
378+
// one is not sent to the other, and a passkey registered on the apex cannot
379+
// be asserted on www. Redirecting first means no credential is ever minted
380+
// against a hostname the board does not consider its own.
381+
//
382+
// 308 and not 302, because only 308 requires the method and body to be
383+
// preserved — a form POST to www must arrive as the same POST, not as a GET
384+
// that silently drops what somebody just wrote.
385+
for (const method of ['GET', 'POST'] as const) {
386+
const response = await app.fetch(
387+
new Request('http://localhost:3999/login'.replace('localhost:3999', 'www.localhost:3999'), {
388+
method,
389+
redirect: 'manual',
390+
}),
391+
);
392+
assert.equal(response.status, 308, method);
393+
assert.equal(response.headers.get('location'), 'http://localhost:3999/login');
394+
}
395+
});
396+
376397
it('reports plugin health', async () => {
377398
const response = await get('/healthz', false);
378399
const health = (await response.json()) as { ok: boolean; plugins: string[] };

0 commit comments

Comments
 (0)