diff --git a/.env.example b/.env.example
index 7d8c285e5..cbf19192a 100644
--- a/.env.example
+++ b/.env.example
@@ -2,8 +2,36 @@
# origin rejection, and HSTS. Injected automatically by Doppler at runtime.
DOPPLER_ENVIRONMENT=dev
-# 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.
+# Fronting edge provider preset (cloudflare | google | cloudfront | none).
+# Selects which trusted request headers carry the client IP, country, and
+# VPN evidence (the mapping lives in utils/edge-presets.ts). Default: none —
+# no edge-derived trust, geo-blocking off, rate limiting on best-effort
+# identity. Fork- and preview-friendly, but REQUIRED in production
+# (DOPPLER_ENVIRONMENT=prd): the server refuses to boot when unset there.
+# Setting it to "none" in production is an explicit opt-out with no trusted
+# identity (rate-limit keys are forgeable) and is logged as a warning at boot.
+# The google preset additionally needs the LB configured to stamp
+# x-client-geo: {client_region} as a custom request header.
+EDGE_PROVIDER=
+
+# Origin-auth shared secret (server-side only). When set, every request
+# must carry a matching x-edge-origin-auth header — stamped by the edge
+# (e.g. a request-header transform rule) — or the edge-derived inputs are
+# treated as absent and the fail-closed paths apply. This replaces the
+# "origin is only reachable through the edge" topology assumption with a
+# check the app enforces itself. Optional for the cloudflare/none presets;
+# REQUIRED for google/cloudfront (their edges forward client headers
+# untouched, so without origin auth their trusted inputs would be forgeable
+# — the server refuses to boot without it). Internal server-to-server
+# fetches authenticate with a random per-process marker when this is unset,
+# so no preset depends on the secret for internal traffic. Configure the
+# edge to stamp the header BEFORE setting this, and to strip
+# client-supplied x-edge-internal.
+EDGE_ORIGIN_SECRET=
+
+# Fallback country when the edge provides none (local dev, PR previews, any
+# env without a geo-capable edge). Bypasses the fail-closed geo-gate, so
+# production (DOPPLER_ENVIRONMENT=prd) refuses to boot when it is set.
DEV_GEO_COUNTRY=GB
# Reown (AppKit) configuration
diff --git a/AGENTS.md b/AGENTS.md
index ff3699012..f8f905065 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -108,9 +108,11 @@ Euler Lite is the only service. Standard commands live in `README.md` ("Availabl
Note: `internal/screen-address` is additionally consumed cross-origin by first-party
`*.euler.finance` SPAs via a path-scoped CORS exception in `cors.ts` — keep its contract
backward-compatible.
-- **Server middleware:** `geo-gate.ts` (451 for sanctioned countries via Cloudflare `CF-IPCountry`;
- set `DEV_GEO_COUNTRY` locally since there's no CF header), `cors.ts`, `security-headers.ts`,
- `body-limit.ts`, `ensure-vault.ts`.
+- **Server middleware:** `geo-gate.ts` (451 for sanctioned countries via the country from
+ `getEdgeContext` — trusted-header mapping per `EDGE_PROVIDER` preset in `utils/edge-presets.ts`;
+ set `DEV_GEO_COUNTRY` locally since there's no edge header), `cors.ts`, `security-headers.ts`,
+ `body-limit.ts`, `ensure-vault.ts`. Middleware and routes stay vendor-neutral: edge header
+ names live only in the presets file, consumed through `server/utils/edge.ts`.
- **Server plugins (load order matters):** `app-config.ts` / `chain-config.ts` inject the `window`
config; `csp.ts` (nonce-based CSP) must run after them; `warm-cache.ts` warms labels/token-list/
vault caches in the background. Caching internals are in `docs/server-side-caching.md`.
diff --git a/Dockerfile b/Dockerfile
index 012fa621b..dcf2df110 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -63,8 +63,12 @@ COPY --from=doppler /usr/local/bin/doppler ./doppler
EXPOSE ${APP_PORT}
+# Liveness probe: /healthz lives outside /api/ so it is exempt from the
+# geo-gate, rate limiting, and internal-request authentication — the probe
+# must not depend on edge configuration (EDGE_PROVIDER / EDGE_ORIGIN_SECRET)
+# and must never carry the origin secret in its arguments.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
- CMD ["/nodejs/bin/node", "-e", "fetch('http://localhost:'+process.env.PORT+'/api/internal/tenderly/status',{headers:{'cf-connecting-ip':'127.0.0.1'}}).then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))"]
+ CMD ["/nodejs/bin/node", "-e", "fetch('http://localhost:'+process.env.PORT+'/healthz').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))"]
# Doppler injects all secrets at runtime via DOPPLER_TOKEN, DOPPLER_PROJECT, DOPPLER_CONFIG env vars.
# server/plugins/chain-config.ts scans env vars and injects chain config via render:html hook.
diff --git a/README.md b/README.md
index 26756b298..28e352495 100644
--- a/README.md
+++ b/README.md
@@ -77,7 +77,7 @@ Euler Lite uses the [Euler V2 SDK](https://github.com/euler-xyz/euler-sdks) for
| `CORS_ALLOWED_ORIGINS` | Comma-separated allowlist for `/api/*`; falls back to `NUXT_PUBLIC_APP_URL`. |
| `FIRST_PARTY_COOKIE_SECRET` | Optional server-only secret that keeps the internal API marker cookie stable across replicas and deploys. Defaults to a value derived from `NUXT_PUBLIC_APP_URL` or `RAILWAY_PUBLIC_DOMAIN`. |
| `CSP_EXTRA_CONNECT_SRC` | Extra `connect-src` origins for development or staging endpoints. |
-| `DEV_GEO_COUNTRY` | Local/preview country fallback when Cloudflare geo headers are absent. Do not set in production behind Cloudflare. |
+| `DEV_GEO_COUNTRY` | Local/preview country fallback when the edge provides no country. Production (`DOPPLER_ENVIRONMENT=prd`) refuses to boot with it set. |
| `ADDRESS_SCREENING_URI` / `ADDRESS_SCREENING_API_KEY` | Server-side data-v3 compliance endpoint + restricted API key, proxied by `/api/internal/screen-address` (also serves first-party `*.euler.finance` SPAs). Both unset ⇒ screening disabled (all addresses pass) — except in production (`DOPPLER_ENVIRONMENT=prd`), where missing configuration fails closed; only one set ⇒ fails closed everywhere. URI must be https (localhost http allowed for dev). |
| `MERKL_API_KEY` | Optional server-side Merkl key. The Merkl API works anonymously (10 req/sec shared across all users via `/api/internal/proxy/merkl`); set this to send `X-API-Key` upstream for a higher quota. Server-only — never exposed to the browser. |
| `TENDERLY_ACCESS_KEY`, `TENDERLY_ACCOUNT_SLUG`, `TENDERLY_PROJECT_SLUG` | Optional Tenderly simulation configuration. |
diff --git a/composables/useAddressScreen.ts b/composables/useAddressScreen.ts
index 5fe2c3840..cf17f3006 100644
--- a/composables/useAddressScreen.ts
+++ b/composables/useAddressScreen.ts
@@ -50,15 +50,7 @@ export const useAddressScreen = () => {
const vpnIsUsed = await detectVpn()
if (gen !== screeningGeneration) return false
- // A positive local signal is independently blocking. A clean or failed
- // remote address-screen response must never erase it.
- if (vpnIsUsed) {
- await disconnect()
- if (gen !== screeningGeneration) return false
- showBlockedModal(address)
- return true
- }
-
+ // VPN usage is audit metadata; only the address-screening verdict gates access.
const isRestricted = await screenAddress(address, vpnIsUsed)
if (gen !== screeningGeneration) return false
diff --git a/composables/useEnvConfig.ts b/composables/useEnvConfig.ts
index ca94ad056..232bb88d6 100644
--- a/composables/useEnvConfig.ts
+++ b/composables/useEnvConfig.ts
@@ -22,8 +22,9 @@ import {
EMPTY_ANNOUNCEMENT_CONFIG,
type AnnouncementConfig,
} from '~/utils/announcement-config'
+import { edgeProvidesVpnEvidence, parseEdgeProvider } from '~/utils/edge-presets'
-interface EnvConfig {
+export interface EnvConfig {
appTitle: string
appDescription: string
logoUrl: string
@@ -42,6 +43,16 @@ interface EnvConfig {
swapApiUrl: string
eulerInterfacesBranch: string
announcement: AnnouncementConfig
+ /** Whether the deployment's edge provider measures VPN usage. Drives the
+ * client VPN probe in services/vpn.ts — false skips it entirely. */
+ vpnDetection: boolean
+}
+
+declare global {
+ interface Window {
+ /** Server-injected runtime config (server/plugins/app-config.ts). */
+ __APP_CONFIG__?: EnvConfig
+ }
}
const DEFAULTS: EnvConfig = {
@@ -58,6 +69,7 @@ const DEFAULTS: EnvConfig = {
swapApiUrl: '',
eulerInterfacesBranch: 'master',
announcement: EMPTY_ANNOUNCEMENT_CONFIG,
+ vpnDetection: false,
}
let cached: EnvConfig | null = null
@@ -94,6 +106,7 @@ function scanEnv(): EnvConfig {
items: env('CONFIG_ANNOUNCEMENT_ITEMS', 'NUXT_PUBLIC_CONFIG_ANNOUNCEMENT_ITEMS'),
url: env('CONFIG_ANNOUNCEMENT_URL', 'NUXT_PUBLIC_CONFIG_ANNOUNCEMENT_URL'),
}),
+ vpnDetection: edgeProvidesVpnEvidence(parseEdgeProvider(process.env.EDGE_PROVIDER)),
}
}
@@ -130,6 +143,10 @@ function fromRuntimeConfig(): EnvConfig {
items: rc.configAnnouncementItems,
url: rc.configAnnouncementUrl,
}),
+ // Static/CDN deployments carry no edge preset information — skip the
+ // VPN probe (the server derives the authoritative verdict from edge
+ // request headers regardless).
+ vpnDetection: false,
}
}
@@ -139,10 +156,8 @@ export const useEnvConfig = (): EnvConfig => {
if (import.meta.server) {
cached = scanEnv()
}
- /* eslint-disable @typescript-eslint/no-explicit-any -- server-injected window global */
- else if (typeof window !== 'undefined' && (window as any).__APP_CONFIG__) {
- cached = (window as any).__APP_CONFIG__
- /* eslint-enable @typescript-eslint/no-explicit-any */
+ else if (typeof window !== 'undefined' && window.__APP_CONFIG__) {
+ cached = window.__APP_CONFIG__
}
else {
cached = fromRuntimeConfig()
diff --git a/docs/architecture.md b/docs/architecture.md
index 8f19ac972..2a6e8ddad 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -343,9 +343,9 @@ The Nuxt server layer (`server/api/`) proxies requests to external services (RPC
|---|---|
| **CORS** (`server/middleware/cors.ts`) | Restricts API access to configured origins |
| **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 |
+| **Geo-blocking** (`server/middleware/geo-gate.ts`) | Blocks sanctioned countries via the edge-provided country (`getEdgeContext`); fails closed (HTTP 451) if a geo-capable edge leaves the country undetermined outside dev |
| **RPC method whitelist** (`server/api/internal/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 the edge provides no trusted client identity in prod |
| **Swap quote contract validation** (`@eulerxyz/euler-v2-sdk` `swapService`) | Validates each fetched quote's swapper and verifier addresses against the chain's canonical deployment allowlist |
#### Rate Limiting
@@ -357,26 +357,40 @@ The app includes a built-in per-IP rate limiter as a defense-in-depth measure. D
- **Tenderly simulate**: 10 requests
- **Address screening**: 10 requests
-**Wallet screening fail-closed**: `server/api/internal/screen-address.post.ts` proxies address checks to the data-v3 compliance API (configured via `ADDRESS_SCREENING_URI` + `ADDRESS_SCREENING_API_KEY`; the shared upstream logic lives in `server/utils/screening.ts`). With BOTH env vars unset the deployment is treated as having no screening provider (a fork, typically) and every address passes — except in production (`DOPPLER_ENVIRONMENT=prd`, the same convention the CORS/geo/rate middleware use), where an absent configuration is a failed secret injection rather than an opt-out and screening fails closed. Once either var is set, the path is fail-closed: partial configuration, a non-https `ADDRESS_SCREENING_URI` (plain http is tolerated for localhost/127.0.0.1 only — the restricted key must not travel without TLS), upstream errors, timeouts, redirects, address-mismatched or malformed verdicts all return `addressIsSuspicious: true`. A strict client `vpnIsUsed: true` is an additional positive signal; client false or malformed values cannot clear trusted edge VPN headers, and the client also blocks its own positive local verdict without waiting for a remote clean result. Operators of screened deployments must therefore set both vars — and be aware that removing both silently disables screening, production refuses to run unscreened, and non-production monitoring can watch the screening-disabled log line. The route is also consumed cross-origin by first-party `*.euler.finance` SPAs that have no server of their own — `server/middleware/cors.ts` carries a CORS exception scoped to exactly this path, so no other internal route is exposed to sibling apps. It deliberately stays under `/api/internal/` (not `/api/public/`): the consumers are our own apps, and the public prefix would advertise it to external integrators. Because of these external first-party consumers, changes to this route's request/response contract must stay backward-compatible.
+**Wallet screening fail-closed**: `server/api/internal/screen-address.post.ts` proxies address checks to the data-v3 compliance API (configured via `ADDRESS_SCREENING_URI` + `ADDRESS_SCREENING_API_KEY`; the shared upstream logic lives in `server/utils/screening.ts`). With BOTH env vars unset the deployment is treated as having no screening provider (a fork, typically) and every address passes — except in production (`DOPPLER_ENVIRONMENT=prd`, the same convention the CORS/geo/rate middleware use), where an absent configuration is a failed secret injection rather than an opt-out and screening fails closed. Once either var is set, the path is fail-closed: partial configuration, a non-https `ADDRESS_SCREENING_URI` (plain http is tolerated for localhost/127.0.0.1 only — the restricted key must not travel without TLS), upstream errors, timeouts, redirects, address-mismatched or malformed verdicts all return `addressIsSuspicious: true`. VPN usage is audit metadata and does not gate wallet access: the client always continues to address screening, including when VPN usage is detected or the VPN probe fails. The client reports missing, invalid, failed, or unsupported VPN measurements as `null`. A strict client `vpnIsUsed: true` contributes positive audit evidence; client false or malformed values cannot clear trusted edge VPN headers. Operators of screened deployments must therefore set both vars — and be aware that removing both silently disables screening, production refuses to run unscreened, and non-production monitoring can watch the screening-disabled log line. The route is also consumed cross-origin by first-party `*.euler.finance` SPAs that have no server of their own — `server/middleware/cors.ts` carries a CORS exception scoped to exactly this path, so no other internal route is exposed to sibling apps. It deliberately stays under `/api/internal/` (not `/api/public/`): the consumers are our own apps, and the public prefix would advertise it to external integrators. Because of these external first-party consumers, changes to this route's request/response contract must stay backward-compatible.
**Important**: This is a best-effort safeguard, not a security boundary. It catches accidental abuse (e.g. a client stuck in a retry loop) but will not stop a determined attacker. Known limitations:
- **In-memory state is per-process** — if Nitro spawns multiple workers, each gets its own budget, effectively multiplying the limit.
-#### Cloudflare Requirement
+#### Edge Provider
-**Production deployments must be behind Cloudflare.** This is a hard requirement, not a recommendation — two independent server features depend on it:
+The server never reads vendor edge headers directly. `getEdgeContext(event)` (`server/utils/edge.ts`) normalizes whatever the fronting infrastructure provides into a single shape — trusted client IP, country, VPN evidence, origin-auth status — and every consumer (geo-gate, rate limiter, CORS country hint, screening audit) reads that. The vendor-specific header mapping lives exclusively in `utils/edge-presets.ts`, selected by the `EDGE_PROVIDER` env var:
-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.
+| Preset | Trusted client IP | Country | VPN evidence |
+|---|---|---|---|
+| `cloudflare` | `cf-connecting-ip` | `cf-ipcountry` | `x-is-vpn` / `x-is-proxy-or-vpn` |
+| `google` | `x-forwarded-for` second-to-last entry (LB-appended) | `x-client-geo` (LB custom request header, see below) | — |
+| `cloudfront` | `cloudfront-viewer-address` (port stripped) | `cloudfront-viewer-country` | — |
+| `none` (default) | rightmost `x-forwarded-for` entry, else socket | — | — |
-Bypass behaviour per environment:
+**Production deployments must set `EDGE_PROVIDER` explicitly** — the server refuses to boot in `prd` without it (`server/plugins/edge-guard.ts`), because the `none` default runs with geo-blocking off. `none` is intended for forks and previews that have no fronting edge. It is permitted in production only as an explicit opt-out (edge-guard logs a warning at boot): under `none` there is no trusted identity at all — the rate limiter keys on the rightmost `x-forwarded-for` entry, which a direct client can rotate unless the hosting platform's proxy rewrites it — so `none` must not be read as rate-limit protection.
+
+**`google` preset prerequisite**: Google's external load balancer does not set a country header on its own. The backend service must be configured with the custom request header `x-client-geo: {client_region}`, which the LB then stamps on every forwarded request (replacing any client-supplied value). Without it the header arrives from the client untouched and the country is forgeable — origin auth proves the request traversed the LB, not that the LB wrote this header. The preset also assumes exactly one LB hop for the `x-forwarded-for` identity.
+
+**Origin auth** (`EDGE_ORIGIN_SECRET`): when set, every request must carry a matching `x-edge-origin-auth` header, stamped by the edge (e.g. a request-header transform rule). Requests without it are treated as having bypassed the edge: their trusted inputs are voided and the fail-closed paths below apply. The secret is optional for the `cloudflare` and `none` presets — until it is set, the edge headers are trusted on the historical assumption that the origin is only reachable through the edge. It is **required** for `google` and `cloudfront` (the server refuses to boot without it): those edges forward client headers untouched, so without origin auth their trusted inputs would be forgeable by anyone who can reach the origin. Configuring the secret is what closes direct-to-origin spoofing in every preset.
+
+**Internal fetches** (`server/utils/internal-headers.ts`): server-internal `$fetch` calls (warm-cache, vaults-cache, labels) authenticate with an `x-edge-internal` marker whose value is the origin-auth secret or, when none is configured, a random per-process value — unforgeable under every preset with zero configuration. The container healthcheck does not use it: it probes `/healthz`, which lives outside `/api/` and is deliberately independent of edge configuration.
+
+Fail-closed behaviour per environment (geo-capable presets):
| 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. |
-| `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`. |
+| `prd` | Country required; fail-closed (HTTP 451) if undetermined. `DEV_GEO_COUNTRY` is rejected at boot (`assertEdgeConfig`), so it cannot mask a missing country. | Trusted identity required; fail-closed (HTTP 403) if absent. |
+| `stg` | Country required; fail-closed (HTTP 451) if undetermined. `DEV_GEO_COUNTRY` bypasses fail-closed if set. | Trusted identity **not** required; falls back to `X-Forwarded-For`. |
+| `dev` | Country not required; falls back to `DEV_GEO_COUNTRY`, then allows through if unset. | Trusted identity not required; falls back to `X-Forwarded-For`. |
+
+Under the `none` preset the geo-gate does not fail closed (there is no geo evidence by design) and the rate limiter keys budgets on the rightmost `x-forwarded-for` entry, best-effort (see the production caveat above).
### Clickjacking & Framing Defenses
diff --git a/docs/geo-blocking.md b/docs/geo-blocking.md
index bd01c85e2..53937f3ad 100644
--- a/docs/geo-blocking.md
+++ b/docs/geo-blocking.md
@@ -51,13 +51,13 @@ 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 the country from the configured edge provider's trusted header via `getEdgeContext` (`server/utils/edge.ts`; header mapping per `EDGE_PROVIDER` preset in `utils/edge-presets.ts`). The edge-set header is protected from client tampering when the origin is reachable only through the edge or origin auth (`EDGE_ORIGIN_SECRET`) is enabled — see the Edge Provider section of `docs/architecture.md`; a caller who reaches an unauthenticated origin directly can still forge vendor headers. Any client-supplied `x-country-code` request header is stripped by `cors.ts` before processing. Under the `none` preset (no edge — forks, previews) the placeholder `--` is emitted so client-side checks don't fail closed.
Detection is cached for 5 minutes to avoid repeated network calls.
```text
Browser → HEAD / → cors.ts strips client x-country-code
- → reads CF-IPCountry: DE
+ → getEdgeContext reads the edge country header: DE
→ sets response x-country-code: DE
← x-country-code: DE ← stored as "DE"
```
@@ -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**: In development (`DOPPLER_ENVIRONMENT=dev`) there is no edge in the request path, so no country header is 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.
## Server-Side Geo-Gate
@@ -79,15 +79,16 @@ A concurrency guard (`loadingCountry`) prevents duplicate in-flight requests if
All API requests first pass through the server-side geo-gate, which applies the same sanctioned-country check at the edge before any client-side logic runs.
-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.
+The gate reads the country from the edge context (`getEdgeContext`), i.e. from the trusted header of the preset selected by `EDGE_PROVIDER`. Special values such as `XX` (unknown IP) and non-alpha codes (e.g. `T1` for Tor exit nodes) are treated as an undetermined country. If a geo-capable preset leaves the country undetermined **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. Under the `none` preset geo-blocking is off (no geo evidence exists by design); production refuses to boot without an explicit `EDGE_PROVIDER`, so this state cannot be reached by mere omission.
```text
-Request → cors.ts (strip client x-country-code, set response x-country-code from CF-IPCountry)
- → geo-gate.ts (read CF-IPCountry)
+Request → cors.ts (strip client x-country-code, set response x-country-code from the edge context)
+ → geo-gate.ts (read country from the edge context)
├─ country determined → check SANCTIONED_COUNTRIES → block or allow
└─ country undetermined
+ ├─ `none` preset → allow (geo-blocking off)
├─ dev env → allow
- └─ prod env → HTTP 451 (fail-closed)
+ └─ otherwise → HTTP 451 (fail-closed)
```
When the gate blocks a request or flags VPN/proxy usage it logs the request path. Because some `/api/*` routes embed a wallet address in the path (e.g. `/api/internal/proxy/merkl/users/0x.../rewards`), the path is first run through `safePathTemplate` (`server/utils/observability.ts`), which replaces address and numeric segments with `:address`/`:number` placeholders. This keeps wallet addresses (PII) out of the log sink while preserving the route shape for observability. Routing and matching still operate on the real path.
@@ -411,11 +412,11 @@ Existing positions in blocked vaults show the "Restricted" chip. No chip for sof
```text
App Startup
│
- ├─ loadCountry() ─► HEAD / ─► cors.ts sets x-country-code from CF-IPCountry
+ ├─ loadCountry() ─► HEAD / ─► cors.ts sets x-country-code from the edge context
│ ◄─────────── x-country-code: DE ──────────────────────────
│ country ref: "DE" (5-min cache)
- │ undefined → null (fail-closed) on unknown/error (prod)
- │ undefined → "--" (non-null sentinel, fail-open) on unknown in dev
+ │ undefined → null (fail-closed) on unknown/error (geo-capable edge, prod)
+ │ undefined → "--" (non-null sentinel, fail-open) on unknown in dev or under the `none` preset
│
└─ loadLabels() ──► euler-labels data source (GitHub or S3/CDN) ─► products.json ─► product.block
product.vaultOverrides[addr].block
@@ -431,9 +432,9 @@ App Startup
(5-min cache)
Server-Side (every API request):
- geo-gate.ts reads CF-IPCountry
- ├─ unknown/XX/T1 + prod → HTTP 451 (fail-closed)
- ├─ unknown + dev → allow
+ geo-gate.ts reads the country from the edge context (EDGE_PROVIDER preset)
+ ├─ unknown/XX/T1 + geo-capable edge + prod → HTTP 451 (fail-closed)
+ ├─ unknown + dev, or `none` preset → allow
└─ known country → check SANCTIONED_COUNTRIES → block or allow
Runtime Check: isVaultBlockedByCountry("0x1234...")
@@ -501,7 +502,8 @@ All blocking and restriction configuration lives outside the app codebase:
| Earn vault restrictions | `euler-labels` repo — `earn-vaults.json` `restricted` field | Soft-restricts specific earn vaults |
| Asset-level blocks/restrictions (per-chain) | `euler-labels` repo — `{chainId}/assets.json` | Blocks/restricts any vault whose underlying is listed + the token in the swap picker |
| Asset-level blocks/restrictions (cross-chain) | `euler-labels` repo — `all/assets.json` | Same as per-chain, usually pattern rules (`symbols`/`symbolRegex`/`names`/`nameRegex`) that apply on every chain |
-| Dev country simulation | `.env` — `DEV_GEO_COUNTRY=GB` | Simulates a country in dev (no effect outside dev) |
+| Edge provider preset | `.env` — `EDGE_PROVIDER` | Selects the trusted-header mapping (geo, client IP, VPN evidence); required in production |
+| Dev country simulation | `.env` — `DEV_GEO_COUNTRY=GB` | Simulates a country when the edge provides none (dev, previews); production refuses to boot with it set |
Changes to `products.json`, `earn-vaults.json`, `{chainId}/assets.json`, or `all/assets.json` in the euler-labels data source (GitHub repo or S3/CDN) take effect within 5 minutes (the label cache TTL) without any app deployment.
@@ -510,7 +512,8 @@ Changes to `products.json`, `earn-vaults.json`, `{chainId}/assets.json`, or `all
| File | Role |
|------|------|
| `services/country.ts` | Client-side country detection via HEAD request and `x-country-code` response header |
-| `server/middleware/cors.ts` | Strips client `x-country-code`, derives authoritative value from `CF-IPCountry`, emits as response header |
+| `server/middleware/cors.ts` | Strips client `x-country-code`, derives authoritative value from the edge context, emits as response header |
+| `server/utils/edge.ts` + `utils/edge-presets.ts` | Normalized edge context (`getEdgeContext`) and the per-provider trusted-header mapping (`EDGE_PROVIDER` presets) |
| `server/middleware/geo-gate.ts` | Server-side sanctioned-country block; fail-closed on unknown country in prod |
| `composables/useGeoBlock.ts` | Core blocking logic, `isVaultBlockedByCountry`, `isVaultRestrictedByCountry`, `isAssetBlockedByCountry`, `isAssetRestrictedByCountry`, `getVaultTags`; `AssetLike` type |
| `composables/useEulerLabels.ts` | SDK-backed label loading and current label snapshot |
diff --git a/features/reviewed-execution/planning/requirements.ts b/features/reviewed-execution/planning/requirements.ts
index b309f2a37..7ae118a13 100644
--- a/features/reviewed-execution/planning/requirements.ts
+++ b/features/reviewed-execution/planning/requirements.ts
@@ -17,20 +17,32 @@ const VAULT_KEYS = new Set(['vault', 'vaultAddress', 'borrowVault', 'collateralV
const ASSET_KEYS = new Set(['assetAddress', 'liabilityAsset', 'tokenIn', 'tokenOut', 'collateralAsset', 'debtAsset', 'fromAsset', 'toAsset', 'oldLiabilityAsset', 'newLiabilityAsset', 'wrappedTokenAddress', 'loanToken', 'collateralToken'])
const ACCOUNT_KEYS = new Set(['owner', 'receiver', 'borrowAccount', 'repayAccount', 'positionAccount', 'liabilityAccount', 'fromAccount', 'from', 'to', 'subAccount', 'accountIn', 'accountOut', 'account', 'eulerAccount'])
-const collectNamedAddresses = (value: unknown, key: string | undefined, target: { accounts: Set
, vaults: Set, assets: Set }) => {
+const collectNamedAddresses = (
+ value: unknown,
+ key: string | undefined,
+ target: { accounts: Set, vaults: Set, assets: Set },
+ collectVaults = true,
+) => {
if (typeof value === 'string' && isAddress(value)) {
const address = getAddress(value)
// SDK swap quotes use zero-address account/vault fields to mean that the
// corresponding wallet-side leg is absent. They are transport sentinels,
// not snapshot or policy dependencies.
if (address === zeroAddress) return
- if (key && VAULT_KEYS.has(key)) target.vaults.add(address)
+ if (collectVaults && key && VAULT_KEYS.has(key)) target.vaults.add(address)
else if (key && ASSET_KEYS.has(key)) target.assets.add(address)
else if (key && ACCOUNT_KEYS.has(key)) target.accounts.add(address)
return
}
- if (Array.isArray(value)) value.forEach(entry => collectNamedAddresses(entry, key, target))
- else if (value && typeof value === 'object') Object.entries(value).forEach(([childKey, entry]) => collectNamedAddresses(entry, childKey, target))
+ if (Array.isArray(value)) value.forEach(entry => collectNamedAddresses(entry, key, target, collectVaults))
+ else if (value && typeof value === 'object') {
+ Object.entries(value).forEach(([childKey, entry]) => {
+ // Migration positionRef values belong to external protocols, including
+ // externalTarget.positionRef. Their assets still need policy checks,
+ // but a MetaMorpho vault cannot be loaded through the Euler registry.
+ collectNamedAddresses(entry, childKey, target, collectVaults && childKey !== 'positionRef')
+ })
+ }
}
export const intentSetDigest = (intents: readonly OperationIntent[]): Hash =>
diff --git a/package-lock.json b/package-lock.json
index dedff1f3f..7192336d1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11658,9 +11658,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
- "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
@@ -14512,6 +14512,22 @@
"node": ">=16"
}
},
+ "node_modules/postcss-svgo/node_modules/css-select": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz",
+ "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^7.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "nth-check": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/postcss-svgo/node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -14525,6 +14541,18 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
+ "node_modules/postcss-svgo/node_modules/css-what": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz",
+ "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/postcss-svgo/node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
@@ -14532,18 +14560,18 @@
"license": "CC0-1.0"
},
"node_modules/postcss-svgo/node_modules/svgo": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz",
- "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz",
+ "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==",
"license": "MIT",
"dependencies": {
"commander": "^11.1.0",
- "css-select": "^5.1.0",
+ "css-select": "^6.0.0",
"css-tree": "^3.0.1",
- "css-what": "^6.1.0",
+ "css-what": "^7.0.0",
"csso": "^5.0.5",
"picocolors": "^1.1.1",
- "sax": "^1.5.0"
+ "sax": "1.6.1"
},
"bin": {
"svgo": "bin/svgo.js"
@@ -15695,9 +15723,9 @@
}
},
"node_modules/sax": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
- "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
+ "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
@@ -16356,9 +16384,9 @@
}
},
"node_modules/svgo": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz",
- "integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==",
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz",
+ "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==",
"license": "MIT",
"dependencies": {
"commander": "^7.2.0",
diff --git a/scripts/execution-record.mjs b/scripts/execution-record.mjs
index 2688022d6..b7e65ece7 100644
--- a/scripts/execution-record.mjs
+++ b/scripts/execution-record.mjs
@@ -548,10 +548,14 @@ async function preflightV3Proxy({ appUrl, fixture }) {
method: 'POST',
headers: {
'content-type': 'application/json',
- // Internal sentinel (see server/utils/internal-headers.ts) so the
- // no-Origin rejection in server/middleware/cors.ts doesn't 403 this
- // preflight against non-dev servers.
- 'cf-connecting-ip': '127.0.0.1',
+ // Identify as a normal first-party caller: the app's own origin is
+ // in the CORS allowlist by construction (NUXT_PUBLIC_APP_URL /
+ // CORS_ALLOWED_ORIGINS), so the no-Origin rejection in
+ // server/middleware/cors.ts doesn't 403 this preflight against
+ // non-dev servers. Internal-request markers are deliberately
+ // unavailable to external processes like this recorder (see
+ // server/utils/internal-headers.ts).
+ 'origin': new URL(appUrl).origin,
},
body: JSON.stringify({
chainId: Number(fixture.chainId),
diff --git a/server/middleware/cors.ts b/server/middleware/cors.ts
index 1a0fd70ca..82993fdef 100644
--- a/server/middleware/cors.ts
+++ b/server/middleware/cors.ts
@@ -2,6 +2,7 @@ import type { H3Event } from 'h3'
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { createError, getCookie, getRequestURL, setCookie, setResponseHeader, sendNoContent } from 'h3'
import { logger } from '~/server/utils/logger'
+import { getEdgeContext } from '~/server/utils/edge'
import { isInternalRequest } from '~/server/utils/internal-headers'
function parseAllowedOrigins(): Set {
@@ -66,8 +67,8 @@ function isEulerFinanceOrigin(origin: string): boolean {
const FIRST_PARTY_COOKIE_NAME = 'euler_lite_first_party'
// The cookie is an advisory first-party marker, not a security boundary:
-// anyone can obtain it from `GET /`, and the internal sentinel
-// (see server/utils/internal-headers.ts) bypasses this check entirely.
+// anyone can obtain it from `GET /`, and internal server-to-server requests
+// (see server/utils/internal-headers.ts) bypass this check entirely.
// Its job is to let same-origin browser GETs (which carry no Origin
// header) through the no-Origin rejection below.
//
@@ -135,29 +136,22 @@ export default defineEventHandler((event) => {
}
// Strip any client-supplied x-country-code to prevent geo-blocking bypass.
- // The authoritative value comes from Cloudflare's CF-IPCountry header which is
- // set by their edge network and cannot be modified by clients.
+ // The authoritative value comes from the configured edge provider's trusted
+ // header (see server/utils/edge.ts), which clients cannot modify. The
+ // context also applies the DEV_GEO_COUNTRY fallback (mirroring geo-gate.ts)
+ // so envs without a geo-capable edge still emit x-country-code.
delete event.node.req.headers['x-country-code']
- const cfCountry = (event.node.req.headers['cf-ipcountry'] as string | undefined)?.toUpperCase()
- let country = (cfCountry && /^[A-Z]{2}$/.test(cfCountry) && cfCountry !== 'XX') ? cfCountry : undefined
+ const edge = getEdgeContext(event)
- // 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) {
- const devCountry = process.env.DEV_GEO_COUNTRY?.toUpperCase()
- if (devCountry && /^[A-Z]{2}$/.test(devCountry) && devCountry !== 'XX') {
- country = devCountry
- }
- }
-
- if (country) {
- setResponseHeader(event, 'x-country-code', country)
+ if (edge.country) {
+ setResponseHeader(event, 'x-country-code', edge.country)
}
- else if (process.env.DOPPLER_ENVIRONMENT === 'dev') {
- // 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.
+ else if (!edge.providesGeo || process.env.DOPPLER_ENVIRONMENT === 'dev') {
+ // No geo evidence exists by design (`none` preset — forks, previews) or
+ // this is local dev without DEV_GEO_COUNTRY — 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', '--')
}
diff --git a/server/middleware/geo-gate.ts b/server/middleware/geo-gate.ts
index 84c3695b0..18b4d59f9 100644
--- a/server/middleware/geo-gate.ts
+++ b/server/middleware/geo-gate.ts
@@ -1,5 +1,6 @@
import { createError, getRequestURL } from 'h3'
import { SANCTIONED_COUNTRIES } from '~/entities/country-constants'
+import { getEdgeContext } from '~/server/utils/edge'
import { isInternalRequest } from '~/server/utils/internal-headers'
import { logger } from '~/server/utils/logger'
import { safePathTemplate } from '~/server/utils/observability'
@@ -12,38 +13,29 @@ 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 — they never traversed the edge and carry no country, which
+ // would otherwise fail-closed and 451 every internal fetch. See
+ // server/utils/internal-headers.ts for the trust model.
if (isInternalRequest(event)) {
return
}
- // 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.
- const cfCountry = (event.node.req.headers['cf-ipcountry'] as string | undefined)?.toUpperCase()
- let country = (cfCountry && /^[A-Z]{2}$/.test(cfCountry) && cfCountry !== 'XX') ? cfCountry : undefined
+ // The country comes from the configured edge provider's trusted header
+ // (see server/utils/edge.ts) — clients cannot modify it, and any
+ // client-supplied x-country-code is stripped in cors.ts. The context also
+ // applies the DEV_GEO_COUNTRY fallback for envs without a geo-capable
+ // edge (local dev, PR previews), so those aren't universally fail-closed.
+ const edge = getEdgeContext(event)
+ const country = edge.country
- // 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) {
- const devCountry = process.env.DEV_GEO_COUNTRY?.toUpperCase()
- if (devCountry && /^[A-Z]{2}$/.test(devCountry) && devCountry !== 'XX') {
- country = devCountry
- }
- }
-
- // 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') {
+ // Fail-closed: when the edge is expected to provide a country but none
+ // was determined, deny access. This prevents bypassing geo-blocks by
+ // omitting or spoofing headers. Not applied under the `none` preset
+ // (no geo evidence exists by design — forks, previews) or in dev
+ // (DOPPLER_ENVIRONMENT=dev) without DEV_GEO_COUNTRY set.
+ if (!country && edge.providesGeo && process.env.DOPPLER_ENVIRONMENT !== 'dev') {
logger.warn(
- { ctx: 'geo-gate', cfCountry: cfCountry || 'absent', pathTemplate: safePathTemplate(url.pathname) },
+ { ctx: 'geo-gate', pathTemplate: safePathTemplate(url.pathname) },
'blocked: country undetermined',
)
throw createError({
@@ -52,13 +44,10 @@ export default defineEventHandler((event) => {
})
}
- const isVpn = event.node.req.headers['x-is-vpn']
- const isProxyOrVpn = event.node.req.headers['x-is-proxy-or-vpn']
-
// Log VPN/proxy usage for monitoring (do not block -- too many false positives)
- if (isVpn === 'true' || isProxyOrVpn === 'true') {
+ if (edge.vpnIsUsed === true) {
logger.warn(
- { ctx: 'geo-gate', country, isVpn, isProxyOrVpn, pathTemplate: safePathTemplate(url.pathname) },
+ { ctx: 'geo-gate', country, vpnIsUsed: true, pathTemplate: safePathTemplate(url.pathname) },
'VPN/proxy detected',
)
}
diff --git a/server/plugins/app-config.ts b/server/plugins/app-config.ts
index cc40553f8..6301f862a 100644
--- a/server/plugins/app-config.ts
+++ b/server/plugins/app-config.ts
@@ -14,6 +14,7 @@ import {
V3_API_PROXY_URL,
} from '~/utils/api-url-env'
import { buildAnnouncementConfig } from '~/utils/announcement-config'
+import { edgeProvidesVpnEvidence, parseEdgeProvider } from '~/utils/edge-presets'
import { escapeScriptJson } from '~/server/utils/escape-script-json'
export { escapeScriptJson }
@@ -55,6 +56,10 @@ function readAppConfig() {
// Adapter chain pinned for the browser's "fast" SDK instance. See
// utils/api-url-env.ts:readBrowserVaultSource.
browserVaultSource: readBrowserVaultSource(),
+ // Whether the deployment's edge provider measures VPN usage. The client
+ // uses this to skip the VPN probe (services/vpn.ts) on edges that carry
+ // no VPN evidence.
+ vpnDetection: edgeProvidesVpnEvidence(parseEdgeProvider(process.env.EDGE_PROVIDER)),
swapApiUrl: env('SWAP_API_URL', 'NUXT_PUBLIC_SWAP_API_URL'),
eulerInterfacesBranch: env(
'EULER_SDK_EULER_INTERFACES_BRANCH',
diff --git a/server/plugins/edge-guard.ts b/server/plugins/edge-guard.ts
new file mode 100644
index 000000000..b659c92b5
--- /dev/null
+++ b/server/plugins/edge-guard.ts
@@ -0,0 +1,29 @@
+/**
+ * Boot-time edge configuration guard.
+ *
+ * Refuses to start when EDGE_PROVIDER is invalid, or unset in production —
+ * a production deployment that silently fell back to the `none` preset
+ * would serve sanctioned countries (geo-blocking off) and lose its trusted
+ * client identity. Failing the boot turns that misconfiguration into a
+ * deploy-time incident instead of a compliance one.
+ */
+import { assertEdgeConfig } from '~/server/utils/edge'
+import { parseEdgeProvider } from '~/utils/edge-presets'
+import { logger } from '~/server/utils/logger'
+
+export default defineNitroPlugin(() => {
+ assertEdgeConfig()
+ const edgeProvider = parseEdgeProvider(process.env.EDGE_PROVIDER)
+ const originAuth = process.env.EDGE_ORIGIN_SECRET?.trim() ? 'enforced' : 'off'
+ logger.info({ ctx: 'edge-guard', edgeProvider, originAuth }, 'edge provider configuration resolved')
+
+ // `none` is a permitted, explicit opt-out in production, but it carries no
+ // trusted identity: geo-blocking is off and the rate limiter keys on a
+ // forgeable x-forwarded-for entry (see server/utils/rate-limit.ts).
+ if (edgeProvider === 'none' && process.env.DOPPLER_ENVIRONMENT === 'prd') {
+ logger.warn(
+ { ctx: 'edge-guard', edgeProvider },
+ 'production is running without a fronting edge: geo-blocking disabled, rate-limit identity forgeable',
+ )
+ }
+})
diff --git a/server/routes/healthz.get.ts b/server/routes/healthz.get.ts
new file mode 100644
index 000000000..cf239d10e
--- /dev/null
+++ b/server/routes/healthz.get.ts
@@ -0,0 +1,17 @@
+import { forceNoStoreCacheHeaders } from '~/server/utils/cache-headers'
+
+/**
+ * Container/platform liveness probe (Docker HEALTHCHECK, Railway).
+ *
+ * Deliberately lives OUTSIDE /api/ so it is exempt from the geo-gate, rate
+ * limiting, and internal-request authentication: a liveness probe must not
+ * depend on edge configuration (EDGE_PROVIDER / EDGE_ORIGIN_SECRET), and
+ * giving it the origin secret would leak the secret into healthcheck
+ * arguments and process listings. It reports process liveness only — no
+ * upstream or config checks — so it cannot flap for reasons a container
+ * restart wouldn't fix.
+ */
+export default defineEventHandler((event) => {
+ forceNoStoreCacheHeaders(event)
+ return { status: 'ok' }
+})
diff --git a/server/utils/edge.ts b/server/utils/edge.ts
new file mode 100644
index 000000000..ce694361a
--- /dev/null
+++ b/server/utils/edge.ts
@@ -0,0 +1,116 @@
+import type { H3Event } from 'h3'
+import {
+ EDGE_ORIGIN_AUTH_HEADER,
+ edgeProvidesGeo,
+ edgeRequiresOriginSecret,
+ extractEdgeInputs,
+ normalizeCountry,
+ parseEdgeProvider,
+} from '~/utils/edge-presets'
+import { isInternalRequest } from '~/server/utils/internal-headers'
+import { timingSafeEqualStrings } from '~/server/utils/timing-safe'
+
+/**
+ * Normalized view of everything the fronting edge infrastructure tells us
+ * about a request. Consumers (geo-gate, rate-limit, cors, screening) read
+ * this instead of vendor headers; the vendor mapping lives exclusively in
+ * `utils/edge-presets.ts`.
+ */
+export interface EdgeContext {
+ /** Trusted client IP, or null when there is no trustworthy identity. */
+ clientIp: string | null
+ /** ISO 3166-1 alpha-2 country, or null when unmeasured. Includes the
+ * DEV_GEO_COUNTRY fallback (envs without a geo-capable edge). */
+ country: string | null
+ /** VPN/proxy evidence from the edge, or null when unmeasured. */
+ vpnIsUsed: boolean | null
+ /** Origin-auth secret verified, or no secret is configured. When false,
+ * the request bypassed the edge and every input above is null. */
+ authenticated: boolean
+ /** Server-internal $fetch call (see server/utils/internal-headers.ts). */
+ isInternal: boolean
+ /** Whether the configured preset is expected to provide a country —
+ * drives the geo-gate's fail-closed (451) branch. */
+ providesGeo: boolean
+}
+
+export function getEdgeContext(event: H3Event): EdgeContext {
+ const provider = parseEdgeProvider(process.env.EDGE_PROVIDER)
+ const providesGeo = edgeProvidesGeo(provider)
+ const secret = process.env.EDGE_ORIGIN_SECRET?.trim()
+ const isInternal = isInternalRequest(event)
+ const headers = event.node.req.headers
+
+ const authHeader = headers[EDGE_ORIGIN_AUTH_HEADER]
+ const authenticated = !secret
+ || (typeof authHeader === 'string' && timingSafeEqualStrings(authHeader, secret))
+
+ if (!authenticated) {
+ // Origin auth is configured but this request doesn't carry the secret:
+ // it bypassed the edge, so none of the edge-derived inputs can be
+ // trusted. Reporting them as absent routes the request into every
+ // consumer's fail-closed path. The DEV_GEO_COUNTRY fallback is skipped
+ // for the same reason.
+ return {
+ clientIp: null,
+ country: null,
+ vpnIsUsed: null,
+ authenticated: false,
+ isInternal,
+ providesGeo,
+ }
+ }
+
+ const inputs = extractEdgeInputs(provider, headers, event.node.req.socket?.remoteAddress)
+ return {
+ ...inputs,
+ // DEV_GEO_COUNTRY injects a country so deployments without a
+ // geo-capable edge (local dev, PR previews) are not universally
+ // fail-closed. Production refuses to boot with it set (assertEdgeConfig),
+ // so it can never mask a missing production country.
+ country: inputs.country ?? normalizeCountry(process.env.DEV_GEO_COUNTRY),
+ authenticated: true,
+ isInternal,
+ providesGeo,
+ }
+}
+
+/**
+ * Boot-time validation, called from `server/plugins/edge-guard.ts`.
+ *
+ * Throws on an unknown EDGE_PROVIDER value (any environment — a typo must
+ * not silently degrade to `none`), when production boots without a
+ * preset (under `none` geo-blocking is off and rate limiting falls back to
+ * best-effort identity, which is fork-friendly but never acceptable for a
+ * production deployment), and when a preset that mandates origin auth
+ * runs without EDGE_ORIGIN_SECRET (see `edgeRequiresOriginSecret`), and
+ * when production carries DEV_GEO_COUNTRY (a synthetic country would let
+ * every request skip the geo-gate's fail-closed 451 branch).
+ */
+export function assertEdgeConfig(): void {
+ const provider = parseEdgeProvider(process.env.EDGE_PROVIDER)
+ const isProduction = process.env.DOPPLER_ENVIRONMENT === 'prd'
+ if (isProduction && !process.env.EDGE_PROVIDER?.trim()) {
+ throw new Error(
+ 'EDGE_PROVIDER must be set in production (DOPPLER_ENVIRONMENT=prd): '
+ + 'without it geo-blocking is disabled and there is no trusted client identity. '
+ + 'Set it to the deployment\'s fronting edge preset, or explicitly to "none" '
+ + 'for a deployment that intentionally runs without one.',
+ )
+ }
+ if (edgeRequiresOriginSecret(provider) && !process.env.EDGE_ORIGIN_SECRET?.trim()) {
+ throw new Error(
+ `EDGE_PROVIDER=${provider} requires EDGE_ORIGIN_SECRET: without origin auth `
+ + 'this edge\'s trusted inputs would be forgeable by anyone who can reach the '
+ + 'origin directly. Configure the edge to stamp x-edge-origin-auth and set '
+ + 'the secret.',
+ )
+ }
+ if (isProduction && process.env.DEV_GEO_COUNTRY?.trim()) {
+ throw new Error(
+ 'DEV_GEO_COUNTRY must not be set in production (DOPPLER_ENVIRONMENT=prd): '
+ + 'it substitutes a synthetic country whenever the edge provides none, which '
+ + 'would let requests with an undetermined country bypass the fail-closed geo-gate.',
+ )
+ }
+}
diff --git a/server/utils/internal-headers.ts b/server/utils/internal-headers.ts
index e6f17b0f0..7852893f9 100644
--- a/server/utils/internal-headers.ts
+++ b/server/utils/internal-headers.ts
@@ -1,36 +1,60 @@
import type { H3Event } from 'h3'
+import { randomBytes } from 'node:crypto'
+import {
+ EDGE_ORIGIN_AUTH_HEADER,
+ INTERNAL_MARKER_HEADER,
+} from '~/utils/edge-presets'
+import { timingSafeEqualStrings } from '~/server/utils/timing-safe'
/**
* 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.
+ * closed when the configured edge provider supplies no trusted client
+ * identity, and the geo-gate middleware fails closed when it supplies no
+ * country. Internal fetches from warm-cache, vaults-cache, etc. never
+ * traverse the edge, so without these headers every internal request would
+ * be 403'd or 451'd. Downstream middleware recognises them via
+ * `isInternalRequest` and bypasses those checks; internal traffic is not
+ * rate-limited (warm-cache issues at most ~240 requests per 5-min cycle
+ * against a >=600/min-per-endpoint budget).
*
- * `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.
+ * The marker value is either EDGE_ORIGIN_SECRET (when configured) or a
+ * random per-process value. Internal $fetch calls are dispatched inside
+ * this same process, so the per-process value never needs to be shared —
+ * and external clients cannot guess either value, so internal status is
+ * not forgeable under any preset. (An earlier design trusted a loopback
+ * `cf-connecting-ip` sentinel, which was forgeable wherever the edge did
+ * not overwrite that header — notably under the `none` preset.)
*
- * 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 edge never stamps the marker header; configure it to strip
+ * client-supplied values as defense-in-depth. Do not add these headers to
+ * anything that forwards user input into the downstream URL.
*/
-export const INTERNAL_FETCH_HEADERS = { 'cf-connecting-ip': '127.0.0.1' } as const
+const PROCESS_INTERNAL_MARKER = randomBytes(32).toString('base64url')
+
+function internalMarkerValue(): string {
+ return process.env.EDGE_ORIGIN_SECRET?.trim() || PROCESS_INTERNAL_MARKER
+}
+
+export function getInternalFetchHeaders(): Record {
+ const secret = process.env.EDGE_ORIGIN_SECRET?.trim()
+ if (secret) {
+ return {
+ [EDGE_ORIGIN_AUTH_HEADER]: secret,
+ [INTERNAL_MARKER_HEADER]: secret,
+ }
+ }
+ return { [INTERNAL_MARKER_HEADER]: PROCESS_INTERNAL_MARKER }
+}
/**
- * 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 was issued by this server itself via
+ * `getInternalFetchHeaders()`. Middleware uses this to bypass geo/rate
+ * checks for warm-cache → `/api/*` traffic that never traversed the edge.
+ * See the trust model above.
*/
-export const isInternalRequest = (event: H3Event): boolean =>
- event.node.req.headers['cf-connecting-ip'] === '127.0.0.1'
+export function isInternalRequest(event: H3Event): boolean {
+ const marker = event.node.req.headers[INTERNAL_MARKER_HEADER]
+ return typeof marker === 'string' && timingSafeEqualStrings(marker, internalMarkerValue())
+}
diff --git a/server/utils/labels-helpers.ts b/server/utils/labels-helpers.ts
index ef78db881..4ba01cfd8 100644
--- a/server/utils/labels-helpers.ts
+++ b/server/utils/labels-helpers.ts
@@ -1,5 +1,5 @@
import { getAddress, type Address } from 'viem'
-import { INTERNAL_FETCH_HEADERS } from './internal-headers'
+import { getInternalFetchHeaders } from './internal-headers'
export interface EntityEntry {
addresses?: unknown
@@ -46,6 +46,6 @@ export async function fetchLabels(
): Promise {
return await $fetch(`/api/internal/labels/${file}`, {
query: { chainId },
- headers: INTERNAL_FETCH_HEADERS,
+ headers: getInternalFetchHeaders(),
}) as unknown as T
}
diff --git a/server/utils/labels-view.ts b/server/utils/labels-view.ts
index a096c9395..40043826c 100644
--- a/server/utils/labels-view.ts
+++ b/server/utils/labels-view.ts
@@ -17,7 +17,7 @@ import {
} from '@eulerxyz/euler-v2-sdk'
import type { Address } from 'viem'
import { createInFlightDedup } from './in-flight'
-import { INTERNAL_FETCH_HEADERS } from './internal-headers'
+import { getInternalFetchHeaders } from './internal-headers'
import { buildEntityAddressSets, declaredKeysOf, tryChecksum } from './labels-helpers'
import { logger } from './logger'
import { summarizeSdkIssue } from './observability'
@@ -137,7 +137,7 @@ const getSdk = (chainId: number): Promise => getServerSdk(chainId)
export async function fetchTokenList(chainId: number): Promise {
const data = await $fetch('/api/internal/token-list', {
query: { chainId },
- headers: INTERNAL_FETCH_HEADERS,
+ headers: getInternalFetchHeaders(),
})
return Array.isArray(data?.tokens) ? data.tokens : []
}
diff --git a/server/utils/rate-limit.ts b/server/utils/rate-limit.ts
index 89ba304ad..129c60e36 100644
--- a/server/utils/rate-limit.ts
+++ b/server/utils/rate-limit.ts
@@ -1,5 +1,7 @@
import type { H3Event } from 'h3'
import { createError } from 'h3'
+import { getEdgeContext } from '~/server/utils/edge'
+import { isInternalRequest } from '~/server/utils/internal-headers'
import { logger } from '~/server/utils/logger'
interface RateLimitEntry {
@@ -16,35 +18,36 @@ interface RateLimiterConfig {
label: string
}
-// 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.
+// NOTE: This rate limiter keys budgets on the trusted client IP delivered by
+// the configured edge provider (see server/utils/edge.ts). In production
+// (DOPPLER_ENVIRONMENT=prd), requests without a trusted identity are rejected
+// fail-closed, which closes the X-Forwarded-For rotation attack that was
+// possible via the old fallback path.
//
-// 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).
+// Residual limitation: an attacker who knows the origin IP and bypasses the
+// edge can still forge the trusted headers with rotating values. Configure
+// EDGE_ORIGIN_SECRET (origin auth) to close that, or enforce it at the
+// network level (allowlisting the edge's IP ranges at the origin firewall).
//
-// 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.
+// EDGE_PROVIDER=none is NOT protected by the production fail-closed branch:
+// the identity is the rightmost X-Forwarded-For entry (or the socket peer),
+// which is only as trustworthy as the hosting platform's proxy. A direct
+// client can rotate it and defeat the limiter. assertEdgeConfig permits
+// `none` in production as an explicit opt-out (edge-guard logs a warning at
+// boot) — operators must not read it as rate-limit protection.
+//
+// In dev and stg, the edge is not always in the request path, so the trusted
+// identity is not required and X-Forwarded-For / socket is used instead.
//
// Remaining known limitation:
// - In-memory state is per-process. If Nitro runs multiple workers the
// effective limit is multiplied by the worker count.
/**
- * Extract the client IP from an H3 event.
- *
- * Prefers CF-Connecting-IP (set by Cloudflare, cannot be spoofed by clients
- * going through Cloudflare), falls back to X-Forwarded-For, then the raw
- * socket address.
+ * Best-effort client IP for environments without a trusted edge identity
+ * (dev, stg, previews). Leftmost X-Forwarded-For, then the socket address.
*/
-export function getClientIp(event: H3Event): string {
- const cfIp = event.node.req.headers['cf-connecting-ip']
- if (typeof cfIp === 'string' && cfIp.trim()) return cfIp.trim()
-
+function fallbackClientIp(event: H3Event): string {
const forwarded = event.node.req.headers['x-forwarded-for']
const forwardedStr = Array.isArray(forwarded) ? forwarded[0] : forwarded
return (
@@ -77,26 +80,26 @@ 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 when there is no trusted client identity.
*/
consume(event: H3Event, cost = 1): void {
// Escape hatch for local tooling (e.g. parity capture) that hammers the
// app from a single IP. Never set in deployed environments.
if (process.env.DISABLE_RATE_LIMIT === 'true') return
- // 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')
+ // Server-internal $fetch calls (warm-cache, vaults-cache) are not
+ // rate-limited — see server/utils/internal-headers.ts.
+ if (isInternalRequest(event)) return
+
+ const edge = getEdgeContext(event)
+ // Fail-closed in production when the edge provided no trusted client
+ // identity: the request bypassed the edge (or failed origin auth).
+ // stg and dev are exempt: they don't always run behind the edge.
+ if (edge.clientIp === null && process.env.DOPPLER_ENVIRONMENT === 'prd') {
+ logger.warn({ ctx: 'rate-limit' }, 'blocked: no trusted client identity, request bypassed the edge')
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
}
- const ip = getClientIp(event)
+ const ip = edge.clientIp ?? fallbackClientIp(event)
const now = Date.now()
const entry = map.get(ip)
diff --git a/server/utils/screening.ts b/server/utils/screening.ts
index 1b900a478..fa92a30d2 100644
--- a/server/utils/screening.ts
+++ b/server/utils/screening.ts
@@ -1,4 +1,5 @@
import type { H3Event } from 'h3'
+import { getEdgeContext } from '~/server/utils/edge'
import { fetchWithTimeout, UPSTREAM_FETCH_TIMEOUT_MS } from '~/server/utils/fetchWithTimeout'
import { logger } from '~/server/utils/logger'
import { hashIdentifier } from '~/server/utils/observability'
@@ -42,30 +43,14 @@ export function isValidScreeningAddress(value: unknown): value is string {
return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)
}
-function isTruthyHeader(value: string | string[] | undefined): boolean {
- const headers = Array.isArray(value) ? value : [value]
- return headers
- .filter((header): header is string => typeof header === 'string')
- .flatMap(header => header.split(','))
- .some(token => token.trim().toLowerCase() === 'true')
-}
-
-function hasHeader(value: string | string[] | undefined): boolean {
- const values = Array.isArray(value) ? value : [value]
- return values.some(entry => typeof entry === 'string' && entry.trim() !== '')
-}
-
-// Edge headers remain authoritative, but a strict client `true` is an
+// Edge evidence (normalized by the edge context; `null` on presets that
+// measure no VPN usage) is authoritative, but a strict client `true` is an
// additional positive signal. Client false/invalid values cannot clear an
-// edge verdict. With no positive signal and no header the value is unknown.
+// edge verdict. With no positive signal and no edge evidence the value is
+// unknown, never a fabricated false.
export function deriveVpnIsUsed(event: H3Event, clientVpnIsUsed?: unknown): boolean | null {
if (clientVpnIsUsed === true) return true
- const vpn = event.node.req.headers['x-is-vpn']
- const proxyOrVpn = event.node.req.headers['x-is-proxy-or-vpn']
- if (!hasHeader(vpn) && !hasHeader(proxyOrVpn)) {
- return null
- }
- return isTruthyHeader(vpn) || isTruthyHeader(proxyOrVpn)
+ return getEdgeContext(event).vpnIsUsed
}
// The restricted API key must only travel over TLS, and never follow a
@@ -89,6 +74,10 @@ function isAllowedScreeningUri(uri: string): boolean {
* Screen an address against the data-v3 compliance API
* (`POST /v3/compliance/address-screening`).
*
+ * `vpnIsUsed` comes from `deriveVpnIsUsed`: edge-derived VPN evidence from
+ * the request context, or a strict client-reported `true`; `null` when the
+ * deployment's edge measures none and the client reports no positive signal.
+ *
* Fail-closed: every branch other than an HTTP 200 carrying an explicit
* `data.addressIsSuspicious: false` **for the requested address** reports the
* address as suspicious — missing or non-TLS configuration, upstream errors,
diff --git a/server/utils/timing-safe.ts b/server/utils/timing-safe.ts
new file mode 100644
index 000000000..a77f77ca4
--- /dev/null
+++ b/server/utils/timing-safe.ts
@@ -0,0 +1,15 @@
+import { timingSafeEqual } from 'node:crypto'
+
+/**
+ * Constant-time string comparison for secrets carried in headers/cookies.
+ * Compares byte lengths, not string lengths: a multibyte value can match
+ * the character count while timingSafeEqual throws on unequal buffers.
+ */
+export function timingSafeEqualStrings(provided: string, expected: string): boolean {
+ const providedBuffer = Buffer.from(provided)
+ const expectedBuffer = Buffer.from(expected)
+ if (providedBuffer.length !== expectedBuffer.length) {
+ return false
+ }
+ return timingSafeEqual(providedBuffer, expectedBuffer)
+}
diff --git a/services/screening.ts b/services/screening.ts
index 760fe0201..55d9325c0 100644
--- a/services/screening.ts
+++ b/services/screening.ts
@@ -2,7 +2,7 @@ import { WALLET_SCREENING_TIMEOUT_MS } from '~/entities/tuning-constants'
export async function screenAddress(
address: string,
- vpnIsUsed: boolean,
+ vpnIsUsed: boolean | null,
): Promise {
if (!address) return false
diff --git a/services/vpn.ts b/services/vpn.ts
index f3fe8ac73..c6f29062f 100644
--- a/services/vpn.ts
+++ b/services/vpn.ts
@@ -1,8 +1,22 @@
import { CACHE_TTL_5MIN_MS, WALLET_SCREENING_TIMEOUT_MS } from '~/entities/tuning-constants'
-let cached: { value: boolean, timestamp: number } | null = null
+let cached: { value: boolean | null, timestamp: number } | null = null
+
+// Whether the deployment's edge provider measures VPN usage at all,
+// injected by server/plugins/app-config.ts. When absent or false (edges
+// without VPN evidence, forks, static deploys) probing would only produce
+// noise. VPN evidence is audit metadata, not an access verdict; the server
+// combines positive client evidence with its own edge request headers.
+function edgeProvidesVpnEvidence(): boolean {
+ if (typeof window === 'undefined') return false
+ return window.__APP_CONFIG__?.vpnDetection === true
+}
+
+export async function detectVpn(): Promise {
+ if (!edgeProvidesVpnEvidence()) {
+ return null
+ }
-export async function detectVpn(): Promise {
if (cached !== null && Date.now() - cached.timestamp < CACHE_TTL_5MIN_MS) {
return cached.value
}
@@ -12,11 +26,12 @@ export async function detectVpn(): Promise {
try {
const resp = await fetch(window.location.origin, { method: 'HEAD', signal: controller.signal })
- const header = resp.headers.get('x-is-vpn')
- cached = { value: header === 'true', timestamp: Date.now() }
+ const header = resp.ok ? resp.headers.get('x-is-vpn')?.trim().toLowerCase() : null
+ const value = header === 'true' ? true : header === 'false' ? false : null
+ cached = { value, timestamp: Date.now() }
}
catch {
- cached = { value: true, timestamp: Date.now() }
+ cached = { value: null, timestamp: Date.now() }
}
finally {
clearTimeout(timeout)
diff --git a/tests/composables/useAddressScreen.test.ts b/tests/composables/useAddressScreen.test.ts
index 9bffdc34e..3f9e8763f 100644
--- a/tests/composables/useAddressScreen.test.ts
+++ b/tests/composables/useAddressScreen.test.ts
@@ -80,8 +80,8 @@ describe('useAddressScreen', () => {
expect(screening.isAddressScreened(USER)).toBe(true)
})
- it('disconnects restricted addresses without marking them screened', async () => {
- mocks.detectVpn.mockResolvedValue(false)
+ it.each([true, false, null])('disconnects restricted addresses regardless of VPN evidence (%s)', async (vpnIsUsed) => {
+ mocks.detectVpn.mockResolvedValue(vpnIsUsed)
mocks.screenAddress.mockResolvedValue(true)
const screening = useAddressScreen()
@@ -92,17 +92,17 @@ describe('useAddressScreen', () => {
expect(screening.isAddressScreened(USER)).toBe(false)
})
- it('blocks a positive local VPN verdict without letting remote screening clear it', async () => {
- mocks.detectVpn.mockResolvedValue(true)
+ it.each([true, false, null])('allows a screened address regardless of VPN evidence (%s)', async (vpnIsUsed) => {
+ mocks.detectVpn.mockResolvedValue(vpnIsUsed)
mocks.screenAddress.mockResolvedValue(false)
const screening = useAddressScreen()
await screening.screenConnectedAddress(USER)
- expect(mocks.screenAddress).not.toHaveBeenCalled()
- expect(mocks.disconnect).toHaveBeenCalledTimes(1)
- expect(mocks.modalOpen).toHaveBeenCalledTimes(1)
- expect(screening.isAddressScreened(USER)).toBe(false)
+ expect(mocks.screenAddress).toHaveBeenCalledWith(USER, vpnIsUsed)
+ expect(mocks.disconnect).not.toHaveBeenCalled()
+ expect(mocks.modalOpen).not.toHaveBeenCalled()
+ expect(screening.isAddressScreened(USER)).toBe(true)
})
it('invalidates a pending verdict when screening state is reset', async () => {
diff --git a/tests/reviewed-execution/intent-factory.test.ts b/tests/reviewed-execution/intent-factory.test.ts
index f9ab47c21..58e38db19 100644
--- a/tests/reviewed-execution/intent-factory.test.ts
+++ b/tests/reviewed-execution/intent-factory.test.ts
@@ -1,4 +1,4 @@
-import { getAddress, isAddress, zeroAddress } from 'viem'
+import { getAddress, isAddress, zeroAddress, zeroHash } from 'viem'
import { describe, expect, it } from 'vitest'
import { createOperationIntent } from '~/features/reviewed-execution/domain/factory'
import { collectPlanningRequirements, selectMatchingPreparedIntents } from '~/features/reviewed-execution/planning/requirements'
@@ -141,4 +141,75 @@ describe('operation intent factory', () => {
expect(collectPlanningRequirements([intent]).vaults).toEqual([TEST_VAULT, targetVault])
})
+
+ it.each(['v1', 'v2'] as const)('keeps external MetaMorpho %s references separate from Euler vault requirements', (version) => {
+ const externalVault = getAddress('0x5000000000000000000000000000000000000000')
+ const borrowVault = getAddress('0x6000000000000000000000000000000000000000')
+ const quoteVault = getAddress('0x7000000000000000000000000000000000000000')
+ const quote = makeSwapQuote()
+ const migration = createOperationIntent({
+ kind: 'migration',
+ planner: 'cross-protocol-migration',
+ args: {
+ direction: 'euler-to-external',
+ connectorId: 'metamorpho',
+ owner: TEST_ACCOUNT,
+ positionRef: { vault: externalVault, version },
+ externalTarget: { positionRef: { vault: externalVault, version } },
+ source: { eulerAccount: TEST_ACCOUNT, borrowVault, collateralVault: TEST_VAULT },
+ collateralSwapQuote: { ...quote, verify: { ...quote.verify, vault: quoteVault } },
+ deadline: 1_000n,
+ authorizationEvidenceDigest: zeroHash,
+ },
+ chainId: 1,
+ account: TEST_ACCOUNT,
+ source: 'test',
+ createdAt: 1,
+ intentId: 'intent-external-vault',
+ constraints: [{ kind: 'deadline', timestamp: 1_000 }],
+ })
+
+ expect(collectPlanningRequirements([migration]).vaults).toEqual([TEST_VAULT, borrowVault, quoteVault])
+ // An external reference must not erase an independent Euler dependency.
+ const deposit = createOperationIntent({
+ kind: 'deposit',
+ planner: 'deposit',
+ args: { vaultAddress: externalVault, assetAddress: TEST_TOKEN, amount: 12n },
+ chainId: 1,
+ account: TEST_ACCOUNT,
+ source: 'test',
+ createdAt: 1,
+ intentId: 'intent-independent-vault',
+ })
+ expect(collectPlanningRequirements([deposit, migration]).vaults).toContain(externalVault)
+ expect(collectPlanningRequirements([migration, deposit]).vaults).toContain(externalVault)
+ })
+
+ it.each([
+ ['aave', { collateralAsset: TEST_TOKEN, debtAsset: TEST_TOKEN }],
+ ['morpho', { loanToken: TEST_TOKEN, collateralToken: TEST_TOKEN, oracle: TEST_ACCOUNT, irm: TEST_ACCOUNT, lltv: 1n }],
+ ] as const)('retains assets inside %s migration position references', (connectorId, positionRef) => {
+ const migration = createOperationIntent({
+ kind: 'migration',
+ planner: 'cross-protocol-migration',
+ args: {
+ direction: 'external-to-euler',
+ connectorId,
+ owner: TEST_ACCOUNT,
+ positionRef,
+ target: { eulerAccount: TEST_ACCOUNT, collateralVault: TEST_VAULT },
+ deadline: 1_000n,
+ authorizationEvidenceDigest: zeroHash,
+ },
+ chainId: 1,
+ account: TEST_ACCOUNT,
+ source: 'test',
+ createdAt: 1,
+ intentId: 'intent-external-assets',
+ constraints: [{ kind: 'deadline', timestamp: 1_000 }],
+ })
+
+ expect(collectPlanningRequirements([migration]).assets).toEqual([TEST_TOKEN])
+ expect(collectPlanningRequirements([migration]).vaults).toEqual([TEST_VAULT])
+ })
})
diff --git a/tests/reviewed-execution/preparation-service.test.ts b/tests/reviewed-execution/preparation-service.test.ts
index d76d02904..491e3226a 100644
--- a/tests/reviewed-execution/preparation-service.test.ts
+++ b/tests/reviewed-execution/preparation-service.test.ts
@@ -1,14 +1,15 @@
import { encodeFunctionData, getAddress, keccak256, toHex } from 'viem'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { EVCBatchItem, TransactionPlan } from '@eulerxyz/euler-v2-sdk'
+import type { Account, EVCBatchItem, IHasVaultAddress, TransactionPlan } from '@eulerxyz/euler-v2-sdk'
import { EVC_ABI } from '~/abis/evc'
import type { PolicyState, WalletBinding } from '~/features/reviewed-execution/domain/reviewed-execution'
import type { OperationIntent } from '~/features/reviewed-execution/domain/intents'
+import { createOperationIntent } from '~/features/reviewed-execution/domain/factory'
import { materializePreparedPlan } from '~/features/reviewed-execution/materialization/prepared-plan'
import { IntentCompilerRegistry } from '~/features/reviewed-execution/planning/compiler'
import { GenerationPublisher, PreparationCache } from '~/features/reviewed-execution/planning/cache'
import { ReviewedExecutionPreparationService, type ReviewedExecutionDependencies } from '~/features/reviewed-execution/planning/service'
-import { PlanningSnapshotLoader } from '~/features/reviewed-execution/planning/snapshot-loader'
+import { PlanningSnapshotLoader, type SnapshotLoaderDependencies } from '~/features/reviewed-execution/planning/snapshot-loader'
import { createAppSnapshotDependencies } from '~/features/reviewed-execution/planning/app-snapshot'
import { collectPlanningRequirements } from '~/features/reviewed-execution/planning/requirements'
import { resolveAppPolicy } from '~/features/reviewed-execution/policy/app-policy'
@@ -32,6 +33,7 @@ const EVC = getAddress('0x4000000000000000000000000000000000000000')
const AAVE_POOL = getAddress('0x5000000000000000000000000000000000000000')
const POSITION_ACCOUNT = getAddress('0x6000000000000000000000000000000000000000')
const REUL = getAddress('0x7000000000000000000000000000000000000000')
+const MORPHO_VAULT = getAddress('0x8000000000000000000000000000000000000000')
const intent: OperationIntent = {
schemaVersion: 1, intentId: 'intent-1', revision: 1, kind: 'deposit', chainId: 1, account: ACCOUNT,
subAccounts: [ACCOUNT], planner: { name: 'deposit', args: { vaultAddress: VAULT, assetAddress: TOKEN, amount: 10n } },
@@ -52,10 +54,14 @@ beforeEach(() => {
vi.mocked(getEulerLabelsVersion).mockReturnValue(1)
vi.mocked(detectVpn).mockReset().mockResolvedValue(false)
vi.mocked(screenAddress).mockReset().mockResolvedValue(false)
- vi.stubGlobal('useVaultRegistry', () => ({
- getVault: (address: string) => getAddress(address) === VAULT ? currentVault : undefined,
+ const getVault = (address: string) => getAddress(address) === VAULT ? currentVault : undefined
+ const registry = {
+ getVault,
+ getOrFetch: vi.fn(async (address: string) => getVault(address)),
+ getType: (address: string) => getVault(address)?.type,
isVerifiedVault: () => true,
- }))
+ }
+ vi.stubGlobal('useVaultRegistry', () => registry)
vi.stubGlobal('useTokenList', () => ({
getTokenByAddress: (address: string) => getAddress(address) === TOKEN
? { address: TOKEN, symbol: 'TEST', name: 'Test token', decimals: 18 }
@@ -67,12 +73,15 @@ afterEach(() => {
vi.unstubAllGlobals()
})
-const createAppPolicyService = (plannerName: OperationIntent['planner']['name']) => {
+const createAppPolicyService = (
+ plannerName: OperationIntent['planner']['name'],
+ snapshotDependencies: SnapshotLoaderDependencies = { load: async key => ({
+ value: { key }, observedBlock: 100n, version: 'v1', freshUntil: 5_000,
+ }) },
+) => {
const cache = new PreparationCache()
const generation = new GenerationPublisher()
- const snapshotLoader = new PlanningSnapshotLoader(cache, { load: async key => ({
- value: { key }, observedBlock: 100n, version: 'v1', freshUntil: 5_000,
- }) }, generation, 'compiler-v1')
+ const snapshotLoader = new PlanningSnapshotLoader(cache, snapshotDependencies, generation, 'compiler-v1')
const compiler = new IntentCompilerRegistry({ [plannerName]: { compile: async () => plan } }, plans => plans.flat())
return new ReviewedExecutionPreparationService({
compiler,
@@ -119,6 +128,32 @@ const aaveMigrationIntent: OperationIntent = {
metadata: { createdAt: 1, source: 'test', operation: 'test' },
}
const aaveWallet: WalletBinding = { ...wallet, subAccounts: [ACCOUNT, POSITION_ACCOUNT] }
+const metamorphoMigrationIntent = (version: 'v1' | 'v2') => createOperationIntent({
+ kind: 'migration',
+ planner: 'cross-protocol-migration',
+ chainId: 1,
+ account: ACCOUNT,
+ subAccounts: [ACCOUNT, POSITION_ACCOUNT],
+ args: {
+ direction: 'external-to-euler',
+ connectorId: 'metamorpho',
+ owner: ACCOUNT,
+ positionRef: { vault: MORPHO_VAULT, version },
+ target: { eulerAccount: POSITION_ACCOUNT, collateralVault: VAULT },
+ deadline: 1_000n,
+ authorizationEvidenceDigest: keccak256(toHex('metamorpho-authorization')),
+ },
+ constraints: [{ kind: 'maximum-input', token: TOKEN, amount: 10n }],
+ source: 'test',
+ createdAt: 1,
+ intentId: `intent-metamorpho-${version}`,
+})
+const appSnapshotDependencies = () => createAppSnapshotDependencies({
+ account: { chainId: 1, owner: ACCOUNT, subAccounts: {} } as Account,
+ getBlockNumber: async () => 100n,
+ dataVersion: 'data-v1',
+ labelsVersion: 'labels-v1',
+})
const reulIntent: OperationIntent = {
schemaVersion: 1,
intentId: 'intent-reul-unlock',
@@ -280,6 +315,54 @@ describe('authoritative reviewed execution preparation', () => {
expect(screenAddress).not.toHaveBeenCalled()
})
+ it.each([
+ ['v1', 'migration'],
+ ['v1', 'batch'],
+ ['v2', 'migration'],
+ ['v2', 'batch'],
+ ] as const)('prepares a MetaMorpho %s migration with real app snapshots for %s review', async (version, presentationKind) => {
+ const migrationIntent = metamorphoMigrationIntent(version)
+ const service = createAppPolicyService('cross-protocol-migration', appSnapshotDependencies())
+
+ const { execution } = await service.prepare({
+ intents: [migrationIntent],
+ wallet: aaveWallet,
+ cartGeneration: 0,
+ runtime: {},
+ presentationKind,
+ presentationInputs: presentationKind === 'batch'
+ ? [{ id: migrationIntent.intentId, review: { type: 'migration' } }]
+ : { type: 'migration' },
+ compilerVersion: 'compiler-v1',
+ policyVersionDigest: keccak256(toHex('policy-v1')),
+ freshUntil: 5_000,
+ })
+
+ expect(useVaultRegistry().getOrFetch).toHaveBeenCalledExactlyOnceWith(VAULT)
+ expect(execution.binding.presentationKind).toBe(presentationKind)
+ expect(execution.intents[0].planner.args.positionRef).toEqual({ vault: MORPHO_VAULT, version })
+ expect(execution.policy.subjects).toContainEqual({ kind: 'vault-or-contract', value: VAULT })
+ expect(execution.policy.subjects).not.toContainEqual({ kind: 'vault-or-contract', value: MORPHO_VAULT })
+ await expect(resolveAppPolicy(execution.requestSet, 200)).resolves.toBeDefined()
+ })
+
+ it('still rejects a MetaMorpho migration when the Euler destination snapshot is unavailable', async () => {
+ currentVault = undefined
+ const service = createAppPolicyService('cross-protocol-migration', appSnapshotDependencies())
+
+ await expect(service.prepare({
+ intents: [metamorphoMigrationIntent('v2')],
+ wallet: aaveWallet,
+ cartGeneration: 0,
+ runtime: {},
+ presentationKind: 'migration',
+ presentationInputs: { type: 'migration' },
+ compilerVersion: 'compiler-v1',
+ policyVersionDigest: keccak256(toHex('policy-v1')),
+ freshUntil: 5_000,
+ })).rejects.toThrow(`Vault snapshot is unavailable for ${VAULT}`)
+ })
+
it('keeps real vault metadata failures fail closed', async () => {
currentVault = { address: VAULT }
const service = createAppPolicyService('deposit')
diff --git a/tests/server/cors-internal-api.test.ts b/tests/server/cors-internal-api.test.ts
index a8be29d96..ab1f938c1 100644
--- a/tests/server/cors-internal-api.test.ts
+++ b/tests/server/cors-internal-api.test.ts
@@ -293,7 +293,7 @@ describe('cors internal API boundary', () => {
expect(event.headers['X-API-Stability']).toBe('internal; may-break-without-notice')
})
- it('rejects loopback requests without a first-party cookie or internal sentinel', async () => {
+ it('rejects loopback requests without a first-party cookie or internal marker', async () => {
vi.stubEnv('DOPPLER_ENVIRONMENT', 'prd')
const handler = await loadHandler()
@@ -309,12 +309,66 @@ describe('cors internal API boundary', () => {
}
})
- it('allows same-process internal requests with the internal sentinel', async () => {
+ it('allows same-process internal requests with the internal marker', async () => {
vi.stubEnv('DOPPLER_ENVIRONMENT', 'prd')
const handler = await loadHandler()
- const internalEvent = makeEvent('/api/internal/vaults', { 'cf-connecting-ip': '127.0.0.1' })
+ // Import AFTER loadHandler's vi.resetModules() so the test reads the
+ // same per-process marker instance the reloaded middleware verifies.
+ const { getInternalFetchHeaders } = await import('~/server/utils/internal-headers')
+ const internalEvent = makeEvent('/api/internal/vaults', { ...getInternalFetchHeaders() })
expect(handler(internalEvent)).toBeUndefined()
expect(internalEvent.headers['X-API-Stability']).toBe('internal; may-break-without-notice')
})
+
+ it('rejects a forged legacy loopback sentinel on no-Origin internal requests', async () => {
+ // Reproduces the review finding: the sentinel used to satisfy the
+ // internal-request exception below, letting header-forging clients
+ // through the no-Origin rejection.
+ vi.stubEnv('DOPPLER_ENVIRONMENT', 'prd')
+ const handler = await loadHandler()
+
+ try {
+ handler(makeEvent('/api/internal/vaults', { 'cf-connecting-ip': '127.0.0.1' }))
+ throw new Error('Expected internal API call to be rejected')
+ }
+ catch (err) {
+ expect(err).toMatchObject({
+ statusCode: 403,
+ statusMessage: 'Forbidden',
+ })
+ }
+ })
+
+ it('derives x-country-code from the edge and strips the client-supplied value', async () => {
+ vi.stubEnv('DOPPLER_ENVIRONMENT', 'prd')
+ vi.stubEnv('EDGE_PROVIDER', 'cloudflare')
+ const handler = await loadHandler()
+ const event = makeEvent('/', { 'cf-ipcountry': 'DE', 'x-country-code': 'US' })
+
+ expect(handler(event)).toBeUndefined()
+ expect(event.headers['x-country-code']).toBe('DE')
+ expect(event.node.req.headers['x-country-code']).toBeUndefined()
+ })
+
+ it('omits x-country-code when a geo-capable edge leaves the country undetermined in prod', async () => {
+ vi.stubEnv('DOPPLER_ENVIRONMENT', 'prd')
+ vi.stubEnv('EDGE_PROVIDER', 'cloudflare')
+ vi.stubEnv('DEV_GEO_COUNTRY', '')
+ const handler = await loadHandler()
+ const event = makeEvent('/')
+
+ expect(handler(event)).toBeUndefined()
+ expect(event.headers['x-country-code']).toBeUndefined()
+ })
+
+ it('sends the "--" placeholder under the none preset so clients do not fail closed', async () => {
+ vi.stubEnv('DOPPLER_ENVIRONMENT', 'prd')
+ vi.stubEnv('DEV_GEO_COUNTRY', '')
+ const handler = await loadHandler()
+ const event = makeEvent('/')
+
+ expect(handler(event)).toBeUndefined()
+ expect(event.headers['x-country-code']).toBe('--')
+ })
})
diff --git a/tests/server/edge.test.ts b/tests/server/edge.test.ts
new file mode 100644
index 000000000..0c0410d01
--- /dev/null
+++ b/tests/server/edge.test.ts
@@ -0,0 +1,340 @@
+/**
+ * Tests for the edge-provider abstraction: preset header mapping
+ * (utils/edge-presets.ts), the per-request EdgeContext (server/utils/edge.ts),
+ * and the boot-time configuration guard.
+ *
+ * The presets are the ONLY place vendor headers are known — if one of these
+ * mappings regresses, geo-blocking, rate-limit identity, or the screening
+ * audit silently degrade in production.
+ */
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import type { H3Event } from 'h3'
+import {
+ edgeProvidesGeo,
+ edgeProvidesVpnEvidence,
+ edgeRequiresOriginSecret,
+ extractEdgeInputs,
+ normalizeCountry,
+ parseEdgeProvider,
+} from '~/utils/edge-presets'
+import { assertEdgeConfig, getEdgeContext } from '~/server/utils/edge'
+import { getInternalFetchHeaders } from '~/server/utils/internal-headers'
+
+const ENV_KNOBS = ['EDGE_PROVIDER', 'EDGE_ORIGIN_SECRET', 'DEV_GEO_COUNTRY', 'DOPPLER_ENVIRONMENT'] as const
+
+const envSnapshot: Record = {}
+
+beforeEach(() => {
+ for (const key of ENV_KNOBS) {
+ envSnapshot[key] = process.env[key]
+ Reflect.deleteProperty(process.env, key)
+ }
+})
+
+afterEach(() => {
+ for (const key of ENV_KNOBS) {
+ if (envSnapshot[key] === undefined) Reflect.deleteProperty(process.env, key)
+ else process.env[key] = envSnapshot[key]
+ }
+})
+
+const eventWith = (
+ headers: Record,
+ remoteAddress?: string,
+): H3Event =>
+ ({ node: { req: { headers, socket: { remoteAddress } } } }) as unknown as H3Event
+
+describe('parseEdgeProvider', () => {
+ it('defaults to none when unset or blank', () => {
+ expect(parseEdgeProvider(undefined)).toBe('none')
+ expect(parseEdgeProvider('')).toBe('none')
+ expect(parseEdgeProvider(' ')).toBe('none')
+ })
+
+ it('accepts every preset name, case- and whitespace-insensitively', () => {
+ expect(parseEdgeProvider('cloudflare')).toBe('cloudflare')
+ expect(parseEdgeProvider(' Cloudflare ')).toBe('cloudflare')
+ expect(parseEdgeProvider('GOOGLE')).toBe('google')
+ expect(parseEdgeProvider('cloudfront')).toBe('cloudfront')
+ expect(parseEdgeProvider('none')).toBe('none')
+ })
+
+ it('throws on unknown values instead of degrading to none', () => {
+ expect(() => parseEdgeProvider('cloudflre')).toThrow(/Unknown EDGE_PROVIDER/)
+ expect(() => parseEdgeProvider('akamai')).toThrow(/Unknown EDGE_PROVIDER/)
+ })
+})
+
+describe('normalizeCountry', () => {
+ it('uppercases valid alpha-2 codes', () => {
+ expect(normalizeCountry('de')).toBe('DE')
+ expect(normalizeCountry('US')).toBe('US')
+ })
+
+ it('rejects unknown, special, and malformed values', () => {
+ for (const value of ['XX', 'T1', 'USA', '1A', '', undefined, null]) {
+ expect(normalizeCountry(value)).toBeNull()
+ }
+ })
+})
+
+describe('preset capabilities', () => {
+ it('every preset except none provides geo', () => {
+ expect(edgeProvidesGeo('cloudflare')).toBe(true)
+ expect(edgeProvidesGeo('google')).toBe(true)
+ expect(edgeProvidesGeo('cloudfront')).toBe(true)
+ expect(edgeProvidesGeo('none')).toBe(false)
+ })
+
+ it('only cloudflare provides VPN evidence', () => {
+ expect(edgeProvidesVpnEvidence('cloudflare')).toBe(true)
+ expect(edgeProvidesVpnEvidence('google')).toBe(false)
+ expect(edgeProvidesVpnEvidence('cloudfront')).toBe(false)
+ expect(edgeProvidesVpnEvidence('none')).toBe(false)
+ })
+
+ it('only google and cloudfront mandate the origin-auth secret', () => {
+ expect(edgeRequiresOriginSecret('cloudflare')).toBe(false)
+ expect(edgeRequiresOriginSecret('none')).toBe(false)
+ expect(edgeRequiresOriginSecret('google')).toBe(true)
+ expect(edgeRequiresOriginSecret('cloudfront')).toBe(true)
+ })
+})
+
+describe('extractEdgeInputs — cloudflare', () => {
+ it('maps trusted IP, country, and VPN evidence', () => {
+ expect(extractEdgeInputs('cloudflare', {
+ 'cf-connecting-ip': '203.0.113.7',
+ 'cf-ipcountry': 'de',
+ 'x-is-vpn': 'true',
+ }, '10.0.0.1')).toEqual({ clientIp: '203.0.113.7', country: 'DE', vpnIsUsed: true })
+ })
+
+ it('treats absent, blank, or duplicated identity headers as no identity', () => {
+ expect(extractEdgeInputs('cloudflare', {}, '10.0.0.1').clientIp).toBeNull()
+ expect(extractEdgeInputs('cloudflare', { 'cf-connecting-ip': ' ' }, undefined).clientIp).toBeNull()
+ expect(extractEdgeInputs('cloudflare', { 'cf-connecting-ip': ['1.1.1.1', '2.2.2.2'] }, undefined).clientIp).toBeNull()
+ })
+
+ it('never falls back to x-forwarded-for or the socket', () => {
+ const inputs = extractEdgeInputs('cloudflare', { 'x-forwarded-for': '198.51.100.9' }, '10.0.0.1')
+ expect(inputs.clientIp).toBeNull()
+ })
+
+ it('treats XX and Tor exit codes as undetermined country', () => {
+ expect(extractEdgeInputs('cloudflare', { 'cf-ipcountry': 'XX' }, undefined).country).toBeNull()
+ expect(extractEdgeInputs('cloudflare', { 'cf-ipcountry': 'T1' }, undefined).country).toBeNull()
+ })
+
+ it.each([
+ [{ 'x-is-vpn': 'true' }, true],
+ [{ 'x-is-proxy-or-vpn': 'true' }, true],
+ [{ 'x-is-vpn': 'false, TRUE' }, true],
+ [{ 'x-is-proxy-or-vpn': ['false', ' true '] }, true],
+ [{ 'x-is-vpn': 'false' }, false],
+ [{}, null],
+ ] as const)('derives VPN evidence %j → %s', (headers, expected) => {
+ expect(extractEdgeInputs('cloudflare', headers as Record, undefined).vpnIsUsed).toBe(expected)
+ })
+})
+
+describe('extractEdgeInputs — google', () => {
+ it('takes the second-to-last x-forwarded-for entry (client appended by the LB)', () => {
+ const inputs = extractEdgeInputs('google', {
+ 'x-forwarded-for': 'spoofed, 203.0.113.7, 35.190.0.1',
+ 'x-client-geo': 'fr',
+ }, '10.0.0.1')
+ expect(inputs).toEqual({ clientIp: '203.0.113.7', country: 'FR', vpnIsUsed: null })
+ })
+
+ it('fails closed with fewer than two forwarded entries', () => {
+ expect(extractEdgeInputs('google', { 'x-forwarded-for': '203.0.113.7' }, '10.0.0.1').clientIp).toBeNull()
+ expect(extractEdgeInputs('google', {}, '10.0.0.1').clientIp).toBeNull()
+ })
+})
+
+describe('extractEdgeInputs — cloudfront', () => {
+ it('strips the port from the viewer address, IPv6 included', () => {
+ expect(extractEdgeInputs('cloudfront', {
+ 'cloudfront-viewer-address': '203.0.113.7:52443',
+ 'cloudfront-viewer-country': 'gb',
+ }, undefined)).toEqual({ clientIp: '203.0.113.7', country: 'GB', vpnIsUsed: null })
+ expect(extractEdgeInputs('cloudfront', {
+ 'cloudfront-viewer-address': '2001:db8::1:41768',
+ }, undefined).clientIp).toBe('2001:db8::1')
+ })
+
+ it('keeps a bare address intact when no port is present', () => {
+ // A misconfigured distribution or a different upstream stamping the
+ // header must not silently drop a numeric final hextet.
+ for (const bare of ['2001:db8::1', '2001:db8:85a3:0:0:8a2e:370:7334', '203.0.113.7']) {
+ expect(extractEdgeInputs('cloudfront', {
+ 'cloudfront-viewer-address': bare,
+ }, undefined).clientIp).toBe(bare)
+ }
+ // Real CloudFront values still lose their port, whatever its length.
+ expect(extractEdgeInputs('cloudfront', {
+ 'cloudfront-viewer-address': '2001:db8:85a3:0:0:8a2e:370:7334:443',
+ }, undefined).clientIp).toBe('2001:db8:85a3:0:0:8a2e:370:7334')
+ expect(extractEdgeInputs('cloudfront', {
+ 'cloudfront-viewer-address': '::1:8080',
+ }, undefined).clientIp).toBe('::1')
+ })
+
+ it('fails closed when the viewer address is absent', () => {
+ expect(extractEdgeInputs('cloudfront', {}, '10.0.0.1').clientIp).toBeNull()
+ })
+})
+
+describe('extractEdgeInputs — none', () => {
+ it('uses the rightmost x-forwarded-for entry, then the socket', () => {
+ expect(extractEdgeInputs('none', {
+ 'x-forwarded-for': 'spoofed, 203.0.113.7',
+ }, '10.0.0.1').clientIp).toBe('203.0.113.7')
+ expect(extractEdgeInputs('none', {}, '10.0.0.1').clientIp).toBe('10.0.0.1')
+ expect(extractEdgeInputs('none', {}, undefined).clientIp).toBeNull()
+ })
+
+ it('never reports a country or VPN evidence', () => {
+ const inputs = extractEdgeInputs('none', {
+ 'cf-ipcountry': 'DE',
+ 'x-is-vpn': 'true',
+ }, undefined)
+ expect(inputs.country).toBeNull()
+ expect(inputs.vpnIsUsed).toBeNull()
+ })
+})
+
+describe('getEdgeContext', () => {
+ it('is authenticated by default when no origin-auth secret is configured', () => {
+ process.env.EDGE_PROVIDER = 'cloudflare'
+ const context = getEdgeContext(eventWith({ 'cf-connecting-ip': '203.0.113.7', 'cf-ipcountry': 'DE' }))
+ expect(context).toMatchObject({
+ clientIp: '203.0.113.7',
+ country: 'DE',
+ authenticated: true,
+ isInternal: false,
+ providesGeo: true,
+ })
+ })
+
+ it('honours the origin-auth secret when the header matches', () => {
+ process.env.EDGE_PROVIDER = 'cloudflare'
+ process.env.EDGE_ORIGIN_SECRET = 'shared-secret'
+ const context = getEdgeContext(eventWith({
+ 'cf-connecting-ip': '203.0.113.7',
+ 'cf-ipcountry': 'DE',
+ 'x-edge-origin-auth': 'shared-secret',
+ }))
+ expect(context).toMatchObject({ clientIp: '203.0.113.7', country: 'DE', authenticated: true })
+ })
+
+ it('nulls every trusted input when origin auth fails', () => {
+ process.env.EDGE_PROVIDER = 'cloudflare'
+ process.env.EDGE_ORIGIN_SECRET = 'shared-secret'
+ for (const headers of [
+ { 'cf-connecting-ip': '203.0.113.7', 'cf-ipcountry': 'DE', 'x-is-vpn': 'true' },
+ { 'cf-connecting-ip': '203.0.113.7', 'cf-ipcountry': 'DE', 'x-edge-origin-auth': 'wrong' },
+ // Same byte length as the secret — exercises the timing-safe compare.
+ { 'cf-ipcountry': 'DE', 'x-edge-origin-auth': 'shared-secreX' },
+ ]) {
+ expect(getEdgeContext(eventWith(headers))).toMatchObject({
+ clientIp: null,
+ country: null,
+ vpnIsUsed: null,
+ authenticated: false,
+ })
+ }
+ })
+
+ it('skips the DEV_GEO_COUNTRY fallback when origin auth fails', () => {
+ process.env.EDGE_PROVIDER = 'cloudflare'
+ process.env.EDGE_ORIGIN_SECRET = 'shared-secret'
+ process.env.DEV_GEO_COUNTRY = 'GB'
+ expect(getEdgeContext(eventWith({})).country).toBeNull()
+ })
+
+ it('applies the DEV_GEO_COUNTRY fallback when the edge provides no country', () => {
+ process.env.DEV_GEO_COUNTRY = 'gb'
+ expect(getEdgeContext(eventWith({})).country).toBe('GB')
+
+ process.env.EDGE_PROVIDER = 'cloudflare'
+ expect(getEdgeContext(eventWith({})).country).toBe('GB')
+ // The real edge header still wins over the fallback.
+ expect(getEdgeContext(eventWith({ 'cf-ipcountry': 'US' })).country).toBe('US')
+ })
+
+ it('flags internal requests', () => {
+ expect(getEdgeContext(eventWith({ ...getInternalFetchHeaders() })).isInternal).toBe(true)
+ // A forged legacy sentinel is not internal.
+ expect(getEdgeContext(eventWith({ 'cf-connecting-ip': '127.0.0.1' })).isInternal).toBe(false)
+
+ process.env.EDGE_ORIGIN_SECRET = 'shared-secret'
+ expect(getEdgeContext(eventWith({
+ 'x-edge-origin-auth': 'shared-secret',
+ 'x-edge-internal': 'shared-secret',
+ })).isInternal).toBe(true)
+ })
+
+ it('defaults to the none preset: geo off, best-effort identity', () => {
+ const context = getEdgeContext(eventWith({ 'x-forwarded-for': 'spoofed, 203.0.113.7' }))
+ expect(context).toMatchObject({
+ clientIp: '203.0.113.7',
+ country: null,
+ vpnIsUsed: null,
+ providesGeo: false,
+ })
+ })
+})
+
+describe('assertEdgeConfig', () => {
+ it('allows any environment with a valid preset, and non-prd without one', () => {
+ expect(() => assertEdgeConfig()).not.toThrow()
+
+ process.env.DOPPLER_ENVIRONMENT = 'dev'
+ expect(() => assertEdgeConfig()).not.toThrow()
+
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'cloudflare'
+ expect(() => assertEdgeConfig()).not.toThrow()
+
+ // Explicitly opting out of an edge in production is a deliberate choice.
+ process.env.EDGE_PROVIDER = 'none'
+ expect(() => assertEdgeConfig()).not.toThrow()
+ })
+
+ it('refuses to boot production without an explicit preset', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ expect(() => assertEdgeConfig()).toThrow(/EDGE_PROVIDER must be set in production/)
+ })
+
+ it('refuses to boot production with DEV_GEO_COUNTRY set', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'cloudflare'
+ process.env.DEV_GEO_COUNTRY = 'GB'
+ expect(() => assertEdgeConfig()).toThrow(/DEV_GEO_COUNTRY must not be set in production/)
+
+ // Blank is the same as unset; non-production keeps the fallback.
+ process.env.DEV_GEO_COUNTRY = ' '
+ expect(() => assertEdgeConfig()).not.toThrow()
+ process.env.DOPPLER_ENVIRONMENT = 'stg'
+ process.env.DEV_GEO_COUNTRY = 'GB'
+ expect(() => assertEdgeConfig()).not.toThrow()
+ })
+
+ it('refuses to boot on a typoed preset in any environment', () => {
+ process.env.EDGE_PROVIDER = 'cloudflre'
+ expect(() => assertEdgeConfig()).toThrow(/Unknown EDGE_PROVIDER/)
+ })
+
+ it('refuses to boot presets that cannot honour the sentinel without the origin-auth secret', () => {
+ for (const provider of ['google', 'cloudfront']) {
+ process.env.EDGE_PROVIDER = provider
+ expect(() => assertEdgeConfig()).toThrow(/requires EDGE_ORIGIN_SECRET/)
+
+ process.env.EDGE_ORIGIN_SECRET = 'shared-secret'
+ expect(() => assertEdgeConfig()).not.toThrow()
+ Reflect.deleteProperty(process.env, 'EDGE_ORIGIN_SECRET')
+ }
+ })
+})
diff --git a/tests/server/geo-gate.test.ts b/tests/server/geo-gate.test.ts
index 3c5b420bb..dcc6aaf52 100644
--- a/tests/server/geo-gate.test.ts
+++ b/tests/server/geo-gate.test.ts
@@ -1,14 +1,20 @@
/**
- * Regression tests for the geo-gate middleware log hygiene.
+ * Regression tests for the geo-gate middleware: log hygiene plus the
+ * preset-dependent gating semantics.
*
- * Some `/api/*` routes embed a wallet address in the path
+ * Log hygiene: some `/api/*` routes embed a wallet address in the path
* (e.g. /api/internal/proxy/merkl/users/0x.../rewards). When geo-gate blocks a
* request or flags VPN/proxy usage it logs the request path. The path
* MUST be run through safePathTemplate so a raw wallet address (PII)
* never reaches the log sink. These tests lock that in.
+ *
+ * Gating semantics: geo-capable presets fail closed on an undetermined
+ * country outside dev; the `none` preset runs with geo-blocking off; failed
+ * origin auth voids the trusted country.
*/
import type { H3Event } from 'h3'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { getInternalFetchHeaders } from '~/server/utils/internal-headers'
vi.mock('h3', () => ({
createError: (error: unknown) => error,
@@ -41,7 +47,7 @@ const runHandler = (event: TestEvent) => (handler as (e: TestEvent) => unknown)(
// caller's shell — a stray DEV_GEO_COUNTRY, for example, would otherwise take
// the dev-country fallback and defeat the fail-closed (undetermined-country)
// path under test.
-const ENV_KNOBS = ['DOPPLER_ENVIRONMENT', 'DEV_GEO_COUNTRY'] as const
+const ENV_KNOBS = ['DOPPLER_ENVIRONMENT', 'DEV_GEO_COUNTRY', 'EDGE_PROVIDER', 'EDGE_ORIGIN_SECRET'] as const
const envSnapshot: Record = {}
@@ -63,6 +69,7 @@ afterEach(() => {
describe('geo-gate log hygiene', () => {
it('templates the wallet address when blocking undetermined country', () => {
process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'cloudflare'
expect(() =>
runHandler(makeEvent(`https://app.example/api/internal/proxy/merkl/users/${ADDRESS}/rewards`)),
@@ -77,6 +84,7 @@ describe('geo-gate log hygiene', () => {
it('templates the wallet address when flagging VPN/proxy usage', () => {
process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'cloudflare'
runHandler(makeEvent(`https://app.example/api/internal/proxy/merkl/users/${ADDRESS}/rewards`, {
'cf-ipcountry': 'US',
@@ -91,6 +99,7 @@ describe('geo-gate log hygiene', () => {
it('templates the wallet address when blocking a sanctioned country', () => {
process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'cloudflare'
expect(() =>
runHandler(makeEvent(`https://app.example/api/internal/proxy/merkl/users/${ADDRESS}/rewards`, {
@@ -106,13 +115,84 @@ describe('geo-gate log hygiene', () => {
it('does not gate or log internal server-to-server requests', () => {
process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'cloudflare'
expect(() =>
- runHandler(makeEvent(`https://app.example/api/internal/proxy/merkl/users/${ADDRESS}/rewards`, {
+ runHandler(makeEvent(
+ `https://app.example/api/internal/proxy/merkl/users/${ADDRESS}/rewards`,
+ { ...getInternalFetchHeaders() },
+ )),
+ ).not.toThrow()
+
+ expect(warn).not.toHaveBeenCalled()
+ })
+
+ it('gates requests bearing a forged legacy loopback sentinel like any external request', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'cloudflare'
+
+ // The sentinel used to grant the internal bypass; it must not anymore.
+ expect(() =>
+ runHandler(makeEvent('https://app.example/api/internal/vaults', {
'cf-connecting-ip': '127.0.0.1',
})),
+ ).toThrow()
+ })
+})
+
+describe('geo-gate preset semantics', () => {
+ it('passes a determined, non-sanctioned country through', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'cloudflare'
+
+ expect(() =>
+ runHandler(makeEvent('https://app.example/api/internal/vaults', { 'cf-ipcountry': 'DE' })),
).not.toThrow()
+ expect(warn).not.toHaveBeenCalled()
+ })
+ it('runs with geo-blocking off under the none preset (forks, previews)', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'none'
+
+ expect(() => runHandler(makeEvent('https://app.example/api/internal/vaults'))).not.toThrow()
expect(warn).not.toHaveBeenCalled()
})
+
+ it('still blocks sanctioned countries under the none preset when DEV_GEO_COUNTRY simulates one', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'none'
+ process.env.DEV_GEO_COUNTRY = 'KP'
+
+ expect(() => runHandler(makeEvent('https://app.example/api/internal/vaults'))).toThrow()
+ })
+
+ it('fails closed when origin auth is configured and the request lacks the secret', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'cloudflare'
+ process.env.EDGE_ORIGIN_SECRET = 'shared-secret'
+
+ // Country header present but the request bypassed the edge — the
+ // trusted inputs are voided and the undetermined-country 451 applies.
+ expect(() =>
+ runHandler(makeEvent('https://app.example/api/internal/vaults', { 'cf-ipcountry': 'DE' })),
+ ).toThrow()
+
+ // The same request stamped by the edge passes.
+ warn.mockClear()
+ expect(() =>
+ runHandler(makeEvent('https://app.example/api/internal/vaults', {
+ 'cf-ipcountry': 'DE',
+ 'x-edge-origin-auth': 'shared-secret',
+ })),
+ ).not.toThrow()
+ expect(warn).not.toHaveBeenCalled()
+ })
+
+ it('allows an undetermined country through in dev', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'dev'
+ process.env.EDGE_PROVIDER = 'cloudflare'
+
+ expect(() => runHandler(makeEvent('https://app.example/api/internal/vaults'))).not.toThrow()
+ })
})
diff --git a/tests/server/healthz.test.ts b/tests/server/healthz.test.ts
new file mode 100644
index 000000000..99d52b049
--- /dev/null
+++ b/tests/server/healthz.test.ts
@@ -0,0 +1,48 @@
+/**
+ * Liveness-probe contract tests.
+ *
+ * The container healthcheck must stay independent of edge configuration:
+ * /healthz lives outside /api/ (exempt from the geo-gate, rate limiting,
+ * and internal-request authentication) and the Dockerfile probe must not
+ * send edge or internal headers — enabling EDGE_ORIGIN_SECRET once marked
+ * a healthy container unhealthy because the probe authenticated with the
+ * legacy loopback sentinel.
+ */
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import type { H3Event } from 'h3'
+
+const noStore = vi.fn()
+vi.mock('~/server/utils/cache-headers', () => ({
+ forceNoStoreCacheHeaders: (...args: unknown[]) => noStore(...args),
+}))
+
+const handler = (await import('~/server/routes/healthz.get')).default
+
+describe('GET /healthz', () => {
+ it('reports liveness with no dependencies and never caches', () => {
+ const event = {} as H3Event
+ expect((handler as (e: H3Event) => unknown)(event)).toEqual({ status: 'ok' })
+ expect(noStore).toHaveBeenCalledWith(event)
+ })
+})
+
+describe('Dockerfile healthcheck', () => {
+ const dockerfile = readFileSync(join(process.cwd(), 'Dockerfile'), 'utf8')
+ const healthcheckIndex = dockerfile.indexOf('HEALTHCHECK')
+ // The CMD is on the continuation line right after the HEALTHCHECK options.
+ const probe = dockerfile.slice(healthcheckIndex).split('\n').slice(0, 2).join('\n')
+
+ it('probes /healthz, not a gated /api/ route', () => {
+ expect(healthcheckIndex).toBeGreaterThanOrEqual(0)
+ expect(probe).toContain('/healthz')
+ expect(probe).not.toContain('/api/')
+ })
+
+ it('sends no edge or internal-auth headers (the probe must not depend on or leak them)', () => {
+ expect(probe).not.toContain('cf-connecting-ip')
+ expect(probe).not.toContain('x-edge')
+ expect(probe).not.toContain('EDGE_ORIGIN_SECRET')
+ })
+})
diff --git a/tests/server/internal-request.test.ts b/tests/server/internal-request.test.ts
index 1995408d1..bf0e8b4ab 100644
--- a/tests/server/internal-request.test.ts
+++ b/tests/server/internal-request.test.ts
@@ -1,53 +1,132 @@
/**
- * Regression tests for the internal-request sentinel.
+ * Regression tests for internal-request detection.
*
* 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.
+ * traverse the edge, so they carry no trusted country or client identity.
+ * Both the geo-gate and rate-limit middlewares fail-closed when those
+ * inputs are missing. `getInternalFetchHeaders()` stamps a marker header
+ * that downstream middleware recognises via `isInternalRequest` to bypass
+ * those checks — the value is EDGE_ORIGIN_SECRET when configured, or a
+ * random per-process fallback otherwise, so internal status is not
+ * forgeable under any preset. (An earlier loopback `cf-connecting-ip`
+ * sentinel was forgeable wherever the edge did not overwrite it — notably
+ * under the `none` preset — and must never be honoured again.)
*
- * If this contract breaks — the sentinel stops being set, the helper
- * stops recognising it, or a middleware forgets to consult the helper —
- * every internal API→API call 502s/451s in prod. One such regression
- * already shipped once (internal `/api/internal/vaults` → `/api/internal/euler-chains`
+ * If this contract breaks — the headers stop being set, the helper stops
+ * recognising them, or a middleware forgets to consult the helper — every
+ * internal API→API call 502s/451s in prod. One such regression already
+ * shipped once (internal `/api/internal/vaults` → `/api/internal/euler-chains`
* 451'd by geo-gate). Lock it down.
*/
-import { describe, it, expect } from 'vitest'
+import { readdirSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { H3Event } from 'h3'
-import { INTERNAL_FETCH_HEADERS, isInternalRequest } from '~/server/utils/internal-headers'
+import { getInternalFetchHeaders, isInternalRequest } from '~/server/utils/internal-headers'
-const eventWithHeaders = (headers: Record): H3Event =>
+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')
- })
+const ENV_KNOBS = ['EDGE_ORIGIN_SECRET', 'EDGE_PROVIDER'] as const
+
+const envSnapshot: Record = {}
+
+beforeEach(() => {
+ for (const key of ENV_KNOBS) {
+ envSnapshot[key] = process.env[key]
+ Reflect.deleteProperty(process.env, key)
+ }
})
-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)
+afterEach(() => {
+ for (const key of ENV_KNOBS) {
+ if (envSnapshot[key] === undefined) Reflect.deleteProperty(process.env, key)
+ else process.env[key] = envSnapshot[key]
+ }
+})
+
+describe('without EDGE_ORIGIN_SECRET (per-process marker mode)', () => {
+ it('stamps a random per-process marker and nothing else', () => {
+ const headers = getInternalFetchHeaders()
+ expect(Object.keys(headers)).toEqual(['x-edge-internal'])
+ // 32 random bytes, base64url — long enough to be unguessable.
+ expect(headers['x-edge-internal']).toMatch(/^[A-Za-z0-9_-]{43}$/)
})
- it('returns true for requests decorated with INTERNAL_FETCH_HEADERS', () => {
- // End-to-end contract: whatever INTERNAL_FETCH_HEADERS sets must be
+ it('recognises requests decorated with getInternalFetchHeaders()', () => {
+ // End-to-end contract: whatever getInternalFetchHeaders sets must be
// what isInternalRequest recognises.
- const event = eventWithHeaders({ ...INTERNAL_FETCH_HEADERS })
- expect(isInternalRequest(event)).toBe(true)
+ expect(isInternalRequest(eventWithHeaders({ ...getInternalFetchHeaders() }))).toBe(true)
})
- it('returns false when cf-connecting-ip is absent', () => {
- const event = eventWithHeaders({})
- expect(isInternalRequest(event)).toBe(false)
+ it('rejects guessed, empty, or same-length marker values', () => {
+ expect(isInternalRequest(eventWithHeaders({}))).toBe(false)
+ expect(isInternalRequest(eventWithHeaders({ 'x-edge-internal': 'anything' }))).toBe(false)
+ const realLength = getInternalFetchHeaders()['x-edge-internal'].length
+ // Same byte length as the real marker — exercises the timing-safe compare.
+ expect(isInternalRequest(eventWithHeaders({ 'x-edge-internal': 'x'.repeat(realLength) }))).toBe(false)
})
- it('returns false for any other IP', () => {
- 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('never honours the legacy loopback sentinel, under any preset', () => {
+ // Reproduces the review finding: under `none` (and any preset whose
+ // edge forwards client headers untouched) a forged sentinel used to
+ // grant internal status, bypassing rate limiting and the internal
+ // exceptions in the CORS and geo middleware.
+ const forged = eventWithHeaders({ 'cf-connecting-ip': '127.0.0.1' })
+ for (const provider of ['none', 'cloudflare', 'google', 'cloudfront', undefined]) {
+ if (provider === undefined) Reflect.deleteProperty(process.env, 'EDGE_PROVIDER')
+ else process.env.EDGE_PROVIDER = provider
+ expect(isInternalRequest(forged), `provider=${provider}`).toBe(false)
+ }
+ })
+})
+
+describe('with EDGE_ORIGIN_SECRET (shared-secret marker mode)', () => {
+ beforeEach(() => {
+ process.env.EDGE_ORIGIN_SECRET = 'shared-secret'
+ })
+
+ it('stamps the origin-auth header and the internal marker', () => {
+ expect(getInternalFetchHeaders()).toEqual({
+ 'x-edge-origin-auth': 'shared-secret',
+ 'x-edge-internal': 'shared-secret',
+ })
+ })
+
+ it('recognises requests decorated with getInternalFetchHeaders()', () => {
+ expect(isInternalRequest(eventWithHeaders({ ...getInternalFetchHeaders() }))).toBe(true)
+ })
+
+ it('rejects a wrong or same-length marker', () => {
+ expect(isInternalRequest(eventWithHeaders({ 'x-edge-internal': 'wrong' }))).toBe(false)
+ // Same byte length as the secret — exercises the timing-safe compare.
+ expect(isInternalRequest(eventWithHeaders({ 'x-edge-internal': 'shared-secreX' }))).toBe(false)
+ })
+
+ it('never honours the legacy loopback sentinel', () => {
+ expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '127.0.0.1' }))).toBe(false)
+ })
+
+ it('rejects an origin-auth header without the marker (external edge traffic)', () => {
+ // The edge stamps x-edge-origin-auth on ALL forwarded requests; that
+ // alone must never grant internal status.
+ expect(isInternalRequest(eventWithHeaders({ 'x-edge-origin-auth': 'shared-secret' }))).toBe(false)
+ })
+})
+
+describe('retired loopback sentinel hygiene', () => {
+ it('no repo script sends the retired sentinel or the internal marker', () => {
+ // External processes (recorder, parity tooling, healthchecks) cannot be
+ // internal by design — they must authenticate as normal first-party
+ // callers (allowed Origin / first-party cookie). A script quietly
+ // reintroducing these headers would 403 against any non-dev server.
+ const scriptsDir = join(process.cwd(), 'scripts')
+ const offenders = readdirSync(scriptsDir)
+ .filter(file => /\.(mjs|js|ts)$/.test(file))
+ .filter((file) => {
+ const source = readFileSync(join(scriptsDir, file), 'utf8')
+ return source.includes('cf-connecting-ip') || source.includes('x-edge-internal')
+ })
+ expect(offenders).toEqual([])
})
})
diff --git a/tests/server/labels-view.test.ts b/tests/server/labels-view.test.ts
index 1e4474742..749eb0065 100644
--- a/tests/server/labels-view.test.ts
+++ b/tests/server/labels-view.test.ts
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { buildProductDescriptors, buildTokenLogoMap, fetchTokenList } from '~/server/utils/labels-view'
-import { INTERNAL_FETCH_HEADERS } from '~/server/utils/internal-headers'
+import { getInternalFetchHeaders } from '~/server/utils/internal-headers'
afterEach(() => {
vi.unstubAllGlobals()
@@ -20,7 +20,7 @@ describe('fetchTokenList', () => {
])
expect(fetch).toHaveBeenCalledWith('/api/internal/token-list', {
query: { chainId: 1 },
- headers: INTERNAL_FETCH_HEADERS,
+ headers: getInternalFetchHeaders(),
})
})
})
diff --git a/tests/server/rate-limit.test.ts b/tests/server/rate-limit.test.ts
new file mode 100644
index 000000000..28ded3445
--- /dev/null
+++ b/tests/server/rate-limit.test.ts
@@ -0,0 +1,174 @@
+/**
+ * Tests for the per-IP rate limiter's identity handling: trusted identity
+ * from the edge context, fail-closed in production without one, internal
+ * bypass, and the dev/stg best-effort fallback.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { H3Event } from 'h3'
+
+vi.mock('h3', () => ({
+ createError: (error: unknown) => error,
+}))
+
+const warn = vi.fn()
+vi.mock('~/server/utils/logger', () => ({
+ logger: { warn: (...args: unknown[]) => warn(...args) },
+}))
+
+const { createRateLimiter } = await import('~/server/utils/rate-limit')
+const { getInternalFetchHeaders } = await import('~/server/utils/internal-headers')
+
+const eventWith = (
+ headers: Record,
+ remoteAddress?: string,
+): H3Event =>
+ ({ node: { req: { headers, socket: { remoteAddress } } } }) as unknown as H3Event
+
+const ENV_KNOBS = ['DOPPLER_ENVIRONMENT', 'EDGE_PROVIDER', 'EDGE_ORIGIN_SECRET', 'DISABLE_RATE_LIMIT', 'DEV_GEO_COUNTRY'] as const
+
+const envSnapshot: Record = {}
+
+beforeEach(() => {
+ warn.mockClear()
+ for (const key of ENV_KNOBS) {
+ envSnapshot[key] = process.env[key]
+ Reflect.deleteProperty(process.env, key)
+ }
+})
+
+afterEach(() => {
+ for (const key of ENV_KNOBS) {
+ if (envSnapshot[key] === undefined) Reflect.deleteProperty(process.env, key)
+ else process.env[key] = envSnapshot[key]
+ }
+})
+
+const makeLimiter = (max = 2) => createRateLimiter({ max, windowMs: 60_000, label: 'test' })
+
+describe('trusted identity (cloudflare preset)', () => {
+ beforeEach(() => {
+ process.env.EDGE_PROVIDER = 'cloudflare'
+ })
+
+ it('budgets per trusted client IP and throws 429 past the budget', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ const limiter = makeLimiter(2)
+ const clientA = () => eventWith({ 'cf-connecting-ip': '203.0.113.1' })
+ const clientB = () => eventWith({ 'cf-connecting-ip': '203.0.113.2' })
+
+ limiter.consume(clientA())
+ limiter.consume(clientA())
+ expect(() => limiter.consume(clientA())).toThrow()
+
+ // A different client keeps its own budget.
+ expect(() => limiter.consume(clientB())).not.toThrow()
+ })
+
+ it('fails closed (403) in production without a trusted identity', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ const limiter = makeLimiter()
+ try {
+ limiter.consume(eventWith({ 'x-forwarded-for': '203.0.113.1' }, '10.0.0.1'))
+ throw new Error('Expected the request to be rejected')
+ }
+ catch (err) {
+ expect(err).toMatchObject({ statusCode: 403 })
+ }
+ })
+
+ it('fails closed (403) in production when origin auth fails', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_ORIGIN_SECRET = 'shared-secret'
+ const limiter = makeLimiter()
+ try {
+ limiter.consume(eventWith({ 'cf-connecting-ip': '203.0.113.1' }))
+ throw new Error('Expected the request to be rejected')
+ }
+ catch (err) {
+ expect(err).toMatchObject({ statusCode: 403 })
+ }
+
+ // With the stamped secret the same request passes.
+ expect(() => limiter.consume(eventWith({
+ 'cf-connecting-ip': '203.0.113.1',
+ 'x-edge-origin-auth': 'shared-secret',
+ }))).not.toThrow()
+ })
+
+ it('never rate-limits internal server-to-server requests', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ const limiter = makeLimiter(1)
+ const internal = () => eventWith({ ...getInternalFetchHeaders() })
+ expect(() => {
+ for (let i = 0; i < 10; i++) limiter.consume(internal())
+ }).not.toThrow()
+ })
+
+ it('falls back to X-Forwarded-For / socket outside production', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'dev'
+ const limiter = makeLimiter(1)
+ limiter.consume(eventWith({ 'x-forwarded-for': '203.0.113.1, 10.0.0.1' }))
+ expect(() => limiter.consume(eventWith({ 'x-forwarded-for': '203.0.113.1, 10.0.0.1' }))).toThrow()
+ // A different leftmost entry is a different bucket.
+ expect(() => limiter.consume(eventWith({ 'x-forwarded-for': '203.0.113.9' }))).not.toThrow()
+ })
+})
+
+describe('none preset', () => {
+ it('keys budgets on the rightmost x-forwarded-for entry', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'none'
+ const limiter = makeLimiter(1)
+
+ // Rotating the client-controlled leftmost entry must not reset the budget.
+ limiter.consume(eventWith({ 'x-forwarded-for': 'spoof-1, 203.0.113.1' }))
+ expect(() => limiter.consume(eventWith({ 'x-forwarded-for': 'spoof-2, 203.0.113.1' }))).toThrow()
+ })
+
+ it('a forged legacy loopback sentinel does not bypass rate limiting', () => {
+ // Reproduces the review finding: under `none` there is no edge to
+ // overwrite cf-connecting-ip, so trusting it as an internal marker let
+ // forged requests skip rate-limit accounting entirely.
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'none'
+ const limiter = makeLimiter(2)
+ const forged = () => eventWith({
+ 'cf-connecting-ip': '127.0.0.1',
+ 'x-forwarded-for': '203.0.113.1',
+ })
+
+ limiter.consume(forged())
+ limiter.consume(forged())
+ try {
+ limiter.consume(forged())
+ throw new Error('Expected the third forged request to be rate limited')
+ }
+ catch (err) {
+ expect(err).toMatchObject({ statusCode: 429 })
+ }
+ })
+})
+
+describe('escape hatches', () => {
+ it('DISABLE_RATE_LIMIT=true bypasses everything', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'prd'
+ process.env.EDGE_PROVIDER = 'cloudflare'
+ process.env.DISABLE_RATE_LIMIT = 'true'
+ const limiter = makeLimiter(1)
+ expect(() => {
+ for (let i = 0; i < 5; i++) limiter.consume(eventWith({}))
+ }).not.toThrow()
+ })
+
+ it('throws 429 when a single cost exceeds the whole budget', () => {
+ process.env.DOPPLER_ENVIRONMENT = 'dev'
+ const limiter = makeLimiter(2)
+ try {
+ limiter.consume(eventWith({}, '10.0.0.1'), 3)
+ throw new Error('Expected the request to be rejected')
+ }
+ catch (err) {
+ expect(err).toMatchObject({ statusCode: 429 })
+ }
+ })
+})
diff --git a/tests/server/screen-address.test.ts b/tests/server/screen-address.test.ts
index 6f8d87ad7..a04392e71 100644
--- a/tests/server/screen-address.test.ts
+++ b/tests/server/screen-address.test.ts
@@ -71,6 +71,7 @@ describe('POST /api/internal/screen-address', () => {
afterEach(() => {
delete process.env.ADDRESS_SCREENING_URI
delete process.env.ADDRESS_SCREENING_API_KEY
+ delete process.env.EDGE_PROVIDER
vi.unstubAllGlobals()
vi.clearAllMocks()
})
@@ -230,6 +231,9 @@ describe('POST /api/internal/screen-address', () => {
it('preserves positive VPN signals from either the client or trusted headers', async () => {
stubScreeningEnv()
+ // VPN evidence is a cloudflare-preset capability; other presets always
+ // report null (see the edge-presets tests).
+ process.env.EDGE_PROVIDER = 'cloudflare'
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => cleanVerdict())
vi.stubGlobal('fetch', fetchMock)
@@ -270,4 +274,16 @@ describe('POST /api/internal/screen-address', () => {
)
expect(bodies.map(body => body.vpnIsUsed)).toEqual([true, true, true, true, false, true, true])
})
+
+ it('reports vpnIsUsed as null under presets without VPN evidence, even when headers are present', async () => {
+ stubScreeningEnv()
+ // Default preset is `none`: the VPN headers are not trusted evidence.
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => cleanVerdict())
+ vi.stubGlobal('fetch', fetchMock)
+
+ await handler(makeEvent({ address: USER }, { 'x-is-vpn': 'true' }))
+
+ const [, init] = fetchMock.mock.calls[0]
+ expect(JSON.parse(String(init?.body)).vpnIsUsed).toBeNull()
+ })
})
diff --git a/tests/services/screening.test.ts b/tests/services/screening.test.ts
index 3cbf9e4fd..c88619a20 100644
--- a/tests/services/screening.test.ts
+++ b/tests/services/screening.test.ts
@@ -10,10 +10,14 @@ describe('screenAddress', () => {
vi.unstubAllGlobals()
})
- it('allows only an explicit false suspicious verdict', async () => {
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ addressIsSuspicious: false }), { status: 200 })))
+ it.each([true, false, null])('forwards VPN audit evidence (%s) and allows an explicit clean verdict', async (vpnIsUsed) => {
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ addressIsSuspicious: false }), { status: 200 }))
+ vi.stubGlobal('fetch', fetchMock)
- await expect(screenAddress(USER, false)).resolves.toBe(false)
+ await expect(screenAddress(USER, vpnIsUsed)).resolves.toBe(false)
+ expect(fetchMock).toHaveBeenCalledWith('/api/internal/screen-address', expect.objectContaining({
+ body: JSON.stringify({ address: USER, vpnIsUsed }),
+ }))
})
it('fails closed for non-ok responses and malformed success bodies', async () => {
diff --git a/tests/services/vpn.test.ts b/tests/services/vpn.test.ts
index 1089168d8..6d78fada6 100644
--- a/tests/services/vpn.test.ts
+++ b/tests/services/vpn.test.ts
@@ -2,6 +2,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { WALLET_SCREENING_TIMEOUT_MS } from '~/entities/tuning-constants'
import { detectVpn, resetVpnCache } from '~/services/vpn'
+// The probe only runs when the deployment's edge provider measures VPN
+// usage, which server/plugins/app-config.ts advertises via __APP_CONFIG__.
+const stubWindow = (vpnDetection: boolean | undefined) => {
+ vi.stubGlobal('window', {
+ location: { origin: 'http://localhost:3000' },
+ __APP_CONFIG__: vpnDetection === undefined ? undefined : { vpnDetection },
+ })
+}
+
describe('detectVpn', () => {
afterEach(() => {
resetVpnCache()
@@ -9,19 +18,50 @@ describe('detectVpn', () => {
vi.unstubAllGlobals()
})
- it('reads the VPN edge header', async () => {
- vi.stubGlobal('window', { location: { origin: 'http://localhost:3000' } })
+ it.each([
+ ['true', true],
+ ['false', false],
+ [' TRUE ', true],
+ [' FALSE ', false],
+ ['', null],
+ ['unknown', null],
+ ] as const)('records header %j as %s', async (header, expected) => {
+ stubWindow(true)
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, {
- headers: { 'x-is-vpn': 'true' },
+ headers: { 'x-is-vpn': header },
status: 200,
})))
- await expect(detectVpn()).resolves.toBe(true)
+ await expect(detectVpn()).resolves.toBe(expected)
+ })
+
+ it('records a missing header as unknown', async () => {
+ stubWindow(true)
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200 })))
+
+ await expect(detectVpn()).resolves.toBeNull()
+ })
+
+ it('records an unsuccessful HTTP response as unknown even with a VPN header', async () => {
+ stubWindow(true)
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(null, {
+ headers: { 'x-is-vpn': 'true' },
+ status: 503,
+ })))
+
+ await expect(detectVpn()).resolves.toBeNull()
})
- it('fails closed when VPN detection stalls', async () => {
+ it('records a network failure as unknown', async () => {
+ stubWindow(true)
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
+
+ await expect(detectVpn()).resolves.toBeNull()
+ })
+
+ it('records a timeout as unknown', async () => {
vi.useFakeTimers()
- vi.stubGlobal('window', { location: { origin: 'http://localhost:3000' } })
+ stubWindow(true)
vi.stubGlobal('fetch', vi.fn((_url: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
@@ -32,6 +72,17 @@ describe('detectVpn', () => {
await vi.advanceTimersByTimeAsync(WALLET_SCREENING_TIMEOUT_MS)
- await expect(promise).resolves.toBe(true)
+ await expect(promise).resolves.toBeNull()
+ })
+
+ it('skips the probe entirely when the edge provides no VPN evidence', async () => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+
+ for (const vpnDetection of [false, undefined] as const) {
+ stubWindow(vpnDetection)
+ await expect(detectVpn()).resolves.toBeNull()
+ }
+ expect(fetchMock).not.toHaveBeenCalled()
})
})
diff --git a/utils/edge-presets.ts b/utils/edge-presets.ts
new file mode 100644
index 000000000..34eeb5f9d
--- /dev/null
+++ b/utils/edge-presets.ts
@@ -0,0 +1,211 @@
+/**
+ * Edge-provider presets: the single place where vendor-specific request
+ * headers are known. Everything downstream (middleware, routes, services)
+ * consumes the normalized `EdgeInputs` shape via `getEdgeContext()` in
+ * `server/utils/edge.ts` and must stay vendor-neutral.
+ *
+ * The preset is selected with the `EDGE_PROVIDER` env var. The default is
+ * `none`: no edge-derived trust, which keeps forks and preview deployments
+ * working with zero configuration. Production deployments must pick a
+ * preset explicitly (enforced at boot by `server/plugins/edge-guard.ts`).
+ *
+ * This module is intentionally pure (no node/h3 imports) so the client
+ * bundle can consult preset capabilities (see `composables/useEnvConfig.ts`).
+ */
+
+export const EDGE_PROVIDERS = ['cloudflare', 'google', 'cloudfront', 'none'] as const
+
+export type EdgeProvider = (typeof EDGE_PROVIDERS)[number]
+
+/**
+ * Origin-auth header: when `EDGE_ORIGIN_SECRET` is configured, the edge
+ * must stamp this header with the secret on every request it forwards to
+ * the origin. Requests without it are treated as having bypassed the edge.
+ */
+export const EDGE_ORIGIN_AUTH_HEADER = 'x-edge-origin-auth'
+
+/**
+ * Internal-fetch marker header: set only by server-internal $fetch calls,
+ * never by the edge. The value is EDGE_ORIGIN_SECRET when configured, or a
+ * random per-process fallback otherwise (see
+ * `server/utils/internal-headers.ts`) — external clients cannot forge
+ * either. The edge should strip this header from inbound traffic as
+ * defense-in-depth.
+ */
+export const INTERNAL_MARKER_HEADER = 'x-edge-internal'
+
+type RawHeaders = Record
+
+/** Normalized trust inputs every preset reduces to. `null` = unmeasured. */
+export interface EdgeInputs {
+ clientIp: string | null
+ country: string | null
+ vpnIsUsed: boolean | null
+}
+
+export function parseEdgeProvider(raw: string | undefined): EdgeProvider {
+ const value = raw?.trim().toLowerCase()
+ if (!value) return 'none'
+ if ((EDGE_PROVIDERS as readonly string[]).includes(value)) return value as EdgeProvider
+ // Fail loudly on typos: silently falling back to `none` would disable
+ // geo-blocking on a deployment that intended to have it.
+ throw new Error(`Unknown EDGE_PROVIDER "${raw}" — expected one of: ${EDGE_PROVIDERS.join(', ')}`)
+}
+
+/** Whether the preset is expected to deliver a country for every request. */
+export function edgeProvidesGeo(provider: EdgeProvider): boolean {
+ return provider !== 'none'
+}
+
+/** Whether the preset delivers VPN/proxy evidence headers. */
+export function edgeProvidesVpnEvidence(provider: EdgeProvider): boolean {
+ return provider === 'cloudflare'
+}
+
+/**
+ * Whether the preset refuses to run without EDGE_ORIGIN_SECRET (enforced at
+ * boot by edge-guard). These edges stamp origin custom headers as a matter
+ * of course, and without origin auth their trusted inputs would be
+ * forgeable by anyone who can reach the origin directly — with no
+ * compensating deployment history. `cloudflare` stays optional for rollout
+ * parity (the origin-locked topology is its documented historical
+ * assumption) and `none` carries no edge-derived trust to protect.
+ */
+export function edgeRequiresOriginSecret(provider: EdgeProvider): boolean {
+ return provider === 'google' || provider === 'cloudfront'
+}
+
+/**
+ * Uppercase ISO 3166-1 alpha-2 or null. 'XX' (unknown IP) and non-alpha
+ * codes (e.g. 'T1' for Tor exit nodes) are treated as undetermined.
+ */
+export function normalizeCountry(raw: string | null | undefined): string | null {
+ const country = raw?.toUpperCase()
+ return (country && /^[A-Z]{2}$/.test(country) && country !== 'XX') ? country : null
+}
+
+function singleHeader(headers: RawHeaders, name: string): string | null {
+ const value = headers[name]
+ // Arrays (duplicate headers) are treated as absent: a trusted edge sets
+ // each of these headers exactly once.
+ return (typeof value === 'string' && value.trim()) ? value.trim() : null
+}
+
+function forwardedForEntries(headers: RawHeaders): string[] {
+ const raw = headers['x-forwarded-for']
+ const joined = Array.isArray(raw) ? raw.join(',') : (raw ?? '')
+ return joined.split(',').map(entry => entry.trim()).filter(Boolean)
+}
+
+function isTruthyHeader(value: string | string[] | undefined): boolean {
+ const headers = Array.isArray(value) ? value : [value]
+ return headers
+ .filter((header): header is string => typeof header === 'string')
+ .flatMap(header => header.split(','))
+ .some(token => token.trim().toLowerCase() === 'true')
+}
+
+function hasHeader(value: string | string[] | undefined): boolean {
+ const values = Array.isArray(value) ? value : [value]
+ return values.some(entry => typeof entry === 'string' && entry.trim() !== '')
+}
+
+// The VPN verdict comes from edge-set request headers, never from the client
+// body — a client could otherwise clear its own flag. When neither header is
+// present the measurement is unknown and reported as null (stored upstream
+// as "not measured"), never as a fabricated false.
+function deriveVpnEvidence(headers: RawHeaders): boolean | null {
+ const vpn = headers['x-is-vpn']
+ const proxyOrVpn = headers['x-is-proxy-or-vpn']
+ if (!hasHeader(vpn) && !hasHeader(proxyOrVpn)) {
+ return null
+ }
+ return isTruthyHeader(vpn) || isTruthyHeader(proxyOrVpn)
+}
+
+const IPV6_GROUP = /^[0-9a-f]{1,4}$/i
+
+// Structural IPv6 check (no node:net — this module must stay pure for the
+// client bundle): eight hex groups, or a single "::" compressing to at most
+// seven. Enough to tell ":" from a bare address.
+function isWellFormedIpv6(candidate: string): boolean {
+ const halves = candidate.split('::')
+ if (halves.length > 2) return false
+ const groups = halves.flatMap(half => half === '' ? [] : half.split(':'))
+ if (!groups.every(group => IPV6_GROUP.test(group))) return false
+ return halves.length === 2 ? groups.length <= 7 : groups.length === 8
+}
+
+// cloudfront-viewer-address is ":", IPv6 unbracketed (e.g.
+// "2001:db8::1:41768"). The trailing segment is only stripped when it is
+// numeric AND what remains is still a well-formed address, so a bare IPv6
+// address whose last hextet happens to be numeric (e.g. "2001:db8::1", or a
+// full eight-group address) keeps it instead of silently becoming a
+// different, still-valid-looking identity.
+function stripPort(address: string): string {
+ const separator = address.lastIndexOf(':')
+ if (separator === -1) return address
+ const host = address.slice(0, separator)
+ const port = address.slice(separator + 1)
+ if (!/^\d{1,5}$/.test(port)) return address
+ if (!host.includes(':')) return host
+ return isWellFormedIpv6(host) ? host : address
+}
+
+export function extractEdgeInputs(
+ provider: EdgeProvider,
+ headers: RawHeaders,
+ socketAddress: string | undefined,
+): EdgeInputs {
+ switch (provider) {
+ case 'cloudflare':
+ return {
+ clientIp: singleHeader(headers, 'cf-connecting-ip'),
+ country: normalizeCountry(singleHeader(headers, 'cf-ipcountry')),
+ vpnIsUsed: deriveVpnEvidence(headers),
+ }
+ case 'google': {
+ // Google external LBs append ", " to x-forwarded-for,
+ // so with exactly one LB hop the client is the second-to-last entry.
+ // Fewer than two entries means the request cannot have traversed the
+ // LB — no trustworthy identity.
+ //
+ // x-client-geo is NOT a header Google's LB sets on its own: the backend
+ // service must be configured with a custom request header
+ // `x-client-geo: {client_region}`, which the LB then stamps on every
+ // forwarded request (replacing any client-supplied value). Without that
+ // configuration the header is forwarded from the client untouched and
+ // the country is forgeable — origin auth proves the request came
+ // through the LB, not that the LB wrote this header. Deploying this
+ // preset requires both the origin-auth stamp and this custom header.
+ const entries = forwardedForEntries(headers)
+ return {
+ clientIp: entries.length >= 2 ? entries[entries.length - 2] : null,
+ country: normalizeCountry(singleHeader(headers, 'x-client-geo')),
+ vpnIsUsed: null,
+ }
+ }
+ case 'cloudfront': {
+ const viewer = singleHeader(headers, 'cloudfront-viewer-address')
+ return {
+ clientIp: viewer ? stripPort(viewer) : null,
+ country: normalizeCountry(singleHeader(headers, 'cloudfront-viewer-country')),
+ vpnIsUsed: null,
+ }
+ }
+ case 'none': {
+ // Without an edge the only semi-trustworthy identity is the rightmost
+ // x-forwarded-for entry (appended by the hosting platform, unlike the
+ // client-controlled leftmost entries), falling back to the socket peer.
+ // When no platform proxy rewrites the header, a direct client controls
+ // that entry too — `none` is best-effort identity by definition, which
+ // is why production must opt into it explicitly.
+ const entries = forwardedForEntries(headers)
+ return {
+ clientIp: entries[entries.length - 1] ?? socketAddress?.trim() ?? null,
+ country: null,
+ vpnIsUsed: null,
+ }
+ }
+ }
+}
diff --git a/utils/sanitizeApiResponse.ts b/utils/sanitizeApiResponse.ts
index b18222915..621d4409c 100644
--- a/utils/sanitizeApiResponse.ts
+++ b/utils/sanitizeApiResponse.ts
@@ -1,7 +1,7 @@
/**
- * Known root-level keys injected into JSON response bodies by the CDN edge
- * layer (e.g. Cloudflare Worker) on certain hostnames. These are geo/VPN
- * metadata fields that pollute the intended JSON schema.
+ * Known root-level keys injected into JSON response bodies by an edge/CDN
+ * worker on certain hostnames. These are geo/VPN metadata fields that
+ * pollute the intended JSON schema.
*/
const EDGE_INJECTED_KEYS = new Set(['countryCode', 'isProxyOrVpn', 'is_vpn'])