Skip to content

Commit 249d1c9

Browse files
committed
fix: harden edge origin trust
1 parent c8dc79b commit 249d1c9

13 files changed

Lines changed: 500 additions & 79 deletions

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ SENTRY_AUTH_TOKEN= # build-time only (sourcemap upload); do not set at runtime
66
# origin rejection, and HSTS. Injected automatically by Doppler at runtime.
77
DOPPLER_ENVIRONMENT=dev
88

9+
# Production edge/origin trust.
10+
# In prd, trusted ingress must add x-euler-edge-origin-secret with this value
11+
# after stripping or overwriting client-supplied CF-* forwarding headers and
12+
# x-euler-internal-request.
13+
EDGE_ORIGIN_SECRET=
14+
# Optional shared secret for server-internal $fetch calls. Leave empty for a
15+
# process-local random secret unless internal relative fetches cross workers.
16+
INTERNAL_FETCH_SECRET=
17+
918
# Fallback country when cf-ipcountry is absent (local dev, PR previews, any env without Cloudflare).
1019
# Bypasses fail-closed geo-gate — do not set in production behind Cloudflare.
1120
DEV_GEO_COUNTRY=GB

docs/architecture.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ The Nuxt server layer (`server/api/`) proxies requests to external services (RPC
327327
| **Body size limits** (`server/middleware/body-limit.ts`) | Caps request payloads (1 MB RPC, 2 MB Tenderly) |
328328
| **Geo-blocking** (`server/middleware/geo-gate.ts`) | Blocks sanctioned countries via Cloudflare `CF-IPCountry`; fails closed (HTTP 451) if country is undetermined in prod |
329329
| **RPC method whitelist** (`server/api/rpc/[chainId].ts`) | Only 15 safe read-only methods are proxied |
330-
| **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 |
330+
| **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 |
331331
| **Swap verifier validation** (`utils/swap-validation.ts`) | Validates swap verifier addresses against known config |
332332

333333
#### Rate Limiting
@@ -349,14 +349,17 @@ The app includes a built-in per-IP rate limiter as a defense-in-depth measure. D
349349

350350
**Production deployments must be behind Cloudflare.** This is a hard requirement, not a recommendation — two independent server features depend on it:
351351

352-
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.
353-
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.
352+
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.
353+
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.
354+
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.
355+
356+
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`.
354357

355358
Bypass behaviour per environment:
356359

357360
| Environment | Geo-gate | Rate limiter |
358361
|---|---|---|
359-
| `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. |
362+
| `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. |
360363
| `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`. |
361364
| `dev` | CF not required; falls back to `DEV_GEO_COUNTRY`, then allows through if unset. | CF not required; falls back to `X-Forwarded-For`. |
362365

docs/geo-blocking.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ When both collateral AND borrow vault in a pair are restricted, the pair is trea
5151

5252
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`).
5353

54-
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.
54+
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.
5555

5656
Detection is cached for 5 minutes to avoid repeated network calls.
5757

@@ -71,7 +71,7 @@ Browser → HEAD / → cors.ts strips client x-country-code
7171

7272
A concurrency guard (`loadingCountry`) prevents duplicate in-flight requests if `loadCountry()` is called multiple times.
7373

74-
**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.
74+
**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.
7575

7676
## Server-Side Geo-Gate
7777

@@ -81,6 +81,10 @@ All API requests first pass through the server-side geo-gate, which applies the
8181

8282
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.
8383

