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
2 changes: 1 addition & 1 deletion packages/node/api-util/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@saga-ed/soa-api-util",
"version": "1.1.1",
"version": "1.2.0-dev.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
3 changes: 2 additions & 1 deletion packages/node/api-util/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './custom-types/date-time.js';
export * from './utils/error-util.js';
export * from './utils/error-util.js';
export * from './utils/cors.js';
80 changes: 80 additions & 0 deletions packages/node/api-util/src/utils/cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import {
DATADOG_RUM_TRACING_HEADERS,
buildSagaOriginAllowlist,
originAllowed,
} from './cors.js';

describe('DATADOG_RUM_TRACING_HEADERS', () => {
it('contains all seven Datadog RUM tracing headers', () => {
expect([...DATADOG_RUM_TRACING_HEADERS]).toEqual([
'traceparent',
'tracestate',
'x-datadog-trace-id',
'x-datadog-parent-id',
'x-datadog-origin',
'x-datadog-sampling-priority',
'x-datadog-tags',
]);
});
});

describe('buildSagaOriginAllowlist', () => {
it('dev (default): includes the *.wootdev.com wildcard, NOT *.saga.org', () => {
const list = buildSagaOriginAllowlist({ env: {} });
expect(list.some((e) => e instanceof RegExp && e.source.includes('wootdev'))).toBe(true);
expect(list.some((e) => e instanceof RegExp && e.source.includes('saga'))).toBe(false);
});

it('prod: includes the *.saga.org wildcard, NOT *.wootdev.com', () => {
const list = buildSagaOriginAllowlist({ env: { NODE_ENV: 'production' } });
expect(list.some((e) => e instanceof RegExp && e.source.includes('saga'))).toBe(true);
expect(list.some((e) => e instanceof RegExp && e.source.includes('wootdev'))).toBe(false);
});

it('includes explicit CORS_ORIGIN entries (trimmed, empties dropped)', () => {
const list = buildSagaOriginAllowlist({
env: { CORS_ORIGIN: 'https://a.example.org, https://b.example.org ,' },
});
expect(list).toContain('https://a.example.org');
expect(list).toContain('https://b.example.org');
expect(list).not.toContain('');
});

it('adds devOrigins in non-prod only', () => {
const dev = buildSagaOriginAllowlist({ env: {}, devOrigins: ['http://localhost:5173'] });
expect(dev).toContain('http://localhost:5173');

const prod = buildSagaOriginAllowlist({ env: { NODE_ENV: 'production' }, devOrigins: ['http://localhost:5173'] });
expect(prod).not.toContain('http://localhost:5173');
});
});

describe('originAllowed', () => {
const devList = buildSagaOriginAllowlist({ env: {}, devOrigins: ['http://localhost:5173'] });
const prodList = buildSagaOriginAllowlist({ env: { NODE_ENV: 'production' } });

it('matches an exact string entry', () => {
expect(originAllowed(devList, 'http://localhost:5173')).toBe(true);
});

it('matches multi-level subdomains under the env wildcard', () => {
expect(originAllowed(devList, 'https://pr-12.dash.wootdev.com')).toBe(true);
expect(originAllowed(prodList, 'https://stable.dash.saga.org')).toBe(true);
});

it('isolates: dev rejects saga.org, prod rejects wootdev.com', () => {
expect(originAllowed(devList, 'https://login.saga.org')).toBe(false);
expect(originAllowed(prodList, 'https://login.wootdev.com')).toBe(false);
});

it('rejects unknown / undefined / suffix-attack origins', () => {
expect(originAllowed(devList, 'https://attacker.example.org')).toBe(false);
expect(originAllowed(devList, undefined)).toBe(false);
expect(originAllowed(devList, 'https://wootdev.com.attacker.org')).toBe(false);
});

it('rejects non-https origins', () => {
expect(originAllowed(devList, 'http://app.wootdev.com')).toBe(false);
});
});
94 changes: 94 additions & 0 deletions packages/node/api-util/src/utils/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Shared CORS primitives for Saga backends.
*
* Two things every browser-facing Saga API needs and currently re-implements:
*
* 1. {@link DATADOG_RUM_TRACING_HEADERS} — the distributed-tracing headers
* Datadog browser RUM injects on cross-origin fetches once a frontend
* enables RUM. Every backend a RUM frontend calls must allow ALL of them
* in its CORS `allowedHeaders`; the browser fails the preflight if any one
* is missing (it only ever names one in the error). See hipponot/iac#358.
*
* 2. {@link buildSagaOriginAllowlist} / {@link originAllowed} — an
* environment-isolated origin allowlist: prod (`NODE_ENV=production`)
* trusts only `https://*.saga.org`, dev/preview only `https://*.wootdev.com`.
* Sharing it keeps layer-7 CORS and any Origin/Referer CSRF gate in lockstep
* and prevents dev/prod origin leakage.
*/

