Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
25 changes: 20 additions & 5 deletions composables/useEnvConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -58,6 +69,7 @@ const DEFAULTS: EnvConfig = {
swapApiUrl: '',
eulerInterfacesBranch: 'master',
announcement: EMPTY_ANNOUNCEMENT_CONFIG,
vpnDetection: false,
}

let cached: EnvConfig | null = null
Expand Down Expand Up @@ -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)),
}
}

Expand Down Expand Up @@ -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,
}
}

Expand All @@ -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()
Expand Down
34 changes: 24 additions & 10 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -363,20 +363,34 @@ The app includes a built-in per-IP rate limiter as a defense-in-depth measure. D

- **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

Expand Down
Loading
Loading