84+
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.
85+
86+
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.
87+
8488
```text
8589
Request → cors.ts (strip client x-country-code, set response x-country-code from CF-IPCountry)
8690
→ geo-gate.ts (read CF-IPCountry)

server/middleware/cors.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { createError, getRequestURL, setResponseHeader, sendNoContent } from 'h3'
2+
import { isDevelopmentRuntime, isProductionRuntime } from '~/server/utils/deploy-env'
3+
import { isInternalRequest } from '~/server/utils/internal-headers'
24
import { logger } from '~/server/utils/logger'
5+
import { isTrustedIngressRequest } from '~/server/utils/trusted-ingress'
36

47
function parseAllowedOrigins(): Set<string> {
58
// CORS_ALLOWED_ORIGINS is the dedicated CORS var (comma-separated).
69
// Falls back to NUXT_PUBLIC_APP_URL (single origin used by Reown/AppKit).
710
const corsOrigins = process.env.CORS_ALLOWED_ORIGINS?.trim()
811
const appUrl = process.env.NUXT_PUBLIC_APP_URL?.trim()
9-
const isDev = process.env.DOPPLER_ENVIRONMENT === 'dev'
12+
const isDev = isDevelopmentRuntime()
1013

1114
const origins = new Set<string>()
1215

@@ -48,13 +51,18 @@ export default defineEventHandler((event) => {
4851
// set by their edge network and cannot be modified by clients.
4952
delete event.node.req.headers['x-country-code']
5053

51-
const cfCountry = (event.node.req.headers['cf-ipcountry'] as string | undefined)?.toUpperCase()
54+
const isProduction = isProductionRuntime()
55+
const isInternal = isInternalRequest(event)
56+
const canTrustCfHeaders = !isProduction || isTrustedIngressRequest(event)
57+
const cfCountry = canTrustCfHeaders
58+
? (event.node.req.headers['cf-ipcountry'] as string | undefined)?.toUpperCase()
59+
: undefined
5260
let country = (cfCountry && /^[A-Z]{2}$/.test(cfCountry) && cfCountry !== 'XX') ? cfCountry : undefined
5361

5462
// When Cloudflare is not in the request path (local dev, PR previews, etc.)
55-
// cf-ipcountry is never set. Mirror geo-gate.ts: use DEV_GEO_COUNTRY as a
56-
// fallback regardless of environment so x-country-code is set in the response.
57-
if (!country) {
63+
// cf-ipcountry is never set. Mirror geo-gate.ts: use DEV_GEO_COUNTRY outside
64+
// production so x-country-code is set in the response.
65+
if (!country && !isProduction) {
5866
const devCountry = process.env.DEV_GEO_COUNTRY?.toUpperCase()
5967
if (devCountry && /^[A-Z]{2}$/.test(devCountry) && devCountry !== 'XX') {
6068
country = devCountry
@@ -64,7 +72,7 @@ export default defineEventHandler((event) => {
6472
if (country) {
6573
setResponseHeader(event, 'x-country-code', country)
6674
}
67-
else if (process.env.DOPPLER_ENVIRONMENT === 'dev') {
75+
else if (isDevelopmentRuntime()) {
6876
// No DEV_GEO_COUNTRY set — send a placeholder so the client doesn't fail-closed.
6977
// '--' is not a real country code so no geo-blocks will trigger.
7078
setResponseHeader(event, 'x-country-code', '--')
@@ -80,6 +88,14 @@ export default defineEventHandler((event) => {
8088
// response (including preflights) for one origin to another.
8189
setResponseHeader(event, 'Vary', 'Origin')
8290

91+
if (isProduction && !isInternal && !isTrustedIngressRequest(event)) {
92+
logger.warn(
93+
{ ctx: 'cors', path: url.pathname },
94+
'blocked: trusted ingress secret absent or invalid',
95+
)
96+
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
97+
}
98+
8399
// Endpoints under /api/public/ are intentionally public.
84100
if (url.pathname.startsWith('/api/public/')) {
85101
setResponseHeader(event, 'Access-Control-Allow-Origin', '*')
@@ -97,7 +113,7 @@ export default defineEventHandler((event) => {
97113
if (origin && allowedOrigins.has(origin)) {
98114
setResponseHeader(event, 'Access-Control-Allow-Origin', origin)
99115
}
100-
else if (origin && process.env.DOPPLER_ENVIRONMENT !== 'dev') {
116+
else if (origin && !isDevelopmentRuntime()) {
101117
if (allowedOrigins.size > 0) {
102118
logger.warn({ ctx: 'cors', origin }, 'rejected origin not in allow list')
103119
}

server/middleware/geo-gate.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { createError, getRequestURL } from 'h3'
22
import { SANCTIONED_COUNTRIES } from '~/entities/country-constants'
3+
import { isDevelopmentRuntime, isProductionRuntime } from '~/server/utils/deploy-env'
34
import { isInternalRequest } from '~/server/utils/internal-headers'
45
import { logger } from '~/server/utils/logger'
6+
import { isTrustedIngressRequest } from '~/server/utils/trusted-ingress'
57

68
export default defineEventHandler((event) => {
79
// Only gate API routes
@@ -11,15 +13,22 @@ export default defineEventHandler((event) => {
1113
}
1214

1315
// Internal server-to-server $fetch calls (warm-cache, vaults-cache) skip
14-
// geo-gating — they never traversed Cloudflare and have no cf-ipcountry,
15-
// which would otherwise fail-closed and 451 every internal fetch. The
16-
// loopback cf-connecting-ip sentinel is the same signal the rate-limiter
17-
// uses to identify internal traffic; both rely on origin being locked
18-
// behind CF (see internal-headers.ts).
16+
// geo-gating because they never traversed Cloudflare and have no
17+
// cf-ipcountry. The private header is generated by internal-headers.ts.
1918
if (isInternalRequest(event)) {
2019
return
2120
}
2221

22+
const isProduction = isProductionRuntime()
23+
24+
if (isProduction && !isTrustedIngressRequest(event)) {
25+
logger.warn(
26+
{ ctx: 'geo-gate', path: url.pathname },
27+
'blocked: trusted ingress secret absent or invalid',
28+
)
29+
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
30+
}
31+
2332
// Use Cloudflare's CF-IPCountry header which is set by their edge network and
2433
// cannot be modified by clients. x-country-code is stripped in cors.ts.
2534
// CF-IPCountry special values: 'XX' = unknown IP, 'T1' = Tor exit node.
@@ -28,9 +37,9 @@ export default defineEventHandler((event) => {
2837

2938
// When Cloudflare is not in the request path (local dev, PR previews, etc.)
3039
// cf-ipcountry is never set. DEV_GEO_COUNTRY allows injecting a country code
31-
// as a fallback regardless of environment, so preview deployments aren't
32-
// universally fail-closed when no CF header is present.
33-
if (!country) {
40+
// outside production, so preview deployments aren't universally fail-closed
41+
// when no CF header is present.
42+
if (!country && !isProduction) {
3443
const devCountry = process.env.DEV_GEO_COUNTRY?.toUpperCase()
3544
if (devCountry && /^[A-Z]{2}$/.test(devCountry) && devCountry !== 'XX') {
3645
country = devCountry
@@ -40,7 +49,7 @@ export default defineEventHandler((event) => {
4049
// Fail-closed: deny access when country cannot be determined.
4150
// This prevents bypassing geo-blocks by omitting or spoofing headers.
4251
// In dev (DOPPLER_ENVIRONMENT=dev) without DEV_GEO_COUNTRY set, allow through.
43-
if (!country && process.env.DOPPLER_ENVIRONMENT !== 'dev') {
52+
if (!country && !isDevelopmentRuntime()) {
4453
logger.warn(
4554
{ ctx: 'geo-gate', cfCountry: cfCountry || 'absent', path: url.pathname },
4655
'blocked: country undetermined',

server/utils/deploy-env.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const KNOWN_DOPPLER_ENVIRONMENTS = new Set(['dev', 'stg', 'prd'])
2+
3+
export function isProductionRuntime(): boolean {
4+
const dopplerEnvironment = process.env.DOPPLER_ENVIRONMENT?.trim()
5+
if (dopplerEnvironment && KNOWN_DOPPLER_ENVIRONMENTS.has(dopplerEnvironment)) {
6+
return dopplerEnvironment === 'prd'
7+
}
8+
9+
return process.env.NODE_ENV === 'production'
10+
}
11+
12+
export function isDevelopmentRuntime(): boolean {
13+
return process.env.DOPPLER_ENVIRONMENT === 'dev'
14+
}

server/utils/internal-headers.ts

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,30 @@
11
import type { H3Event } from 'h3'
2+
import { randomUUID } from 'node:crypto'
3+
4+
export const INTERNAL_REQUEST_HEADER = 'x-euler-internal-request'
5+
6+
const INTERNAL_REQUEST_SECRET = process.env.INTERNAL_FETCH_SECRET?.trim() || randomUUID()
27

38
/**
49
* Synthetic headers for server-internal $fetch calls.
510
*
6-
* The rate-limit middleware in production (DOPPLER_ENVIRONMENT=prd) fails
7-
* closed when `cf-connecting-ip` is absent — a Cloudflare egress invariant
8-
* that keeps direct-to-origin traffic out. The geo-gate middleware
9-
* likewise fails closed when `cf-ipcountry` is absent. Internal fetches
10-
* from warm-cache, vaults-cache, etc. don't go through Cloudflare, so
11-
* without these headers every internal request would be 403'd or 451'd.
12-
*
13-
* `cf-connecting-ip` is a fixed loopback sentinel that downstream
14-
* middleware also uses to identify internal traffic (see isInternalRequest
15-
* below) — all server-internal traffic shares one rate-limit bucket, which
16-
* is fine: warm-cache issues at most ~240 requests per 5-min cycle
17-
* against a >=600/min-per-endpoint budget.
11+
* Internal fetches from warm-cache, vaults-cache, etc. don't go through
12+
* Cloudflare, so they have neither `cf-connecting-ip` nor `cf-ipcountry`.
13+
* This private header lets downstream middleware recognise those calls and
14+
* skip checks that only make sense for public edge traffic.
1815
*
19-
* SECURITY: this sentinel relies on origin ingress NOT being directly
20-
* reachable — Cloudflare is the only public entrypoint. If that
21-
* assumption changes (eg a new ingress is exposed), attackers could
22-
* spoof these headers to bypass rate limiting AND geo-blocking. Do not
23-
* add the headers to anything that forwards user input into the
24-
* downstream URL, and keep origin locked behind Cloudflare.
16+
* The value is either `INTERNAL_FETCH_SECRET` (for deployments that need a
17+
* shared internal secret across workers) or a process-local random value.
18+
* The header name is not secret; the value is.
2519
*/
26-
export const INTERNAL_FETCH_HEADERS = { 'cf-connecting-ip': '127.0.0.1' } as const
20+
export const INTERNAL_FETCH_HEADERS = {
21+
[INTERNAL_REQUEST_HEADER]: INTERNAL_REQUEST_SECRET,
22+
} as const
2723

2824
/**
29-
* True when the incoming request bears the loopback `cf-connecting-ip`
30-
* sentinel set by `INTERNAL_FETCH_HEADERS`. Middleware uses this to
31-
* bypass geo/rate checks for warm-cache → `/api/*` traffic that never
32-
* traversed Cloudflare. Relies on the same origin-locked-behind-CF
33-
* security invariant noted above.
25+
* True when the incoming request bears the private internal header set by
26+
* `INTERNAL_FETCH_HEADERS`. Middleware uses this to bypass geo/rate checks
27+
* for warm-cache → `/api/*` traffic that never traversed Cloudflare.
3428
*/
3529
export const isInternalRequest = (event: H3Event): boolean =>
36-
event.node.req.headers['cf-connecting-ip'] === '127.0.0.1'
30+
event.node.req.headers[INTERNAL_REQUEST_HEADER] === INTERNAL_REQUEST_SECRET

0 commit comments

Comments
 (0)