From 2399b2f719fbbe09b880cd1841341019b76d2fdb Mon Sep 17 00:00:00 2001 From: Keith Fawcett Date: Wed, 8 Jul 2026 11:10:04 -0700 Subject: [PATCH] fix(api): open CORS on /attribution/identify for brand-site origins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser SDK's stitch call comes from the BRAND'S OWN website origin (acme.com) — never in our allowlist — so identify() was CORS-blocked for every hosted tenant unless ops hand-added their site to CORS_EXTRA_ORIGINS. The endpoint is deliberately unauthenticated, rate-limited, cookie-free, and returns nothing sensitive, so it gets analytics-collector CORS: any origin, NO credentials. Every other route keeps the strict allowlist + credentials (pinned by test). Co-Authored-By: Claude Fable 5 --- apps/api/src/__tests__/identify-cors.test.ts | 48 ++++++++++++++++++++ apps/api/src/app.ts | 24 +++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/__tests__/identify-cors.test.ts diff --git a/apps/api/src/__tests__/identify-cors.test.ts b/apps/api/src/__tests__/identify-cors.test.ts new file mode 100644 index 00000000..0a510061 --- /dev/null +++ b/apps/api/src/__tests__/identify-cors.test.ts @@ -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(); + } + }); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 56615377..a758fc03 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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