diff --git a/backend/.env.example b/backend/.env.example index 6777ff34..54dbddf9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,12 +2,27 @@ # Port the Express server listens on (default: 3001) PORT=3001 -# Application environment mode ('development', 'production', or 'test') +# Application environment mode ('development', 'production', or 'test'). +# Deployed environments MUST set 'production': any other value (or leaving it +# unset) applies the relaxed development CORS policy. NODE_ENV=development -# Comma-separated list of allowed origins for CORS. In development, if unset, -# defaults to http://localhost:3000 -CORS_ALLOWED_ORIGINS="https://app.flowfi.xyz,https://flowfi.xyz" +# ─── CORS ───────────────────────────────────────────────────────────────────── +# Frontend origin(s) allowed to call the API from a browser. Comma-separate +# multiple origins (e.g. production + preview domains). Each entry is reduced to +# its origin (scheme + host + port), so a trailing slash or path is ignored. +# REQUIRED when NODE_ENV=production: the server refuses to start if this (or +# CORS_ALLOWED_ORIGINS) is missing or contains an invalid URL. +# In development, if neither is set, defaults to http://localhost:3000 +FRONTEND_URL=http://localhost:3000 + +# Additional allowed origins, merged with FRONTEND_URL. Still supported for +# existing deployments; new deployments can use FRONTEND_URL alone. +CORS_ALLOWED_ORIGINS= + +# How long (seconds) browsers may cache a preflight response in production +# (default: 7200). Browsers cap this (Chromium at 7200, Firefox at 86400). +CORS_MAX_AGE_SECONDS=7200 # Optional base URL for Swagger OpenAPI documentation server (e.g. http://localhost:3001) API_BASE_URL=http://localhost:3001 diff --git a/backend/README.md b/backend/README.md index fe646d13..82fa87a8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -26,6 +26,23 @@ API_BASE_URL=https://api.staging.flowfi.io - `API_BASE_URL`: Overrides the Swagger UI server URL for the deployed environment (e.g., staging or production). When set, Swagger UI targets `/v1` instead of the hardcoded defaults. +## CORS + +The CORS policy is built in `src/config/cors.ts` when the app starts and depends on `NODE_ENV`: + +| | `NODE_ENV=production` | Any other value (or unset) | +|---|---|---| +| Allowed origins | `FRONTEND_URL` + `CORS_ALLOWED_ORIGINS`, exact match | Same, or `http://localhost:3000` if neither is set | +| Allowed request headers | `Content-Type`, `Authorization`, `X-Request-ID` | Whatever the preflight requests | +| Allowed methods | `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` | `cors` defaults | +| Preflight cache (`Access-Control-Max-Age`) | `CORS_MAX_AGE_SECONDS` (default `7200`) | Not set | +| Credentials | Allowed | Allowed | + +- **`FRONTEND_URL` is required in production.** Set it to the frontend origin, e.g. `FRONTEND_URL=https://app.flowfi.xyz`. Comma-separate several origins (e.g. preview domains). Each entry is normalised to its origin, so `https://app.flowfi.xyz/` works too. If no valid origin is configured, or any entry is not an absolute `http(s)` URL (including `*`), the server throws at startup instead of running with an open policy. +- **Set `NODE_ENV=production` in every deployed environment.** Otherwise the development policy applies (headers and methods unrestricted). The server logs a warning at startup when `NODE_ENV` is unset. +- Browser requests from any other origin get `403 {"error": "CORS origin not allowed"}` and no `Access-Control-Allow-Origin` header. The rejected origin is logged at `warn` level. +- Requests without an `Origin` header (health checks, webhooks, curl, server-to-server calls) are not affected. + ## Prisma Database We use Prisma as our ORM to interact with PostgreSQL. diff --git a/backend/src/app.ts b/backend/src/app.ts index 3aaa07c2..ca787058 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,22 +14,17 @@ import { sandboxMiddleware } from "./middleware/sandbox.middleware.js"; import { globalRateLimiter } from "./middleware/rate-limiter.middleware.js"; import { metricsMiddleware } from "./middleware/metrics.middleware.js"; import { requestIdMiddleware } from "./middleware/requestId.js"; +import { buildCorsOptions, CorsError } from "./config/cors.js"; +import logger from "./logger.js"; import v1Routes from "./routes/v1/index.js"; import healthRoutes from "./routes/health.routes.js"; import metricsRoutes from "./routes/metrics.routes.js"; const app = express(); -const isProduction = process.env.NODE_ENV === "production"; -const rawCors = process.env.CORS_ALLOWED_ORIGINS ?? ""; -const allowedOrigins = rawCors - .split(",") - .map((origin) => origin.trim()) - .filter(Boolean); - -// Default in development to only localhost:3000 (frontend dev server) -if (!process.env.CORS_ALLOWED_ORIGINS && !isProduction) { - allowedOrigins.push("http://localhost:3000"); -} + +// Resolved once at startup; throws in production when FRONTEND_URL is missing +// or invalid, so the server never runs with an open CORS policy. +const corsOptions = buildCorsOptions(); // Apply global rate limiter first app.use(globalRateLimiter); @@ -66,31 +61,19 @@ app.use((req: Request, res: Response, next: NextFunction) => { next(); }); -app.use( - cors({ - origin(origin, callback) { - // Allow non-browser clients (no Origin header) - if (!origin) { - callback(null, true); - return; - } - - if (allowedOrigins.includes(origin)) { - callback(null, true); - return; - } - - // Not allowed - callback(new Error("CORS origin not allowed")); - }, - credentials: true, - }), -); +// CORS runs before the body parser, auth and routes, so preflight OPTIONS +// requests are answered here and never reach route-level auth. +app.use(cors(corsOptions)); // Convert CORS errors into 403 responses so callers get a clear status code app.use((err: unknown, req: Request, res: Response, next: NextFunction) => { - if (err instanceof Error && err.message === "CORS origin not allowed") { - res.status(403).json({ error: "CORS origin not allowed" }); + if (err instanceof CorsError) { + logger.warn("CORS origin rejected", { + origin: err.origin.slice(0, 256), + method: req.method, + path: req.path, + }); + res.status(err.statusCode).json({ error: err.message }); return; } next(err); diff --git a/backend/src/config/cors.ts b/backend/src/config/cors.ts new file mode 100644 index 00000000..cc9fd24b --- /dev/null +++ b/backend/src/config/cors.ts @@ -0,0 +1,172 @@ +/** + * CORS Configuration + * + * Builds the options for the `cors` middleware from environment variables. + * The options are resolved when the app is created (not per request), so a + * misconfigured production deployment fails at startup instead of serving + * traffic with the wrong policy. + * + * Production (NODE_ENV=production): + * - Only the origins listed in FRONTEND_URL / CORS_ALLOWED_ORIGINS are allowed, + * compared by exact match after normalising each entry to its origin. + * - Allowed request headers and methods are restricted to what the API uses. + * - Preflight responses are cacheable for CORS_MAX_AGE_SECONDS. + * - A missing or invalid origin list throws: the app never falls back to a + * permissive policy. + * + * Any other NODE_ENV keeps the development policy: the configured origins (or + * http://localhost:3000 when none are set), with request headers and methods + * left unrestricted. + * + * Requests without an Origin header (webhooks, health checks, curl, + * server-to-server calls) are never rejected: CORS is enforced by browsers, and + * browsers always send Origin on cross-origin requests. + */ +import type { CorsOptions } from 'cors'; +import logger from '../logger.js'; + +export const DEFAULT_DEV_ORIGIN = 'http://localhost:3000'; + +/** + * How long browsers may cache a preflight response. Browsers cap this value + * (Chromium at 2 hours, Firefox at 24 hours), so larger values are ignored. + */ +export const DEFAULT_CORS_MAX_AGE_SECONDS = 7200; + +export const PRODUCTION_ALLOWED_HEADERS = ['Content-Type', 'Authorization', 'X-Request-ID']; + +// PATCH is not in the list from issue #1491, but the frontend updates webhook +// subscriptions with PATCH /v1/webhooks/:id, so leaving it out would break that flow. +export const PRODUCTION_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']; + +/** Raised by the origin check when a browser request comes from an origin that is not allowed. */ +export class CorsError extends Error { + readonly statusCode = 403; + readonly origin: string; + + constructor(origin: string) { + super('CORS origin not allowed'); + this.name = 'CorsError'; + this.origin = origin; + } +} + +/** + * Reduce a configured URL to its origin (scheme + host + port), which is the + * form browsers send in the Origin header. Returns null for anything that is + * not an absolute http(s) URL without credentials. + */ +export function normalizeOrigin(value: string): string | null { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + if (url.username || url.password) return null; + + return url.origin; +} + +interface ParsedOrigins { + origins: string[]; + invalid: string[]; +} + +function parseOriginList(...values: Array): ParsedOrigins { + const origins = new Set(); + const invalid: string[] = []; + + for (const value of values) { + if (!value) continue; + for (const entry of value.split(',').map((item) => item.trim()).filter(Boolean)) { + const origin = normalizeOrigin(entry); + if (origin) origins.add(origin); + else invalid.push(entry); + } + } + + return { origins: [...origins], invalid }; +} + +function parseMaxAge(value: string | undefined): number { + if (value === undefined || value.trim() === '') return DEFAULT_CORS_MAX_AGE_SECONDS; + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error( + `CORS_MAX_AGE_SECONDS has invalid value '${value}'. Expected a non-negative integer number of seconds.`, + ); + } + return parsed; +} + +function createOriginCheck(allowedOrigins: ReadonlySet): CorsOptions['origin'] { + return (origin, callback) => { + // No Origin header: not a cross-origin browser request, so there is + // nothing for CORS to enforce. Let it through without Allow-Origin. + if (!origin) { + callback(null, true); + return; + } + + // Exact match only: no prefix, suffix or pattern matching. + if (allowedOrigins.has(origin)) { + callback(null, true); + return; + } + + callback(new CorsError(origin)); + }; +} + +/** + * Reads NODE_ENV, FRONTEND_URL, CORS_ALLOWED_ORIGINS and CORS_MAX_AGE_SECONDS. + * Throws in production when the origin list is missing or invalid. + */ +export function buildCorsOptions(env: NodeJS.ProcessEnv = process.env): CorsOptions { + const { origins, invalid } = parseOriginList(env.FRONTEND_URL, env.CORS_ALLOWED_ORIGINS); + + if (env.NODE_ENV === 'production') { + if (invalid.length > 0) { + throw new Error( + `Invalid CORS origin(s) in FRONTEND_URL/CORS_ALLOWED_ORIGINS: ${invalid.join(', ')}. ` + + 'Expected absolute http(s) URLs such as https://app.flowfi.xyz.', + ); + } + if (origins.length === 0) { + throw new Error( + 'FRONTEND_URL is required when NODE_ENV=production: set it to the frontend origin ' + + '(e.g. https://app.flowfi.xyz, comma-separate multiple origins). ' + + 'Refusing to start with an open CORS policy.', + ); + } + + return { + origin: createOriginCheck(new Set(origins)), + credentials: true, + methods: PRODUCTION_ALLOWED_METHODS, + allowedHeaders: PRODUCTION_ALLOWED_HEADERS, + maxAge: parseMaxAge(env.CORS_MAX_AGE_SECONDS), + optionsSuccessStatus: 204, + }; + } + + if (!env.NODE_ENV) { + logger.warn( + 'NODE_ENV is not set; using the development CORS policy. Set NODE_ENV=production in deployed environments.', + ); + } + if (invalid.length > 0) { + logger.warn('Ignoring invalid CORS origin(s) in FRONTEND_URL/CORS_ALLOWED_ORIGINS', { invalid }); + } + + const devOrigins = env.FRONTEND_URL || env.CORS_ALLOWED_ORIGINS ? origins : [DEFAULT_DEV_ORIGIN]; + + return { + origin: createOriginCheck(new Set(devOrigins)), + credentials: true, + }; +} diff --git a/backend/tests/cors.config.test.ts b/backend/tests/cors.config.test.ts new file mode 100644 index 00000000..02c25145 --- /dev/null +++ b/backend/tests/cors.config.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { CorsOptions } from 'cors'; + +vi.mock('../src/logger.js', () => ({ + default: { error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +import logger from '../src/logger.js'; +import { + buildCorsOptions, + normalizeOrigin, + CorsError, + DEFAULT_CORS_MAX_AGE_SECONDS, +} from '../src/config/cors.js'; + +const FRONTEND = 'https://app.flowfi.xyz'; + +/** Run the configured origin callback the way the cors middleware does. */ +function checkOrigin( + options: CorsOptions, + origin: string | undefined, +): Promise<{ err: Error | null; allowed: unknown }> { + const check = options.origin; + if (typeof check !== 'function') throw new Error('expected an origin callback'); + return new Promise((resolve) => { + check(origin, (err, allowed) => resolve({ err, allowed })); + }); +} + +describe('normalizeOrigin', () => { + it.each([ + ['https://app.flowfi.xyz', 'https://app.flowfi.xyz'], + ['https://app.flowfi.xyz/', 'https://app.flowfi.xyz'], + ['https://app.flowfi.xyz/dashboard?tab=1#top', 'https://app.flowfi.xyz'], + [' https://APP.FlowFi.xyz ', 'https://app.flowfi.xyz'], + ['https://app.flowfi.xyz:443', 'https://app.flowfi.xyz'], + ['http://localhost:3000/', 'http://localhost:3000'], + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeOrigin(input)).toBe(expected); + }); + + it.each(['*', 'app.flowfi.xyz', 'not a url', 'ftp://app.flowfi.xyz', 'javascript:alert(1)', 'https://user:pass@app.flowfi.xyz', ''])( + 'rejects %j', + (input) => { + expect(normalizeOrigin(input)).toBeNull(); + }, + ); +}); + +describe('buildCorsOptions (production)', () => { + beforeEach(() => { + vi.mocked(logger.warn).mockClear(); + }); + + it('restricts methods, headers and caches preflights', () => { + const options = buildCorsOptions({ NODE_ENV: 'production', FRONTEND_URL: FRONTEND }); + + expect(options.methods).toEqual(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']); + expect(options.allowedHeaders).toEqual(['Content-Type', 'Authorization', 'X-Request-ID']); + expect(options.maxAge).toBe(DEFAULT_CORS_MAX_AGE_SECONDS); + expect(options.optionsSuccessStatus).toBe(204); + expect(options.credentials).toBe(true); + }); + + it('allows the configured origin by exact match', async () => { + const options = buildCorsOptions({ NODE_ENV: 'production', FRONTEND_URL: FRONTEND }); + + await expect(checkOrigin(options, FRONTEND)).resolves.toEqual({ err: null, allowed: true }); + }); + + it('allows requests without an Origin header', async () => { + const options = buildCorsOptions({ NODE_ENV: 'production', FRONTEND_URL: FRONTEND }); + + await expect(checkOrigin(options, undefined)).resolves.toEqual({ err: null, allowed: true }); + }); + + it('rejects other origins with a CorsError', async () => { + const options = buildCorsOptions({ NODE_ENV: 'production', FRONTEND_URL: FRONTEND }); + + const { err } = await checkOrigin(options, 'https://evil.example'); + expect(err).toBeInstanceOf(CorsError); + expect((err as CorsError).origin).toBe('https://evil.example'); + expect((err as CorsError).statusCode).toBe(403); + }); + + it('merges FRONTEND_URL and CORS_ALLOWED_ORIGINS', async () => { + const options = buildCorsOptions({ + NODE_ENV: 'production', + FRONTEND_URL: `${FRONTEND}, https://preview.flowfi.xyz`, + CORS_ALLOWED_ORIGINS: 'https://flowfi.xyz', + }); + + for (const origin of [FRONTEND, 'https://preview.flowfi.xyz', 'https://flowfi.xyz']) { + await expect(checkOrigin(options, origin)).resolves.toEqual({ err: null, allowed: true }); + } + }); + + it('accepts CORS_ALLOWED_ORIGINS alone for existing deployments', async () => { + const options = buildCorsOptions({ NODE_ENV: 'production', CORS_ALLOWED_ORIGINS: FRONTEND }); + + await expect(checkOrigin(options, FRONTEND)).resolves.toEqual({ err: null, allowed: true }); + }); + + it.each([ + [{}], + [{ FRONTEND_URL: '' }], + [{ FRONTEND_URL: ' , ' }], + ])('throws when no origin is configured (%j)', (env) => { + expect(() => buildCorsOptions({ NODE_ENV: 'production', ...env })).toThrow( + /FRONTEND_URL is required when NODE_ENV=production/, + ); + }); + + it.each(['*', 'app.flowfi.xyz', 'ftp://app.flowfi.xyz'])('throws on invalid FRONTEND_URL %j', (value) => { + expect(() => buildCorsOptions({ NODE_ENV: 'production', FRONTEND_URL: value })).toThrow(/Invalid CORS origin/); + }); + + it('throws when one entry of a list is invalid', () => { + expect(() => + buildCorsOptions({ NODE_ENV: 'production', FRONTEND_URL: `${FRONTEND},*` }), + ).toThrow(/Invalid CORS origin\(s\) in FRONTEND_URL\/CORS_ALLOWED_ORIGINS: \*/); + }); + + it('reads the preflight max-age from CORS_MAX_AGE_SECONDS', () => { + const options = buildCorsOptions({ + NODE_ENV: 'production', + FRONTEND_URL: FRONTEND, + CORS_MAX_AGE_SECONDS: '600', + }); + + expect(options.maxAge).toBe(600); + }); + + it.each(['abc', '-1', '1.5'])('throws on invalid CORS_MAX_AGE_SECONDS %j', (value) => { + expect(() => + buildCorsOptions({ NODE_ENV: 'production', FRONTEND_URL: FRONTEND, CORS_MAX_AGE_SECONDS: value }), + ).toThrow(/CORS_MAX_AGE_SECONDS/); + }); +}); + +describe('buildCorsOptions (development)', () => { + beforeEach(() => { + vi.mocked(logger.warn).mockClear(); + }); + + it('leaves headers, methods and max-age unrestricted', () => { + const options = buildCorsOptions({ NODE_ENV: 'development' }); + + expect(options.methods).toBeUndefined(); + expect(options.allowedHeaders).toBeUndefined(); + expect(options.maxAge).toBeUndefined(); + expect(options.credentials).toBe(true); + }); + + it('defaults to the local frontend dev server when no origin is configured', async () => { + const options = buildCorsOptions({ NODE_ENV: 'development' }); + + await expect(checkOrigin(options, 'http://localhost:3000')).resolves.toEqual({ err: null, allowed: true }); + expect((await checkOrigin(options, 'https://evil.example')).err).toBeInstanceOf(CorsError); + }); + + it('uses the configured origins instead of the default when set', async () => { + const options = buildCorsOptions({ NODE_ENV: 'development', FRONTEND_URL: 'http://localhost:4000' }); + + await expect(checkOrigin(options, 'http://localhost:4000')).resolves.toEqual({ err: null, allowed: true }); + expect((await checkOrigin(options, 'http://localhost:3000')).err).toBeInstanceOf(CorsError); + }); + + it('does not throw on a missing or invalid origin list', () => { + expect(() => buildCorsOptions({ NODE_ENV: 'development', FRONTEND_URL: '*' })).not.toThrow(); + expect(logger.warn).toHaveBeenCalledWith( + 'Ignoring invalid CORS origin(s) in FRONTEND_URL/CORS_ALLOWED_ORIGINS', + { invalid: ['*'] }, + ); + }); + + it('warns when NODE_ENV is unset', () => { + buildCorsOptions({}); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('NODE_ENV is not set')); + }); + + it('does not warn about NODE_ENV when it is set', () => { + buildCorsOptions({ NODE_ENV: 'test' }); + + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/cors.test.ts b/backend/tests/cors.test.ts index 53ae7f52..b8a5c771 100644 --- a/backend/tests/cors.test.ts +++ b/backend/tests/cors.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Express } from 'express'; import request from 'supertest'; import app from '../src/app.js'; @@ -13,3 +14,254 @@ describe('CORS middleware', () => { expect(response.body.error).toBe('CORS origin not allowed'); }); }); + +/** + * The CORS policy is resolved when the app module is evaluated, so each case + * resets the module registry and re-imports the app with a fresh environment. + */ +const ENV_KEYS = ['NODE_ENV', 'FRONTEND_URL', 'CORS_ALLOWED_ORIGINS', 'CORS_MAX_AGE_SECONDS', 'JWT_SECRET'] as const; +type CorsTestEnv = { [K in (typeof ENV_KEYS)[number]]?: string | undefined }; + +const FRONTEND = 'https://app.flowfi.xyz'; +// auth.ts refuses to load in production without a 32+ byte secret. +const JWT_SECRET = 'cors-test-secret-that-is-at-least-32-bytes-long'; + +async function loadApp(env: CorsTestEnv): Promise { + for (const key of ENV_KEYS) delete process.env[key]; + // Skip undefined values: assigning undefined to process.env stores the string "undefined". + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) process.env[key] = value; + } + vi.resetModules(); + const { default: freshApp } = await import('../src/app.js'); + return freshApp; +} + +function loadProductionApp(env: CorsTestEnv = {}): Promise { + return loadApp({ NODE_ENV: 'production', JWT_SECRET, FRONTEND_URL: FRONTEND, ...env }); +} + +describe('CORS policy', () => { + const savedEnv: CorsTestEnv = {}; + + beforeEach(() => { + for (const key of ENV_KEYS) savedEnv[key] = process.env[key]; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + vi.restoreAllMocks(); + }); + + describe('production', () => { + it('reflects an allowed origin and varies on Origin', async () => { + const prodApp = await loadProductionApp(); + + const response = await request(prodApp).get('/').set('Origin', FRONTEND); + + expect(response.status).toBe(200); + expect(response.headers['access-control-allow-origin']).toBe(FRONTEND); + expect(response.headers['access-control-allow-credentials']).toBe('true'); + expect(response.headers.vary).toMatch(/\bOrigin\b/); + }); + + it('rejects a disallowed origin with a 403 JSON error, no Allow-Origin, and a warning log', async () => { + const prodApp = await loadProductionApp(); + const { default: logger } = await import('../src/logger.js'); + const warn = vi.spyOn(logger, 'warn'); + + const response = await request(prodApp).get('/').set('Origin', 'https://evil.example'); + + expect(response.status).toBe(403); + expect(response.headers['content-type']).toMatch(/application\/json/); + expect(response.body).toEqual({ error: 'CORS origin not allowed' }); + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + 'CORS origin rejected', + expect.objectContaining({ origin: 'https://evil.example', method: 'GET', path: '/' }), + ); + }); + + it.each([ + ['frontend host under another domain', 'https://app.flowfi.xyz.evil.com'], + ['http instead of https', 'http://app.flowfi.xyz'], + ['different port', 'https://app.flowfi.xyz:8443'], + ['subdomain of the frontend', 'https://evil.app.flowfi.xyz'], + ['shared suffix', 'https://evilapp.flowfi.xyz'], + ['parent domain', 'https://flowfi.xyz'], + ['trailing slash', 'https://app.flowfi.xyz/'], + ['uppercase host', 'https://APP.flowfi.xyz'], + ['opaque origin', 'null'], + ])('rejects a lookalike origin (%s)', async (_label, origin) => { + const prodApp = await loadProductionApp(); + + const response = await request(prodApp).get('/').set('Origin', origin); + + expect(response.status).toBe(403); + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('matches a configured origin written with a trailing slash or path', async () => { + const prodApp = await loadProductionApp({ FRONTEND_URL: 'https://app.flowfi.xyz/dashboard/' }); + + const response = await request(prodApp).get('/').set('Origin', FRONTEND); + + expect(response.status).toBe(200); + expect(response.headers['access-control-allow-origin']).toBe(FRONTEND); + }); + + it('passes requests without an Origin header through without CORS headers', async () => { + const prodApp = await loadProductionApp(); + + const response = await request(prodApp).get('/'); + + expect(response.status).toBe(200); + expect(response.text).toBe('FlowFi Backend is running'); + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('answers an allowed preflight with 204 and cacheable method/header lists', async () => { + const prodApp = await loadProductionApp(); + + const response = await request(prodApp) + .options('/v1/webhooks/abc') + .set('Origin', FRONTEND) + .set('Access-Control-Request-Method', 'PATCH') + .set('Access-Control-Request-Headers', 'content-type,authorization,x-request-id'); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-origin']).toBe(FRONTEND); + expect(response.headers['access-control-allow-methods']).toBe('GET,POST,PUT,PATCH,DELETE,OPTIONS'); + expect(response.headers['access-control-allow-headers']).toBe('Content-Type,Authorization,X-Request-ID'); + expect(response.headers['access-control-max-age']).toBe('7200'); + }); + + it('uses CORS_MAX_AGE_SECONDS for the preflight max-age', async () => { + const prodApp = await loadProductionApp({ CORS_MAX_AGE_SECONDS: '600' }); + + const response = await request(prodApp) + .options('/v1/streams') + .set('Origin', FRONTEND) + .set('Access-Control-Request-Method', 'POST'); + + expect(response.headers['access-control-max-age']).toBe('600'); + }); + + it('does not permit headers or methods outside the allowlist', async () => { + const prodApp = await loadProductionApp(); + + const response = await request(prodApp) + .options('/v1/streams') + .set('Origin', FRONTEND) + .set('Access-Control-Request-Method', 'TRACE') + .set('Access-Control-Request-Headers', 'x-sandbox-mode,x-custom-header'); + + // The browser compares the request against these lists and blocks the + // actual request; the server must not echo the requested values back. + expect(response.headers['access-control-allow-methods']).toBe('GET,POST,PUT,PATCH,DELETE,OPTIONS'); + expect(response.headers['access-control-allow-headers']).toBe('Content-Type,Authorization,X-Request-ID'); + expect(response.headers.vary ?? '').not.toMatch(/Access-Control-Request-Headers/i); + }); + + it('rejects a preflight from a disallowed origin', async () => { + const prodApp = await loadProductionApp(); + + const response = await request(prodApp) + .options('/v1/streams') + .set('Origin', 'https://evil.example') + .set('Access-Control-Request-Method', 'POST'); + + expect(response.status).toBe(403); + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + expect(response.headers['access-control-allow-methods']).toBeUndefined(); + }); + + it('answers preflights for authenticated routes without running auth', async () => { + const prodApp = await loadProductionApp(); + + // The route itself requires a Bearer token... + const unauthenticated = await request(prodApp).get('/v1/users/me').set('Origin', FRONTEND); + expect(unauthenticated.status).toBe(401); + + // ...but its preflight is answered by CORS before auth runs. + const preflight = await request(prodApp) + .options('/v1/users/me') + .set('Origin', FRONTEND) + .set('Access-Control-Request-Method', 'GET') + .set('Access-Control-Request-Headers', 'authorization'); + + expect(preflight.status).toBe(204); + expect(preflight.headers['access-control-allow-origin']).toBe(FRONTEND); + }); + + it('allows each of several comma-separated origins and rejects others', async () => { + const prodApp = await loadProductionApp({ + FRONTEND_URL: `${FRONTEND}, https://preview.flowfi.xyz`, + }); + + for (const origin of [FRONTEND, 'https://preview.flowfi.xyz']) { + const response = await request(prodApp).get('/').set('Origin', origin); + expect(response.status).toBe(200); + expect(response.headers['access-control-allow-origin']).toBe(origin); + } + + const rejected = await request(prodApp).get('/').set('Origin', 'https://other.flowfi.xyz'); + expect(rejected.status).toBe(403); + }); + + it('still honours CORS_ALLOWED_ORIGINS for existing deployments', async () => { + const prodApp = await loadProductionApp({ FRONTEND_URL: undefined, CORS_ALLOWED_ORIGINS: FRONTEND }); + + const response = await request(prodApp).get('/').set('Origin', FRONTEND); + + expect(response.status).toBe(200); + expect(response.headers['access-control-allow-origin']).toBe(FRONTEND); + }); + + it.each([ + ['missing', undefined], + ['empty', ''], + ])('refuses to start when FRONTEND_URL is %s', async (_label, value) => { + await expect(loadProductionApp({ FRONTEND_URL: value })).rejects.toThrow( + /FRONTEND_URL is required when NODE_ENV=production/, + ); + }); + + it.each(['*', 'app.flowfi.xyz', 'not a url'])('refuses to start when FRONTEND_URL is invalid (%j)', async (value) => { + await expect(loadProductionApp({ FRONTEND_URL: value })).rejects.toThrow(/Invalid CORS origin/); + }); + }); + + describe('development', () => { + it('keeps the existing allowlist with unrestricted headers and methods', async () => { + const devApp = await loadApp({ NODE_ENV: 'development' }); + + const response = await request(devApp) + .options('/v1/streams') + .set('Origin', 'http://localhost:3000') + .set('Access-Control-Request-Method', 'POST') + .set('Access-Control-Request-Headers', 'x-sandbox-mode'); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-origin']).toBe('http://localhost:3000'); + expect(response.headers['access-control-allow-headers']).toBe('x-sandbox-mode'); + expect(response.headers['access-control-max-age']).toBeUndefined(); + }); + + it('allows any origin listed in FRONTEND_URL', async () => { + const devApp = await loadApp({ NODE_ENV: 'development', FRONTEND_URL: 'http://localhost:4000' }); + + const response = await request(devApp).get('/').set('Origin', 'http://localhost:4000'); + + expect(response.status).toBe(200); + expect(response.headers['access-control-allow-origin']).toBe('http://localhost:4000'); + }); + + it('starts without FRONTEND_URL', async () => { + await expect(loadApp({ NODE_ENV: 'development' })).resolves.toBeDefined(); + }); + }); +}); diff --git a/render.yaml b/render.yaml index c75757fe..369188c9 100644 --- a/render.yaml +++ b/render.yaml @@ -25,6 +25,8 @@ services: value: info # ─── Required Infrastructure ────────────────────────────────────────── + # The backend refuses to start in production unless FRONTEND_URL and/or + # CORS_ALLOWED_ORIGINS lists at least one valid frontend origin. - key: CORS_ALLOWED_ORIGINS value: "https://app.flowfi.xyz,https://flowfi.xyz" # Replace with your deployed frontend origin(s)