diff --git a/src/backend/core/http/middleware/authProbe.test.ts b/src/backend/core/http/middleware/authProbe.test.ts index ae586ea72..7589dd6f2 100644 --- a/src/backend/core/http/middleware/authProbe.test.ts +++ b/src/backend/core/http/middleware/authProbe.test.ts @@ -73,6 +73,7 @@ interface ReqInit { query?: Record; handshakeQuery?: Record; 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 ────────────────────────────── diff --git a/src/backend/core/http/middleware/authProbe.ts b/src/backend/core/http/middleware/authProbe.ts index 17693aa3b..c71b2f8c2 100644 --- a/src/backend/core/http/middleware/authProbe.ts +++ b/src/backend/core/http/middleware/authProbe.ts @@ -44,7 +44,7 @@ interface AuthProbeOptions { * 1. `req.body.auth_token` * 2. `Authorization: Bearer ` 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.