diff --git a/.env.example b/.env.example index ad4c5f09..82e8b6d3 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,15 @@ SENTRY_AUTH_TOKEN= # build-time only (sourcemap upload); do not set at runtime # origin rejection, and HSTS. Injected automatically by Doppler at runtime. DOPPLER_ENVIRONMENT=dev +# Production edge/origin trust (server-side secrets; do not expose with NUXT_PUBLIC_*). +# In prd, trusted ingress must add x-euler-edge-origin-secret with this value +# after stripping or overwriting client-supplied CF-* forwarding headers and +# x-euler-internal-request. +EDGE_ORIGIN_SECRET= +# Optional server-side shared secret for server-internal $fetch calls. Leave +# empty for a process-local random secret unless internal relative fetches cross workers. +INTERNAL_FETCH_SECRET= + # Fallback country when cf-ipcountry is absent (local dev, PR previews, any env without Cloudflare). # Bypasses fail-closed geo-gate — do not set in production behind Cloudflare. DEV_GEO_COUNTRY=GB diff --git a/docs/architecture.md b/docs/architecture.md index c32e0850..c61633d7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -327,7 +327,7 @@ The Nuxt server layer (`server/api/`) proxies requests to external services (RPC | **Body size limits** (`server/middleware/body-limit.ts`) | Caps request payloads (1 MB RPC, 2 MB Tenderly) | | **Geo-blocking** (`server/middleware/geo-gate.ts`) | Blocks sanctioned countries via Cloudflare `CF-IPCountry`; fails closed (HTTP 451) if country is undetermined in prod | | **RPC method whitelist** (`server/api/rpc/[chainId].ts`) | Only 15 safe read-only methods are proxied | -| **Rate limiting** (`server/utils/rate-limit.ts`) | Per-IP cost-based budgets (see below); fails closed (HTTP 403) if `CF-Connecting-IP` is absent in prod | +| **Rate limiting** (`server/utils/rate-limit.ts`) | Per-IP cost-based budgets (see below); fails closed (HTTP 403) if `CF-Connecting-IP` or the trusted-ingress marker is absent in prod | | **Swap verifier validation** (`utils/swap-validation.ts`) | Validates swap verifier addresses against known config | #### Rate Limiting @@ -349,14 +349,19 @@ The app includes a built-in per-IP rate limiter as a defense-in-depth measure. D **Production deployments must be behind Cloudflare.** This is a hard requirement, not a recommendation — two independent server features depend on it: -1. **Geo-gate** (`server/middleware/geo-gate.ts`) reads `CF-IPCountry` to enforce sanctioned-country blocks. Without Cloudflare, the country cannot be determined and all API requests are rejected with HTTP 451. -2. **Rate limiter** (`server/utils/rate-limit.ts`) uses `CF-Connecting-IP` as the trusted client IP. Without Cloudflare, `CF-Connecting-IP` is absent and all API requests are rejected with HTTP 403. +1. **Trusted ingress marker** (`server/utils/trusted-ingress.ts`) verifies that traffic reached the origin through the expected edge path before Cloudflare forwarding headers are trusted. +2. **Geo-gate** (`server/middleware/geo-gate.ts`) reads `CF-IPCountry` to enforce sanctioned-country blocks. Without Cloudflare, the country cannot be determined and all API requests are rejected with HTTP 451. +3. **Rate limiter** (`server/utils/rate-limit.ts`) uses `CF-Connecting-IP` as the trusted client IP. Without Cloudflare, `CF-Connecting-IP` is absent and all API requests are rejected with HTTP 403. + +The trusted ingress must add `x-euler-edge-origin-secret` with `EDGE_ORIGIN_SECRET`, and strip or overwrite client-supplied `CF-*` forwarding headers plus `x-euler-internal-request`. + +Production health checks that hit `/api/*` must use the same trusted path. If a platform health check calls an API route such as `/api/tenderly/status` directly at origin, it must either pass through trusted ingress or include the trusted ingress marker; otherwise it fails closed with HTTP 403. Bypass behaviour per environment: | Environment | Geo-gate | Rate limiter | |---|---|---| -| `prd` | CF required; fail-closed (HTTP 451) if absent. `DEV_GEO_COUNTRY` bypasses fail-closed if set. | CF required; fail-closed (HTTP 403) if absent. | +| `prd` | Trusted ingress and CF required; fail-closed (HTTP 403/451) if absent. `DEV_GEO_COUNTRY` is ignored. | Trusted ingress and CF required; fail-closed (HTTP 403) if absent. | | `stg` | CF required; fail-closed (HTTP 451) if absent. `DEV_GEO_COUNTRY` bypasses fail-closed if set. | CF **not** required; falls back to `X-Forwarded-For`. | | `dev` | CF not required; falls back to `DEV_GEO_COUNTRY`, then allows through if unset. | CF not required; falls back to `X-Forwarded-For`. | diff --git a/docs/geo-blocking.md b/docs/geo-blocking.md index b1710b5b..421d7871 100644 --- a/docs/geo-blocking.md +++ b/docs/geo-blocking.md @@ -51,7 +51,7 @@ When both collateral AND borrow vault in a pair are restricted, the pair is trea The user's country is detected by sending a `HEAD` request to the application's origin and reading the `x-country-code` response header. The result is normalized to uppercase ISO 3166-1 alpha-2 (e.g. `US`, `DE`, `GB`). -The `x-country-code` response header is set by `server/middleware/cors.ts`, which reads Cloudflare's `CF-IPCountry` edge header (immutably set by Cloudflare's network). Any client-supplied `x-country-code` request header is stripped by `cors.ts` before processing, preventing bypass. +The `x-country-code` response header is set by `server/middleware/cors.ts`, which reads Cloudflare's `CF-IPCountry` edge header after the production trusted-ingress marker is verified. Any client-supplied `x-country-code` request header is stripped by `cors.ts` before processing, preventing bypass. Detection is cached for 5 minutes to avoid repeated network calls. @@ -71,7 +71,7 @@ Browser → HEAD / → cors.ts strips client x-country-code A concurrency guard (`loadingCountry`) prevents duplicate in-flight requests if `loadCountry()` is called multiple times. -**Local development**: In development (`DOPPLER_ENVIRONMENT=dev`), Cloudflare is not in the request path so `CF-IPCountry` is never set. Set `DEV_GEO_COUNTRY=GB` (or any ISO country code) in `.env` to simulate a country for geo-block testing. Without it, the server allows requests through in dev rather than blocking. +**Local development and previews**: Outside production, Cloudflare is not always in the request path so `CF-IPCountry` may be absent. Set `DEV_GEO_COUNTRY=GB` (or any ISO country code) in `.env` to simulate a country for geo-block testing. Without it, the server allows requests through in dev rather than blocking. Production ignores `DEV_GEO_COUNTRY` and fails closed when Cloudflare country data is absent. ## Server-Side Geo-Gate @@ -81,6 +81,12 @@ All API requests first pass through the server-side geo-gate, which applies the The gate reads `CF-IPCountry` from the Cloudflare edge header. Special values `XX` (unknown IP) and `T1` (Tor exit node) are treated as an undetermined country. If the country cannot be determined **and** the environment is not `dev`, the request is rejected with HTTP 451 (fail-closed). In dev, unknown country is allowed through so local development is not blocked. +Production API traffic also depends on the trusted ingress boundary enforced by `server/middleware/cors.ts`, `server/middleware/geo-gate.ts`, and `server/utils/rate-limit.ts`. In `prd`, ingress must add `x-euler-edge-origin-secret` with the configured `EDGE_ORIGIN_SECRET` value and strip or overwrite client-supplied `CF-*` forwarding headers plus `x-euler-internal-request` before the request reaches the origin. Requests without this trusted-ingress marker are rejected before Cloudflare country or client-IP headers are trusted. + +Production health checks that call `/api/*` directly at origin need the same trusted ingress marker, or need to run through the trusted ingress path. For example, `/api/tenderly/status` returns HTTP 403 in `prd` when the trusted-ingress marker is absent. + +Server-internal `$fetch` calls use `INTERNAL_FETCH_HEADERS`, which carries a private `x-euler-internal-request` value generated by `server/utils/internal-headers.ts`. Those internal calls bypass the geo-gate and rate-limit edge checks without using public Cloudflare headers as a sentinel. + ```text Request → cors.ts (strip client x-country-code, set response x-country-code from CF-IPCountry) → geo-gate.ts (read CF-IPCountry) diff --git a/server/middleware/cors.ts b/server/middleware/cors.ts index f8b28e15..64a72505 100644 --- a/server/middleware/cors.ts +++ b/server/middleware/cors.ts @@ -1,12 +1,15 @@ import { createError, getRequestURL, setResponseHeader, sendNoContent } from 'h3' +import { isDevelopmentRuntime, isProductionRuntime } from '~/server/utils/deploy-env' +import { isInternalRequest } from '~/server/utils/internal-headers' import { logger } from '~/server/utils/logger' +import { isTrustedIngressRequest } from '~/server/utils/trusted-ingress' function parseAllowedOrigins(): Set { // CORS_ALLOWED_ORIGINS is the dedicated CORS var (comma-separated). // Falls back to NUXT_PUBLIC_APP_URL (single origin used by Reown/AppKit). const corsOrigins = process.env.CORS_ALLOWED_ORIGINS?.trim() const appUrl = process.env.NUXT_PUBLIC_APP_URL?.trim() - const isDev = process.env.DOPPLER_ENVIRONMENT === 'dev' + const isDev = isDevelopmentRuntime() const origins = new Set() @@ -48,13 +51,18 @@ export default defineEventHandler((event) => { // set by their edge network and cannot be modified by clients. delete event.node.req.headers['x-country-code'] - const cfCountry = (event.node.req.headers['cf-ipcountry'] as string | undefined)?.toUpperCase() + const isProduction = isProductionRuntime() + const isInternal = isInternalRequest(event) + const canTrustCfHeaders = !isProduction || isTrustedIngressRequest(event) + const cfCountry = canTrustCfHeaders + ? (event.node.req.headers['cf-ipcountry'] as string | undefined)?.toUpperCase() + : undefined let country = (cfCountry && /^[A-Z]{2}$/.test(cfCountry) && cfCountry !== 'XX') ? cfCountry : undefined // When Cloudflare is not in the request path (local dev, PR previews, etc.) - // cf-ipcountry is never set. Mirror geo-gate.ts: use DEV_GEO_COUNTRY as a - // fallback regardless of environment so x-country-code is set in the response. - if (!country) { + // cf-ipcountry is never set. Mirror geo-gate.ts: use DEV_GEO_COUNTRY outside + // production so x-country-code is set in the response. + if (!country && !isProduction) { const devCountry = process.env.DEV_GEO_COUNTRY?.toUpperCase() if (devCountry && /^[A-Z]{2}$/.test(devCountry) && devCountry !== 'XX') { country = devCountry @@ -64,7 +72,7 @@ export default defineEventHandler((event) => { if (country) { setResponseHeader(event, 'x-country-code', country) } - else if (process.env.DOPPLER_ENVIRONMENT === 'dev') { + else if (isDevelopmentRuntime()) { // No DEV_GEO_COUNTRY set — send a placeholder so the client doesn't fail-closed. // '--' is not a real country code so no geo-blocks will trigger. setResponseHeader(event, 'x-country-code', '--') @@ -80,6 +88,14 @@ export default defineEventHandler((event) => { // response (including preflights) for one origin to another. setResponseHeader(event, 'Vary', 'Origin') + if (isProduction && !isInternal && !isTrustedIngressRequest(event)) { + logger.warn( + { ctx: 'cors', path: url.pathname }, + 'blocked: trusted ingress secret absent or invalid', + ) + throw createError({ statusCode: 403, statusMessage: 'Forbidden' }) + } + // Endpoints under /api/public/ are intentionally public. if (url.pathname.startsWith('/api/public/')) { setResponseHeader(event, 'Access-Control-Allow-Origin', '*') @@ -97,7 +113,7 @@ export default defineEventHandler((event) => { if (origin && allowedOrigins.has(origin)) { setResponseHeader(event, 'Access-Control-Allow-Origin', origin) } - else if (origin && process.env.DOPPLER_ENVIRONMENT !== 'dev') { + else if (origin && !isDevelopmentRuntime()) { if (allowedOrigins.size > 0) { logger.warn({ ctx: 'cors', origin }, 'rejected origin not in allow list') } diff --git a/server/middleware/geo-gate.ts b/server/middleware/geo-gate.ts index cd63d5b9..22ba91d3 100644 --- a/server/middleware/geo-gate.ts +++ b/server/middleware/geo-gate.ts @@ -1,7 +1,9 @@ import { createError, getRequestURL } from 'h3' import { SANCTIONED_COUNTRIES } from '~/entities/country-constants' +import { isDevelopmentRuntime, isProductionRuntime } from '~/server/utils/deploy-env' import { isInternalRequest } from '~/server/utils/internal-headers' import { logger } from '~/server/utils/logger' +import { isTrustedIngressRequest } from '~/server/utils/trusted-ingress' export default defineEventHandler((event) => { // Only gate API routes @@ -11,15 +13,22 @@ export default defineEventHandler((event) => { } // Internal server-to-server $fetch calls (warm-cache, vaults-cache) skip - // geo-gating — they never traversed Cloudflare and have no cf-ipcountry, - // which would otherwise fail-closed and 451 every internal fetch. The - // loopback cf-connecting-ip sentinel is the same signal the rate-limiter - // uses to identify internal traffic; both rely on origin being locked - // behind CF (see internal-headers.ts). + // geo-gating because they never traversed Cloudflare and have no + // cf-ipcountry. The private header is generated by internal-headers.ts. if (isInternalRequest(event)) { return } + const isProduction = isProductionRuntime() + + if (isProduction && !isTrustedIngressRequest(event)) { + logger.warn( + { ctx: 'geo-gate', path: url.pathname }, + 'blocked: trusted ingress secret absent or invalid', + ) + throw createError({ statusCode: 403, statusMessage: 'Forbidden' }) + } + // Use Cloudflare's CF-IPCountry header which is set by their edge network and // cannot be modified by clients. x-country-code is stripped in cors.ts. // CF-IPCountry special values: 'XX' = unknown IP, 'T1' = Tor exit node. @@ -28,9 +37,9 @@ export default defineEventHandler((event) => { // When Cloudflare is not in the request path (local dev, PR previews, etc.) // cf-ipcountry is never set. DEV_GEO_COUNTRY allows injecting a country code - // as a fallback regardless of environment, so preview deployments aren't - // universally fail-closed when no CF header is present. - if (!country) { + // outside production, so preview deployments aren't universally fail-closed + // when no CF header is present. + if (!country && !isProduction) { const devCountry = process.env.DEV_GEO_COUNTRY?.toUpperCase() if (devCountry && /^[A-Z]{2}$/.test(devCountry) && devCountry !== 'XX') { country = devCountry @@ -40,7 +49,7 @@ export default defineEventHandler((event) => { // Fail-closed: deny access when country cannot be determined. // This prevents bypassing geo-blocks by omitting or spoofing headers. // In dev (DOPPLER_ENVIRONMENT=dev) without DEV_GEO_COUNTRY set, allow through. - if (!country && process.env.DOPPLER_ENVIRONMENT !== 'dev') { + if (!country && !isDevelopmentRuntime()) { logger.warn( { ctx: 'geo-gate', cfCountry: cfCountry || 'absent', path: url.pathname }, 'blocked: country undetermined', diff --git a/server/utils/deploy-env.ts b/server/utils/deploy-env.ts new file mode 100644 index 00000000..31c2b275 --- /dev/null +++ b/server/utils/deploy-env.ts @@ -0,0 +1,18 @@ +const KNOWN_DOPPLER_ENVIRONMENTS = new Set(['dev', 'stg', 'prd']) + +function getDopplerEnvironment(): string | undefined { + return process.env.DOPPLER_ENVIRONMENT?.trim() || undefined +} + +export function isProductionRuntime(): boolean { + const dopplerEnvironment = getDopplerEnvironment() + if (dopplerEnvironment && KNOWN_DOPPLER_ENVIRONMENTS.has(dopplerEnvironment)) { + return dopplerEnvironment === 'prd' + } + + return process.env.NODE_ENV === 'production' +} + +export function isDevelopmentRuntime(): boolean { + return getDopplerEnvironment() === 'dev' +} diff --git a/server/utils/internal-headers.ts b/server/utils/internal-headers.ts index e6f17b0f..2e8ebd67 100644 --- a/server/utils/internal-headers.ts +++ b/server/utils/internal-headers.ts @@ -1,36 +1,30 @@ import type { H3Event } from 'h3' +import { randomUUID } from 'node:crypto' + +export const INTERNAL_REQUEST_HEADER = 'x-euler-internal-request' + +const INTERNAL_REQUEST_SECRET = process.env.INTERNAL_FETCH_SECRET?.trim() || randomUUID() /** * Synthetic headers for server-internal $fetch calls. * - * The rate-limit middleware in production (DOPPLER_ENVIRONMENT=prd) fails - * closed when `cf-connecting-ip` is absent — a Cloudflare egress invariant - * that keeps direct-to-origin traffic out. The geo-gate middleware - * likewise fails closed when `cf-ipcountry` is absent. Internal fetches - * from warm-cache, vaults-cache, etc. don't go through Cloudflare, so - * without these headers every internal request would be 403'd or 451'd. - * - * `cf-connecting-ip` is a fixed loopback sentinel that downstream - * middleware also uses to identify internal traffic (see isInternalRequest - * below) — all server-internal traffic shares one rate-limit bucket, which - * is fine: warm-cache issues at most ~240 requests per 5-min cycle - * against a >=600/min-per-endpoint budget. + * Internal fetches from warm-cache, vaults-cache, etc. don't go through + * Cloudflare, so they have neither `cf-connecting-ip` nor `cf-ipcountry`. + * This private header lets downstream middleware recognise those calls and + * skip checks that only make sense for public edge traffic. * - * SECURITY: this sentinel relies on origin ingress NOT being directly - * reachable — Cloudflare is the only public entrypoint. If that - * assumption changes (eg a new ingress is exposed), attackers could - * spoof these headers to bypass rate limiting AND geo-blocking. Do not - * add the headers to anything that forwards user input into the - * downstream URL, and keep origin locked behind Cloudflare. + * The value is either `INTERNAL_FETCH_SECRET` (for deployments that need a + * shared internal secret across workers) or a process-local random value. + * The header name is not secret; the value is. */ -export const INTERNAL_FETCH_HEADERS = { 'cf-connecting-ip': '127.0.0.1' } as const +export const INTERNAL_FETCH_HEADERS = { + [INTERNAL_REQUEST_HEADER]: INTERNAL_REQUEST_SECRET, +} as const /** - * True when the incoming request bears the loopback `cf-connecting-ip` - * sentinel set by `INTERNAL_FETCH_HEADERS`. Middleware uses this to - * bypass geo/rate checks for warm-cache → `/api/*` traffic that never - * traversed Cloudflare. Relies on the same origin-locked-behind-CF - * security invariant noted above. + * True when the incoming request bears the private internal header set by + * `INTERNAL_FETCH_HEADERS`. Middleware uses this to bypass geo/rate checks + * for warm-cache → `/api/*` traffic that never traversed Cloudflare. */ export const isInternalRequest = (event: H3Event): boolean => - event.node.req.headers['cf-connecting-ip'] === '127.0.0.1' + event.node.req.headers[INTERNAL_REQUEST_HEADER] === INTERNAL_REQUEST_SECRET diff --git a/server/utils/rate-limit.ts b/server/utils/rate-limit.ts index 9f1c6229..e1d450ad 100644 --- a/server/utils/rate-limit.ts +++ b/server/utils/rate-limit.ts @@ -1,6 +1,9 @@ import type { H3Event } from 'h3' import { createError } from 'h3' +import { isProductionRuntime } from '~/server/utils/deploy-env' +import { isInternalRequest } from '~/server/utils/internal-headers' import { logger } from '~/server/utils/logger' +import { isTrustedIngressRequest } from '~/server/utils/trusted-ingress' interface RateLimitEntry { consumed: number @@ -18,14 +21,13 @@ interface RateLimiterConfig { // NOTE: This rate limiter relies on Cloudflare being in the request path for // production (DOPPLER_ENVIRONMENT=prd). CF-Connecting-IP is set by Cloudflare's -// edge and cannot be spoofed by clients going through Cloudflare. In production, -// requests arriving without this header are rejected fail-closed, which closes -// the X-Forwarded-For rotation attack that was possible via the old fallback path. +// edge and cannot be spoofed by clients going through Cloudflare. The trusted +// ingress also adds x-euler-edge-origin-secret and strips/overwrites incoming +// CF-* forwarding headers before the request reaches the origin. // -// Residual limitation: an attacker who knows the origin IP and bypasses Cloudflare -// can still manually set CF-Connecting-IP with rotating values. Closing that fully -// requires network-level enforcement (allowlisting Cloudflare's IP ranges at the -// origin firewall). +// In production, requests missing either CF-Connecting-IP or the trusted ingress +// secret are rejected fail-closed. This keeps CF-Connecting-IP usable as the +// per-client identity only after the immediate edge/origin path is verified. // // In dev and stg, Cloudflare is not always in the request path, so the CF // requirement is not enforced and X-Forwarded-For / socket is used instead. @@ -34,6 +36,20 @@ interface RateLimiterConfig { // - In-memory state is per-process. If Nitro runs multiple workers the // effective limit is multiplied by the worker count. +function assertProductionIngressTrusted(event: H3Event): void { + const cfIp = event.node.req.headers['cf-connecting-ip'] + const hasCfIp = typeof cfIp === 'string' && !!cfIp.trim() + if (!hasCfIp) { + logger.warn({ ctx: 'rate-limit' }, 'blocked: CF-Connecting-IP absent, request bypassed Cloudflare') + throw createError({ statusCode: 403, statusMessage: 'Forbidden' }) + } + + if (!isTrustedIngressRequest(event)) { + logger.warn({ ctx: 'rate-limit' }, 'blocked: trusted ingress secret absent or invalid') + throw createError({ statusCode: 403, statusMessage: 'Forbidden' }) + } +} + /** * Extract the client IP from an H3 event. * @@ -77,20 +93,15 @@ export function createRateLimiter(config: RateLimiterConfig) { /** * Consume `cost` units from the client's rate-limit budget. * Throws a 429 error when the budget is exceeded. - * In production, throws 403 if the request did not arrive via Cloudflare. + * In production, throws 403 if the request did not arrive via trusted ingress. */ consume(event: H3Event, cost = 1): void { - // In production, CF-Connecting-IP must be present. Requests that arrive - // without it bypassed Cloudflare entirely — reject them fail-closed. - // Outside production (stg, preview, dev) Cloudflare may not be in the - // path, so the check is skipped. - const cfIp = event.node.req.headers['cf-connecting-ip'] - // Fail-closed in production when CF-Connecting-IP is absent. - // stg and dev are exempt: they don't always run behind Cloudflare. - const hasCfIp = typeof cfIp === 'string' && !!cfIp.trim() - if (!hasCfIp && process.env.DOPPLER_ENVIRONMENT === 'prd') { - logger.warn({ ctx: 'rate-limit' }, 'blocked: CF-Connecting-IP absent, request bypassed Cloudflare') - throw createError({ statusCode: 403, statusMessage: 'Forbidden' }) + if (isInternalRequest(event)) { + return + } + + if (isProductionRuntime()) { + assertProductionIngressTrusted(event) } const ip = getClientIp(event) diff --git a/server/utils/trusted-ingress.ts b/server/utils/trusted-ingress.ts new file mode 100644 index 00000000..f544c976 --- /dev/null +++ b/server/utils/trusted-ingress.ts @@ -0,0 +1,14 @@ +import type { H3Event } from 'h3' + +export const TRUSTED_INGRESS_SECRET_HEADER = 'x-euler-edge-origin-secret' + +function getTrustedIngressSecret(): string | undefined { + return process.env.EDGE_ORIGIN_SECRET?.trim() || undefined +} + +export function isTrustedIngressRequest(event: H3Event): boolean { + const expected = getTrustedIngressSecret() + if (!expected) return false + + return event.node.req.headers[TRUSTED_INGRESS_SECRET_HEADER] === expected +} diff --git a/tests/server/cors.test.ts b/tests/server/cors.test.ts new file mode 100644 index 00000000..1a4ac710 --- /dev/null +++ b/tests/server/cors.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { H3Event } from 'h3' +import cors from '~/server/middleware/cors' +import { INTERNAL_FETCH_HEADERS } from '~/server/utils/internal-headers' + +const ORIGINAL_DOPPLER_ENVIRONMENT = process.env.DOPPLER_ENVIRONMENT +const ORIGINAL_EDGE_ORIGIN_SECRET = process.env.EDGE_ORIGIN_SECRET +const ORIGINAL_DEV_GEO_COUNTRY = process.env.DEV_GEO_COUNTRY +const ORIGINAL_NODE_ENV = process.env.NODE_ENV +const CONFIGURED_APP_URL = process.env.NUXT_PUBLIC_APP_URL || 'https://external-host.example' + +const eventWithHeaders = ( + headers: Record, + url = '/', + method = 'HEAD', +): { event: H3Event, responseHeaders: Record } => { + const responseHeaders: Record = {} + const event = { + path: url, + node: { + req: { + url, + method, + headers: { + host: 'euler.example', + ...headers, + }, + }, + res: { + setHeader: (name: string, value: string) => { + responseHeaders[name.toLowerCase()] = value + }, + removeHeader: (name: string) => { + responseHeaders[name.toLowerCase()] = '' + }, + writeHead: () => {}, + end: () => {}, + }, + }, + } as unknown as H3Event + + return { event, responseHeaders } +} + +afterEach(() => { + if (ORIGINAL_DOPPLER_ENVIRONMENT === undefined) delete process.env.DOPPLER_ENVIRONMENT + else process.env.DOPPLER_ENVIRONMENT = ORIGINAL_DOPPLER_ENVIRONMENT + + if (ORIGINAL_EDGE_ORIGIN_SECRET === undefined) delete process.env.EDGE_ORIGIN_SECRET + else process.env.EDGE_ORIGIN_SECRET = ORIGINAL_EDGE_ORIGIN_SECRET + + if (ORIGINAL_DEV_GEO_COUNTRY === undefined) delete process.env.DEV_GEO_COUNTRY + else process.env.DEV_GEO_COUNTRY = ORIGINAL_DEV_GEO_COUNTRY + + if (ORIGINAL_NODE_ENV === undefined) delete process.env.NODE_ENV + else process.env.NODE_ENV = ORIGINAL_NODE_ENV +}) + +describe('cors country header', () => { + it('does not trust spoofable CF-IPCountry in production without trusted ingress', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const { event, responseHeaders } = eventWithHeaders({ 'cf-ipcountry': 'GB' }) + + cors(event) + + expect(responseHeaders['x-country-code']).toBeUndefined() + }) + + it('sets x-country-code from Cloudflare country after trusted ingress is verified', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const { event, responseHeaders } = eventWithHeaders({ + 'cf-ipcountry': 'GB', + 'x-euler-edge-origin-secret': 'edge-secret', + }) + + cors(event) + + expect(responseHeaders['x-country-code']).toBe('GB') + }) + + it('strips client-supplied x-country-code before processing', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const { event } = eventWithHeaders({ + 'cf-ipcountry': 'GB', + 'x-country-code': 'US', + 'x-euler-edge-origin-secret': 'edge-secret', + }) + + cors(event) + + expect(event.node.req.headers['x-country-code']).toBeUndefined() + }) + + it('does not use DEV_GEO_COUNTRY as a production country fallback', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + process.env.DEV_GEO_COUNTRY = 'GB' + const { event, responseHeaders } = eventWithHeaders({ + 'x-euler-edge-origin-secret': 'edge-secret', + }) + + cors(event) + + expect(responseHeaders['x-country-code']).toBeUndefined() + }) + + it('rejects production API preflights without trusted ingress', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const { event } = eventWithHeaders({ + 'origin': CONFIGURED_APP_URL, + 'access-control-request-method': 'POST', + }, '/api/vaults', 'OPTIONS') + + expect(() => cors(event)).toThrowError(expect.objectContaining({ statusCode: 403 })) + }) + + it('rejects production API preflights when NODE_ENV is production and DOPPLER_ENVIRONMENT is absent', () => { + delete process.env.DOPPLER_ENVIRONMENT + process.env.NODE_ENV = 'production' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const { event } = eventWithHeaders({ + 'origin': CONFIGURED_APP_URL, + 'access-control-request-method': 'POST', + }, '/api/vaults', 'OPTIONS') + + expect(() => cors(event)).toThrowError(expect.objectContaining({ statusCode: 403 })) + }) + + it('lets private internal API requests pass the production CORS ingress check', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const { event } = eventWithHeaders({ ...INTERNAL_FETCH_HEADERS }, '/api/euler-chains', 'GET') + + expect(() => cors(event)).not.toThrow() + }) + + it('treats padded dev environment values as development', () => { + process.env.DOPPLER_ENVIRONMENT = 'dev ' + const { event, responseHeaders } = eventWithHeaders({}) + + cors(event) + + expect(responseHeaders['x-country-code']).toBe('--') + }) +}) diff --git a/tests/server/geo-gate.test.ts b/tests/server/geo-gate.test.ts new file mode 100644 index 00000000..284af6f4 --- /dev/null +++ b/tests/server/geo-gate.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { H3Event } from 'h3' +import { INTERNAL_FETCH_HEADERS } from '~/server/utils/internal-headers' +import geoGate from '~/server/middleware/geo-gate' + +const ORIGINAL_DOPPLER_ENVIRONMENT = process.env.DOPPLER_ENVIRONMENT +const ORIGINAL_EDGE_ORIGIN_SECRET = process.env.EDGE_ORIGIN_SECRET +const ORIGINAL_DEV_GEO_COUNTRY = process.env.DEV_GEO_COUNTRY +const ORIGINAL_NODE_ENV = process.env.NODE_ENV + +const eventWithHeaders = (headers: Record, url = '/api/vaults'): H3Event => + ({ + path: url, + node: { + req: { + url, + headers: { + host: 'euler.example', + ...headers, + }, + }, + }, + }) as unknown as H3Event + +afterEach(() => { + if (ORIGINAL_DOPPLER_ENVIRONMENT === undefined) delete process.env.DOPPLER_ENVIRONMENT + else process.env.DOPPLER_ENVIRONMENT = ORIGINAL_DOPPLER_ENVIRONMENT + + if (ORIGINAL_EDGE_ORIGIN_SECRET === undefined) delete process.env.EDGE_ORIGIN_SECRET + else process.env.EDGE_ORIGIN_SECRET = ORIGINAL_EDGE_ORIGIN_SECRET + + if (ORIGINAL_DEV_GEO_COUNTRY === undefined) delete process.env.DEV_GEO_COUNTRY + else process.env.DEV_GEO_COUNTRY = ORIGINAL_DEV_GEO_COUNTRY + + if (ORIGINAL_NODE_ENV === undefined) delete process.env.NODE_ENV + else process.env.NODE_ENV = ORIGINAL_NODE_ENV +}) + +describe('geo-gate trusted ingress', () => { + it('rejects production API requests before trusting spoofable CF-IPCountry', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + + expect(() => geoGate(eventWithHeaders({ + 'cf-ipcountry': 'GB', + }))).toThrowError(expect.objectContaining({ statusCode: 403 })) + }) + + it('accepts production API requests from trusted ingress with a non-sanctioned country', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + + expect(() => geoGate(eventWithHeaders({ + 'cf-ipcountry': 'GB', + 'x-euler-edge-origin-secret': 'edge-secret', + }))).not.toThrow() + }) + + it('lets private internal requests bypass production edge checks', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + + expect(() => geoGate(eventWithHeaders({ ...INTERNAL_FETCH_HEADERS }))).not.toThrow() + }) + + it('rejects production requests without CF-IPCountry even when DEV_GEO_COUNTRY is set', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + process.env.DEV_GEO_COUNTRY = 'GB' + + expect(() => geoGate(eventWithHeaders({ + 'x-euler-edge-origin-secret': 'edge-secret', + }))).toThrowError(expect.objectContaining({ statusCode: 451 })) + }) + + it('requires trusted ingress when NODE_ENV is production and DOPPLER_ENVIRONMENT is absent', () => { + delete process.env.DOPPLER_ENVIRONMENT + process.env.NODE_ENV = 'production' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + + expect(() => geoGate(eventWithHeaders({ + 'cf-ipcountry': 'GB', + }))).toThrowError(expect.objectContaining({ statusCode: 403 })) + }) +}) diff --git a/tests/server/internal-request.test.ts b/tests/server/internal-request.test.ts index 321895fd..94107fda 100644 --- a/tests/server/internal-request.test.ts +++ b/tests/server/internal-request.test.ts @@ -3,10 +3,9 @@ * * Server-internal $fetch calls (warm-cache, vaults-cache, etc.) do not * traverse Cloudflare, so they have no `cf-ipcountry` or real - * `cf-connecting-ip`. Both the geo-gate and rate-limit middlewares - * fail-closed when those headers are missing. INTERNAL_FETCH_HEADERS - * stamps a loopback `cf-connecting-ip: 127.0.0.1` that downstream - * middleware recognises via `isInternalRequest` to bypass those checks. + * `cf-connecting-ip`. INTERNAL_FETCH_HEADERS stamps a private header that + * downstream middleware recognises via `isInternalRequest` to bypass checks + * that only apply to public edge traffic. * * If this contract breaks — the sentinel stops being set, the helper * stops recognising it, or a middleware forgets to consult the helper — @@ -16,23 +15,20 @@ */ import { describe, it, expect } from 'vitest' import type { H3Event } from 'h3' -import { INTERNAL_FETCH_HEADERS, isInternalRequest } from '~/server/utils/internal-headers' +import { INTERNAL_FETCH_HEADERS, INTERNAL_REQUEST_HEADER, isInternalRequest } from '~/server/utils/internal-headers' const eventWithHeaders = (headers: Record): H3Event => ({ node: { req: { headers } } }) as unknown as H3Event describe('INTERNAL_FETCH_HEADERS', () => { - it('sets cf-connecting-ip to the loopback sentinel', () => { - expect(INTERNAL_FETCH_HEADERS['cf-connecting-ip']).toBe('127.0.0.1') + it('sets a private internal request header', () => { + expect(INTERNAL_FETCH_HEADERS[INTERNAL_REQUEST_HEADER]).toEqual(expect.any(String)) + expect(INTERNAL_FETCH_HEADERS[INTERNAL_REQUEST_HEADER]).not.toBe('') + expect(INTERNAL_FETCH_HEADERS).not.toHaveProperty('cf-connecting-ip') }) }) describe('isInternalRequest', () => { - it('returns true when cf-connecting-ip matches the sentinel', () => { - const event = eventWithHeaders({ 'cf-connecting-ip': '127.0.0.1' }) - expect(isInternalRequest(event)).toBe(true) - }) - it('returns true for requests decorated with INTERNAL_FETCH_HEADERS', () => { // End-to-end contract: whatever INTERNAL_FETCH_HEADERS sets must be // what isInternalRequest recognises. @@ -45,9 +41,14 @@ describe('isInternalRequest', () => { expect(isInternalRequest(event)).toBe(false) }) - it('returns false for any other IP', () => { + it('returns false for spoofed CF-Connecting-IP values', () => { + expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '127.0.0.1' }))).toBe(false) expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '203.0.113.1' }))).toBe(false) expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '::1' }))).toBe(false) expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '127.0.0.2' }))).toBe(false) }) + + it('returns false when the private header has the wrong value', () => { + expect(isInternalRequest(eventWithHeaders({ [INTERNAL_REQUEST_HEADER]: 'wrong-secret' }))).toBe(false) + }) }) diff --git a/tests/server/rate-limit.test.ts b/tests/server/rate-limit.test.ts new file mode 100644 index 00000000..a0378077 --- /dev/null +++ b/tests/server/rate-limit.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { H3Event } from 'h3' +import { INTERNAL_FETCH_HEADERS } from '~/server/utils/internal-headers' +import { createRateLimiter, getClientIp } from '~/server/utils/rate-limit' + +const ORIGINAL_DOPPLER_ENVIRONMENT = process.env.DOPPLER_ENVIRONMENT +const ORIGINAL_EDGE_ORIGIN_SECRET = process.env.EDGE_ORIGIN_SECRET +const ORIGINAL_NODE_ENV = process.env.NODE_ENV + +const eventWithHeaders = (headers: Record): H3Event => + ({ + node: { + req: { + headers, + socket: { remoteAddress: '198.51.100.9' }, + }, + }, + }) as unknown as H3Event + +afterEach(() => { + if (ORIGINAL_DOPPLER_ENVIRONMENT === undefined) delete process.env.DOPPLER_ENVIRONMENT + else process.env.DOPPLER_ENVIRONMENT = ORIGINAL_DOPPLER_ENVIRONMENT + + if (ORIGINAL_EDGE_ORIGIN_SECRET === undefined) delete process.env.EDGE_ORIGIN_SECRET + else process.env.EDGE_ORIGIN_SECRET = ORIGINAL_EDGE_ORIGIN_SECRET + + if (ORIGINAL_NODE_ENV === undefined) delete process.env.NODE_ENV + else process.env.NODE_ENV = ORIGINAL_NODE_ENV +}) + +describe('getClientIp', () => { + it('prefers CF-Connecting-IP when present', () => { + const event = eventWithHeaders({ + 'cf-connecting-ip': '203.0.113.10', + 'x-forwarded-for': '203.0.113.20', + }) + + expect(getClientIp(event)).toBe('203.0.113.10') + }) + + it('falls back outside production when CF-Connecting-IP is absent', () => { + process.env.DOPPLER_ENVIRONMENT = 'dev' + const event = eventWithHeaders({ 'x-forwarded-for': '203.0.113.20, 203.0.113.21' }) + + expect(getClientIp(event)).toBe('203.0.113.20') + }) +}) + +describe('createRateLimiter', () => { + it('rejects production requests when CF-Connecting-IP is absent', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const limiter = createRateLimiter({ max: 1, windowMs: 60_000, label: 'test' }) + + expect(() => limiter.consume(eventWithHeaders({ + 'x-euler-edge-origin-secret': 'edge-secret', + }))).toThrowError(expect.objectContaining({ statusCode: 403 })) + }) + + it('rejects production requests when the trusted ingress secret is absent', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const limiter = createRateLimiter({ max: 1, windowMs: 60_000, label: 'test' }) + + expect(() => limiter.consume(eventWithHeaders({ + 'cf-connecting-ip': '203.0.113.10', + }))).toThrowError(expect.objectContaining({ statusCode: 403 })) + }) + + it('rejects production requests when EDGE_ORIGIN_SECRET is not configured', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + delete process.env.EDGE_ORIGIN_SECRET + const limiter = createRateLimiter({ max: 1, windowMs: 60_000, label: 'test' }) + + expect(() => limiter.consume(eventWithHeaders({ + 'cf-connecting-ip': '203.0.113.10', + }))).toThrowError(expect.objectContaining({ statusCode: 403 })) + }) + + it('requires trusted ingress when NODE_ENV is production and DOPPLER_ENVIRONMENT is absent', () => { + delete process.env.DOPPLER_ENVIRONMENT + process.env.NODE_ENV = 'production' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const limiter = createRateLimiter({ max: 1, windowMs: 60_000, label: 'test' }) + + expect(() => limiter.consume(eventWithHeaders({ + 'cf-connecting-ip': '203.0.113.10', + }))).toThrowError(expect.objectContaining({ statusCode: 403 })) + }) + + it('accepts production requests after trusted ingress is verified', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const limiter = createRateLimiter({ max: 1, windowMs: 60_000, label: 'test' }) + + expect(() => limiter.consume(eventWithHeaders({ + 'cf-connecting-ip': '203.0.113.10', + 'x-euler-edge-origin-secret': 'edge-secret', + }))).not.toThrow() + }) + + it('does not treat spoofed loopback CF-Connecting-IP as internal', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const limiter = createRateLimiter({ max: 1, windowMs: 60_000, label: 'test' }) + + expect(() => limiter.consume(eventWithHeaders({ + 'cf-connecting-ip': '127.0.0.1', + 'x-euler-edge-origin-secret': 'edge-secret', + }))).not.toThrow() + expect(() => limiter.consume(eventWithHeaders({ + 'cf-connecting-ip': '127.0.0.1', + 'x-euler-edge-origin-secret': 'edge-secret', + }))).toThrowError(expect.objectContaining({ statusCode: 429 })) + }) + + it('lets private internal requests bypass production edge checks and rate limits', () => { + process.env.DOPPLER_ENVIRONMENT = 'prd' + process.env.EDGE_ORIGIN_SECRET = 'edge-secret' + const limiter = createRateLimiter({ max: 1, windowMs: 60_000, label: 'test' }) + const event = eventWithHeaders({ ...INTERNAL_FETCH_HEADERS }) + + expect(() => limiter.consume(event)).not.toThrow() + expect(() => limiter.consume(event)).not.toThrow() + }) +})