Restrict cross-origin session-cookie auth (#3088)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

* Restrict cross-origin cookie auth

Block the auth probe from using ambient session cookies when a browser Origin does not match the request Host. Explicit bearer, body, and x-api-key tokens continue to work for cross-origin SDK calls.

Co-authored-by: Codex <noreply@openai.com>

* Normalize origin checks for cookie auth

Compare normalized origins for browser cookie authentication so default ports and protocol mismatches are handled consistently. Add coverage for default-port and protocol-mismatch cases.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
slashdevcorpse
2026-05-12 16:40:25 -04:00
committed by GitHub
parent 8ad1a58f98
commit f20445bab7
2 changed files with 139 additions and 5 deletions
@@ -73,6 +73,7 @@ interface ReqInit {
query?: Record<string, unknown>;
handshakeQuery?: Record<string, unknown>;
actor?: Actor;
protocol?: string;
}
const makeReq = (init: ReqInit = {}): Request => {
@@ -82,6 +83,7 @@ const makeReq = (init: ReqInit = {}): Request => {
body: init.body,
query: (init.query ?? {}) as Request['query'],
headers: headers as unknown as Request['headers'],
protocol: init.protocol,
header(name: string) {
// Express's `req.header()` is case-insensitive; mirror that.
return headers[name.toLowerCase()];
@@ -319,6 +321,114 @@ describe('createAuthProbe — cookie reading', () => {
);
expect(stub.seenTokens).toEqual([]);
});
it('ignores session cookies on cross-origin browser requests', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
const { req } = await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test',
origin: 'https://attacker.example',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual([]);
expect(req.actor).toBeUndefined();
expect(req.tokenAuthFailed).toBeUndefined();
});
it('still accepts bearer tokens on cross-origin browser requests', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
authorization: 'Bearer header-tok',
host: 'api.puter.test',
origin: 'https://app.example',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual(['header-tok']);
});
it('keeps session cookies for same-origin browser requests', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test',
origin: 'https://api.puter.test',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual(['session-abc']);
});
it('normalizes default ports when comparing browser origins', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test:443',
origin: 'https://api.puter.test',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual(['session-abc']);
});
it('treats protocol mismatches as cross-origin for session cookies', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test',
origin: 'http://api.puter.test',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual([]);
});
});
// ── Behavior when AuthService responds ──────────────────────────────
+29 -5
View File
@@ -44,7 +44,7 @@ interface AuthProbeOptions {
* 1. `req.body.auth_token`
* 2. `Authorization: Bearer <token>` header
* 3. `x-api-key` header — third-party SDK convention (Anthropic etc.)
* 4. Session cookie
* 4. Session cookie, only when the browser request is same-origin
* 5. `?auth_token=...` query param
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
*/
@@ -126,10 +126,11 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
return stripBearer(xApiKey);
}
// 4. Cookie (set by login flow for session tokens). We parse the
// Cookie header directly rather than depending on `cookie-parser`
// middleware — the probe only needs one named value.
if (cookieName) {
// 4. Cookie (set by login flow for session tokens). Do not let an
// arbitrary browser Origin spend an ambient session cookie against the
// credentialed API CORS surface; bearer/body/x-api-key tokens remain
// available for cross-origin SDK requests.
if (cookieName && !isCrossOriginBrowserRequest(req)) {
const cookieToken = readCookie(req, cookieName);
if (cookieToken) {
return stripBearer(cookieToken);
@@ -157,6 +158,29 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
const stripBearer = (t: string): string => t.replace(/^Bearer\s+/i, '').trim();
const isCrossOriginBrowserRequest = (req: Request): boolean => {
const origin =
typeof req.header === 'function' ? req.header('origin') : undefined;
if (!origin) return false;
const host =
typeof req.header === 'function' ? req.header('host') : undefined;
if (!host) return true;
const protocol =
typeof req.protocol === 'string' && req.protocol.length > 0
? req.protocol
: undefined;
if (!protocol) return true;
try {
const requestOrigin = new URL(`${protocol}://${host.trim()}`).origin;
return new URL(origin).origin !== requestOrigin;
} catch {
return true;
}
};
/**
* Minimal cookie reader. Avoids pulling in `cookie-parser` for the one
* lookup the probe needs. Handles quoted values and URL-decodes the result.