/**
* Distributed-tracing headers emitted by `@datadog/browser-rum` (v6, default
* `datadog` + `tracecontext` propagators). Spread into a CORS `allowedHeaders`
* list. `as const` so callers get a readonly tuple; spread into a mutable array
* (`[...DATADOG_RUM_TRACING_HEADERS]`) where a mutable list is required.
*/
export const DATADOG_RUM_TRACING_HEADERS = [
'traceparent',
'tracestate',
'x-datadog-trace-id',
'x-datadog-parent-id',
'x-datadog-origin',
'x-datadog-sampling-priority',
'x-datadog-tags',
] as const;

// Anchored + https-only so a suffix attack (`wootdev.com.attacker.org`) can't
// match. Allows any multi-level subdomain (previews, stable.dash, …) + an
// optional port.
const SAGA_DEV_ORIGIN_REGEX = /^https:\/\/([a-z0-9-]+\.)+wootdev\.com(:\d+)?$/;
const SAGA_PROD_ORIGIN_REGEX = /^https:\/\/([a-z0-9-]+\.)+saga\.org(:\d+)?$/;

export interface SagaOriginAllowlistOptions {
/**
* Env source. `NODE_ENV` selects the prod-vs-dev wildcard; `CORS_ORIGIN`
* (comma-separated) adds explicit origins. Defaults to `process.env`.
*/
env?: Record<string, string | undefined>;
/**
* Extra explicit origins allowed in NON-production only — e.g. local dev
* servers (`http://localhost:5173`). Omitted in prod so localhost can never
* be trusted by a production deploy.
*/
devOrigins?: readonly string[];
}

/**
* Build an environment-isolated CORS / CSRF origin allowlist.
*
* - **prod** (`NODE_ENV==='production'`): explicit `CORS_ORIGIN` entries + the
* `https://*.saga.org` wildcard. No dev origins, no `*.wootdev.com`.
* - **dev / preview**: explicit `CORS_ORIGIN` entries + `devOrigins` + the
* `https://*.wootdev.com` wildcard. No `*.saga.org`.
*
* Pass the result to the `cors` middleware's `origin` and/or to
* {@link originAllowed} for an Origin/Referer CSRF check (one source of truth).
*/
export function buildSagaOriginAllowlist(
opts: SagaOriginAllowlistOptions = {},
): (string | RegExp)[] {
const env = opts.env ?? process.env;
const isProd = env.NODE_ENV === 'production';
const list: (string | RegExp)[] = (env.CORS_ORIGIN ?? '')
.split(',')
.map((o) => o.trim())
.filter((o) => o.length > 0);
if (!isProd && opts.devOrigins) list.push(...opts.devOrigins);
list.push(isProd ? SAGA_PROD_ORIGIN_REGEX : SAGA_DEV_ORIGIN_REGEX);
return list;
}

/**
* Match an `Origin` (or `Referer`-derived origin) against an allowlist built by
* {@link buildSagaOriginAllowlist}. A missing origin is rejected — browsers
* send `Origin` on cross-origin (and most same-origin) state-changing requests,
* so absence is suspicious.
*/
export function originAllowed(
allowlist: readonly (string | RegExp)[],
origin: string | undefined,
): boolean {
if (!origin) return false;
return allowlist.some((entry) =>
typeof entry === 'string' ? entry === origin : entry.test(origin),
);
}
Loading