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
23 changes: 19 additions & 4 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<API_BASE_URL>/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.
Expand Down
49 changes: 16 additions & 33 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
172 changes: 172 additions & 0 deletions backend/src/config/cors.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>): ParsedOrigins {
const origins = new Set<string>();
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<string>): 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,
};
}
Loading
Loading