Skip to content
Open
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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`. |

Expand Down
10 changes: 8 additions & 2 deletions docs/geo-blocking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand All @@ -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)
Expand Down
30 changes: 23 additions & 7 deletions server/middleware/cors.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
// 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<string>()

Expand Down Expand Up @@ -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
Expand All @@ -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', '--')
Expand All @@ -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', '*')
Expand All @@ -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')
}
Expand Down
27 changes: 18 additions & 9 deletions server/middleware/geo-gate.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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',
Expand Down
18 changes: 18 additions & 0 deletions server/utils/deploy-env.ts
Original file line number Diff line number Diff line change
@@ -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'
}
Comment thread
Seranged marked this conversation as resolved.
44 changes: 19 additions & 25 deletions server/utils/internal-headers.ts
Original file line number Diff line number Diff line change
@@ -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
Loading