mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-29 21:01:27 +00:00
Restrict cross-origin session-cookie auth (#3088)
* 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:
@@ -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 ──────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user