Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/api/src/__tests__/identify-cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Open CORS on /attribution/identify (the browser SDK's stitch call).
*
* The call originates from the BRAND'S OWN website origin, which can never
* be in our allowlist ahead of time — so this one path gets
* analytics-collector CORS (any origin, no credentials), while every other
* route keeps the strict allowlist. Preflight-only tests: no DB needed.
*/

import { describe, expect, it } from 'vitest';
import request from 'supertest';
import { createApp } from '../app.js';

const app = createApp({ enableLogger: false });

describe('identify CORS carve-out', () => {
it('preflights /attribution/identify from any origin, without credentials', async () => {
const res = await request(app)
.options('/attribution/identify')
.set('Origin', 'https://xispark.com')
.set('Access-Control-Request-Method', 'POST')
.set('Access-Control-Request-Headers', 'content-type');
expect(res.headers['access-control-allow-origin']).toBe('https://xispark.com');
// No credentials on the open route — a reflected origin + credentials
// would be the CSRF foot-gun the strict allowlist exists to prevent.
expect(res.headers['access-control-allow-credentials']).toBeUndefined();
});

it('covers the tenant-scoped path shapes', async () => {
for (const path of ['/t/xispark/attribution/identify', '/api/t/xispark/attribution/identify']) {
const res = await request(app)
.options(path)
.set('Origin', 'https://brand-site.example')
.set('Access-Control-Request-Method', 'POST');
expect(res.headers['access-control-allow-origin']).toBe('https://brand-site.example');
}
});

it('does NOT open any other route — the strict allowlist still governs', async () => {
for (const path of ['/attribution/events', '/partners', '/t/xispark/config/program']) {
const res = await request(app)
.options(path)
.set('Origin', 'https://evil.example')
.set('Access-Control-Request-Method', 'POST');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
}
});
});
24 changes: 18 additions & 6 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,29 @@ export function createApp(options: { enableLogger?: boolean } = {}) {
if (corsOrigins.length === 0 && process.env.NODE_ENV === 'production') {
throw new Error('PORTAL_URL must be set in production so CORS has an origin allowlist');
}
// /attribution/identify is the browser SDK's stitch call, made from the
// BRAND'S OWN website origin (acme.com) — an origin we can never
// allowlist ahead of time. It gets analytics-collector CORS: any origin,
// NO credentials (the endpoint is deliberately unauthenticated,
// rate-limited, reads no cookies, and returns nothing sensitive — see
// routes/identify.ts). Mounted BEFORE the global CORS so it also owns
// the OPTIONS preflight for this path; the global middleware runs after
// but never unsets headers, so the open ACAO survives for this route
// only. Everything else keeps the strict allowlist + credentials.
const IDENTIFY_PATH = /^(?:\/(?:api\/)?t\/[a-z0-9-]+)?\/attribution\/identify\/?$/;
const identifyCors = cors({ origin: true, credentials: false });
app.use((req, res, next) => (IDENTIFY_PATH.test(req.path) ? identifyCors(req, res, next) : next()));

// Origin callback = static seed allowlist ∪ cached verified custom
// domains (white-label; see cors-origins.ts). Still no `origin: true` —
// an origin is echoed back only after it matches one of the two sets;
// anything else gets no Access-Control-Allow-Origin at all. Requests
// without an Origin header (same-origin, curl) skip the DB entirely.
app.use(
cors({
origin: corsOriginDecider(new Set(corsOrigins)),
credentials: true,
}),
);
const strictCors = cors({
origin: corsOriginDecider(new Set(corsOrigins)),
credentials: true,
});
app.use((req, res, next) => (IDENTIFY_PATH.test(req.path) ? next() : strictCors(req, res, next)));
app.use(cookieParser());

// CSRF: we deliberately do NOT mount a CSRF-token middleware. Defense
Expand Down
Loading