From d8ed4248b12c185ba42780414fece0820dbeae98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:58:50 +0000 Subject: [PATCH 01/16] chore(deps): bump svgo Bumps and [svgo](https://github.com/svg/svgo). These dependencies needed to be updated together. Updates `svgo` from 3.3.4 to 3.3.5 - [Release notes](https://github.com/svg/svgo/releases) - [Commits](https://github.com/svg/svgo/compare/v3.3.4...v3.3.5) Updates `svgo` from 4.0.2 to 4.1.0 - [Release notes](https://github.com/svg/svgo/releases) - [Commits](https://github.com/svg/svgo/compare/v3.3.4...v3.3.5) --- updated-dependencies: - dependency-name: svgo dependency-version: 3.3.5 dependency-type: indirect - dependency-name: svgo dependency-version: 4.1.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 52 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index dedff1f3f..2b136e406 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", From 72845600e452e327addb9df766aadf534ef2cc4d Mon Sep 17 00:00:00 2001 From: Darek Date: Wed, 9 Sep 2026 11:31:24 +0200 Subject: [PATCH 02/16] fix: exclude external vaults from migration snapshots --- .../planning/requirements.ts | 20 +++- .../reviewed-execution/intent-factory.test.ts | 73 ++++++++++++- .../preparation-service.test.ts | 101 ++++++++++++++++-- 3 files changed, 180 insertions(+), 14 deletions(-) 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/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') From ab3a994c393a09b33778282ce25fc433e21d0c87 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Fri, 21 Aug 2026 14:06:28 +0200 Subject: [PATCH 03/16] refactor: provider-neutral internal fetch auth with origin-secret marker --- server/utils/internal-headers.ts | 77 ++++++++---- server/utils/labels-helpers.ts | 4 +- server/utils/labels-view.ts | 4 +- server/utils/timing-safe.ts | 15 +++ tests/server/internal-request.test.ts | 100 ++++++++++----- tests/server/labels-view.test.ts | 4 +- utils/edge-presets.ts | 171 ++++++++++++++++++++++++++ 7 files changed, 314 insertions(+), 61 deletions(-) create mode 100644 server/utils/timing-safe.ts create mode 100644 utils/edge-presets.ts diff --git a/server/utils/internal-headers.ts b/server/utils/internal-headers.ts index e6f17b0f0..1f41cc0c4 100644 --- a/server/utils/internal-headers.ts +++ b/server/utils/internal-headers.ts @@ -1,36 +1,65 @@ import type { H3Event } from 'h3' +import { + EDGE_ORIGIN_AUTH_HEADER, + INTERNAL_MARKER_HEADER, + INTERNAL_SENTINEL_HEADER, + INTERNAL_SENTINEL_VALUE, +} 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. + * Two trust models, selected by whether EDGE_ORIGIN_SECRET is configured: * - * 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. + * - Secret configured: internal fetches stamp the origin-auth header plus + * an internal marker header, both carrying the secret. External clients + * cannot forge the marker without knowing the secret, and the edge never + * stamps it (it should strip it from inbound traffic as defense-in-depth). + * This is the recommended mode for any deployment fronted by an edge. + * + * - No secret: fall back to a loopback sentinel in a header the edge + * overwrites in transit (see the sentinel constants and their rationale + * in utils/edge-presets.ts). SECURITY: this sentinel + * is only sound while the edge overwrites that header in transit AND the + * origin is not directly reachable — an attacker who bypasses the edge + * can spoof it to skip rate limiting AND geo-blocking. Configure + * EDGE_ORIGIN_SECRET to close that hole. 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 +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_SENTINEL_HEADER]: INTERNAL_SENTINEL_VALUE } +} /** - * 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 secret = process.env.EDGE_ORIGIN_SECRET?.trim() + const headers = event.node.req.headers + if (secret) { + const marker = headers[INTERNAL_MARKER_HEADER] + return typeof marker === 'string' && timingSafeEqualStrings(marker, secret) + } + return headers[INTERNAL_SENTINEL_HEADER] === INTERNAL_SENTINEL_VALUE +} 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/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/tests/server/internal-request.test.ts b/tests/server/internal-request.test.ts index 1995408d1..d696e1ecf 100644 --- a/tests/server/internal-request.test.ts +++ b/tests/server/internal-request.test.ts @@ -1,53 +1,91 @@ /** - * 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 headers that + * downstream middleware recognises via `isInternalRequest` to bypass those + * checks — a secret-based marker when EDGE_ORIGIN_SECRET is configured, a + * loopback sentinel otherwise. * - * 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 { 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') - }) +let secretSnapshot: string | undefined + +beforeEach(() => { + secretSnapshot = process.env.EDGE_ORIGIN_SECRET + Reflect.deleteProperty(process.env, 'EDGE_ORIGIN_SECRET') }) -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(() => { + if (secretSnapshot === undefined) Reflect.deleteProperty(process.env, 'EDGE_ORIGIN_SECRET') + else process.env.EDGE_ORIGIN_SECRET = secretSnapshot +}) - it('returns true for requests decorated with INTERNAL_FETCH_HEADERS', () => { - // End-to-end contract: whatever INTERNAL_FETCH_HEADERS sets must be - // what isInternalRequest recognises. - const event = eventWithHeaders({ ...INTERNAL_FETCH_HEADERS }) - expect(isInternalRequest(event)).toBe(true) +describe('without EDGE_ORIGIN_SECRET (sentinel mode)', () => { + it('stamps the loopback sentinel', () => { + expect(getInternalFetchHeaders()).toEqual({ 'cf-connecting-ip': '127.0.0.1' }) }) - it('returns false when cf-connecting-ip is absent', () => { - const event = eventWithHeaders({}) - expect(isInternalRequest(event)).toBe(false) + it('recognises requests decorated with getInternalFetchHeaders()', () => { + // End-to-end contract: whatever getInternalFetchHeaders sets must be + // what isInternalRequest recognises. + expect(isInternalRequest(eventWithHeaders({ ...getInternalFetchHeaders() }))).toBe(true) }) - it('returns false for any other IP', () => { + it('returns false when the sentinel is absent or different', () => { + expect(isInternalRequest(eventWithHeaders({}))).toBe(false) expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '203.0.113.1' }))).toBe(false) expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '::1' }))).toBe(false) expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '127.0.0.2' }))).toBe(false) }) + + it('ignores the secret marker header entirely (nothing to verify it against)', () => { + expect(isInternalRequest(eventWithHeaders({ 'x-edge-internal': 'anything' }))).toBe(false) + }) +}) + +describe('with EDGE_ORIGIN_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('no longer honours the loopback sentinel — spoofing it must not bypass gates', () => { + 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) + }) }) 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/utils/edge-presets.ts b/utils/edge-presets.ts new file mode 100644 index 000000000..969ca3543 --- /dev/null +++ b/utils/edge-presets.ts @@ -0,0 +1,171 @@ +/** + * 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 (with the origin-auth secret as value) + * only by server-internal $fetch calls, never by the edge. Because the + * value is the secret itself, external clients cannot forge it. The edge + * should additionally strip this header from inbound traffic as + * defense-in-depth. + */ +export const INTERNAL_MARKER_HEADER = 'x-edge-internal' + +/** + * No-secret internal sentinel. Deliberately reuses the cloudflare preset's + * trusted-IP header: that edge always overwrites it in transit, so external + * clients cannot smuggle the loopback value through the edge. See + * `server/utils/internal-headers.ts` for the full trust model. + */ +export const INTERNAL_SENTINEL_HEADER = 'cf-connecting-ip' +export const INTERNAL_SENTINEL_VALUE = '127.0.0.1' + +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' +} + +/** + * 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 { + return Array.isArray(value) ? value.length > 0 : value !== undefined +} + +// 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) +} + +// cloudfront-viewer-address is always ":", IPv6 included +// (e.g. "2001:db8::1:41768"), so stripping after the last colon is safe. +function stripPort(address: string): string { + const separator = address.lastIndexOf(':') + return separator === -1 ? address : address.slice(0, separator) +} + +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. + 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. + const entries = forwardedForEntries(headers) + return { + clientIp: entries[entries.length - 1] ?? socketAddress?.trim() ?? null, + country: null, + vpnIsUsed: null, + } + } + } +} From b2fde88eef015b7bf07913e6ada207ab539e96eb Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Fri, 21 Aug 2026 14:06:44 +0200 Subject: [PATCH 04/16] feat: normalized edge context with origin auth and production boot guard --- server/plugins/edge-guard.ts | 24 +++ server/utils/edge.ts | 95 ++++++++++++ tests/server/edge.test.ts | 287 +++++++++++++++++++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 server/plugins/edge-guard.ts create mode 100644 server/utils/edge.ts create mode 100644 tests/server/edge.test.ts diff --git a/server/plugins/edge-guard.ts b/server/plugins/edge-guard.ts new file mode 100644 index 000000000..f8938da78 --- /dev/null +++ b/server/plugins/edge-guard.ts @@ -0,0 +1,24 @@ +/** + * 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() + logger.info( + { + ctx: 'edge-guard', + edgeProvider: parseEdgeProvider(process.env.EDGE_PROVIDER), + originAuth: process.env.EDGE_ORIGIN_SECRET?.trim() ? 'enforced' : 'off', + }, + 'edge provider configuration resolved', + ) +}) diff --git a/server/utils/edge.ts b/server/utils/edge.ts new file mode 100644 index 000000000..606c519ff --- /dev/null +++ b/server/utils/edge.ts @@ -0,0 +1,95 @@ +import type { H3Event } from 'h3' +import { + EDGE_ORIGIN_AUTH_HEADER, + edgeProvidesGeo, + 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 regardless of environment so + // deployments without a geo-capable edge (local dev, PR previews) are + // not universally fail-closed. Do not set it in production. + 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`), and 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. + */ +export function assertEdgeConfig(): void { + parseEdgeProvider(process.env.EDGE_PROVIDER) + if (process.env.DOPPLER_ENVIRONMENT === 'prd' && !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.', + ) + } +} diff --git a/tests/server/edge.test.ts b/tests/server/edge.test.ts new file mode 100644 index 000000000..735f75d9f --- /dev/null +++ b/tests/server/edge.test.ts @@ -0,0 +1,287 @@ +/** + * 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, + extractEdgeInputs, + normalizeCountry, + parseEdgeProvider, +} from '~/utils/edge-presets' +import { assertEdgeConfig, getEdgeContext } from '~/server/utils/edge' + +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) + }) +}) + +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('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({ 'cf-connecting-ip': '127.0.0.1' })).isInternal).toBe(true) + + 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 on a typoed preset in any environment', () => { + process.env.EDGE_PROVIDER = 'cloudflre' + expect(() => assertEdgeConfig()).toThrow(/Unknown EDGE_PROVIDER/) + }) +}) From 19e0177ad885268e68d92c0b9e5eba71c88e8543 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Fri, 21 Aug 2026 14:06:48 +0200 Subject: [PATCH 05/16] refactor: consume the edge context in rate-limit, geo-gate, cors, and screening --- server/middleware/cors.ts | 32 +++--- server/middleware/geo-gate.ts | 51 ++++----- server/utils/rate-limit.ts | 60 +++++----- server/utils/screening.ts | 31 ++--- tests/server/cors-internal-api.test.ts | 32 ++++++ tests/server/geo-gate.test.ts | 72 +++++++++++- tests/server/rate-limit.test.ts | 150 +++++++++++++++++++++++++ tests/server/screen-address.test.ts | 16 +++ utils/edge-presets.ts | 3 +- 9 files changed, 340 insertions(+), 107 deletions(-) create mode 100644 tests/server/rate-limit.test.ts diff --git a/server/middleware/cors.ts b/server/middleware/cors.ts index 1a0fd70ca..1ac8e68cc 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 { @@ -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/utils/rate-limit.ts b/server/utils/rate-limit.ts index 89ba304ad..1923dcc58 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,29 @@ 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. +// 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 +73,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/tests/server/cors-internal-api.test.ts b/tests/server/cors-internal-api.test.ts index a8be29d96..4aac9e3fc 100644 --- a/tests/server/cors-internal-api.test.ts +++ b/tests/server/cors-internal-api.test.ts @@ -317,4 +317,36 @@ describe('cors internal API boundary', () => { expect(handler(internalEvent)).toBeUndefined() expect(internalEvent.headers['X-API-Stability']).toBe('internal; may-break-without-notice') }) + + 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/geo-gate.test.ts b/tests/server/geo-gate.test.ts index 3c5b420bb..2fa618f74 100644 --- a/tests/server/geo-gate.test.ts +++ b/tests/server/geo-gate.test.ts @@ -1,11 +1,16 @@ /** - * 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' @@ -41,7 +46,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 +68,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 +83,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 +98,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,6 +114,7 @@ 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`, { @@ -116,3 +125,60 @@ describe('geo-gate log hygiene', () => { expect(warn).not.toHaveBeenCalled() }) }) + +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/rate-limit.test.ts b/tests/server/rate-limit.test.ts new file mode 100644 index 000000000..6a78622b7 --- /dev/null +++ b/tests/server/rate-limit.test.ts @@ -0,0 +1,150 @@ +/** + * 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 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({ 'cf-connecting-ip': '127.0.0.1' }) + 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() + }) +}) + +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/utils/edge-presets.ts b/utils/edge-presets.ts index 969ca3543..d060b4fe9 100644 --- a/utils/edge-presets.ts +++ b/utils/edge-presets.ts @@ -101,7 +101,8 @@ function isTruthyHeader(value: string | string[] | undefined): boolean { } function hasHeader(value: string | string[] | undefined): boolean { - return Array.isArray(value) ? value.length > 0 : value !== undefined + 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 From 13011dbc0eb7a0ac52ebd9ed393cec5ed5bf0de2 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Fri, 21 Aug 2026 14:07:02 +0200 Subject: [PATCH 06/16] feat: skip the client VPN probe on edges without VPN evidence --- composables/useEnvConfig.ts | 10 ++++++++++ server/plugins/app-config.ts | 5 +++++ services/vpn.ts | 15 +++++++++++++++ tests/services/vpn.test.ts | 24 ++++++++++++++++++++++-- utils/sanitizeApiResponse.ts | 6 +++--- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/composables/useEnvConfig.ts b/composables/useEnvConfig.ts index ca94ad056..dc60b8ba9 100644 --- a/composables/useEnvConfig.ts +++ b/composables/useEnvConfig.ts @@ -22,6 +22,7 @@ import { EMPTY_ANNOUNCEMENT_CONFIG, type AnnouncementConfig, } from '~/utils/announcement-config' +import { edgeProvidesVpnEvidence, parseEdgeProvider } from '~/utils/edge-presets' interface EnvConfig { appTitle: string @@ -42,6 +43,9 @@ 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 } const DEFAULTS: EnvConfig = { @@ -58,6 +62,7 @@ const DEFAULTS: EnvConfig = { swapApiUrl: '', eulerInterfacesBranch: 'master', announcement: EMPTY_ANNOUNCEMENT_CONFIG, + vpnDetection: false, } let cached: EnvConfig | null = null @@ -94,6 +99,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 +136,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, } } 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/services/vpn.ts b/services/vpn.ts index f3fe8ac73..83f498945 100644 --- a/services/vpn.ts +++ b/services/vpn.ts @@ -2,7 +2,22 @@ import { CACHE_TTL_5MIN_MS, WALLET_SCREENING_TIMEOUT_MS } from '~/entities/tunin let cached: { value: boolean, 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 — the authoritative verdict is derived server-side from edge +// request headers, and the client-reported value is never trusted. +function edgeProvidesVpnEvidence(): boolean { + if (typeof window === 'undefined') return false + /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- server-injected window global */ + return (window as any).__APP_CONFIG__?.vpnDetection === true +} + export async function detectVpn(): Promise { + if (!edgeProvidesVpnEvidence()) { + return false + } + if (cached !== null && Date.now() - cached.timestamp < CACHE_TTL_5MIN_MS) { return cached.value } diff --git a/tests/services/vpn.test.ts b/tests/services/vpn.test.ts index 1089168d8..fba481e6c 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() @@ -10,7 +19,7 @@ describe('detectVpn', () => { }) it('reads the VPN edge header', async () => { - vi.stubGlobal('window', { location: { origin: 'http://localhost:3000' } }) + stubWindow(true) vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { headers: { 'x-is-vpn': 'true' }, status: 200, @@ -21,7 +30,7 @@ describe('detectVpn', () => { it('fails closed when VPN detection stalls', 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'))) @@ -34,4 +43,15 @@ describe('detectVpn', () => { await expect(promise).resolves.toBe(true) }) + + 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.toBe(false) + } + expect(fetchMock).not.toHaveBeenCalled() + }) }) 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']) From 3927cb4d7ac271048733e7cb885d3bba4db17331 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Fri, 21 Aug 2026 14:07:06 +0200 Subject: [PATCH 07/16] docs: provider-neutral edge model in env, architecture, and geo docs --- .env.example | 22 ++++++++++++++++++++-- AGENTS.md | 8 +++++--- docs/architecture.md | 30 ++++++++++++++++++++---------- docs/geo-blocking.md | 33 ++++++++++++++++++--------------- 4 files changed, 63 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index 7d8c285e5..c2eee47c2 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,26 @@ # 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. +EDGE_PROVIDER= + +# Origin-auth shared secret (optional, 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. 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 — do +# not set in production. 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/docs/architecture.md b/docs/architecture.md index 8f19ac972..1b4fe7ad2 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 @@ -363,20 +363,30 @@ 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 header) | — | +| `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. + +**Origin auth** (`EDGE_ORIGIN_SECRET`, optional): 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. Until the secret is set, the edge headers are trusted on the historical assumption that the origin is only reachable through the edge — configuring the secret is what closes direct-to-origin spoofing. + +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` bypasses fail-closed if set. | 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. ### Clickjacking & Framing Defenses diff --git a/docs/geo-blocking.md b/docs/geo-blocking.md index bd01c85e2..c7983415d 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`). Clients cannot modify the edge-set header, and any client-supplied `x-country-code` request header is stripped by `cors.ts` before processing, preventing bypass. 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); do not set in production | 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 | From 14c3eb15533c61e365a2663e7c158b45a233e20b Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Fri, 21 Aug 2026 14:24:39 +0200 Subject: [PATCH 08/16] fix: never honor the internal sentinel under presets whose edge forwards it The loopback sentinel is only a sound internal-request signal where the edge overwrites the sentinel header in transit (cloudflare) or where no edge-derived trust exists at all (none). The google/cloudfront presets forward client headers untouched, so a forged sentinel could bypass geo-blocking and rate limiting: those presets now require EDGE_ORIGIN_SECRET at boot and never honor the sentinel. --- .env.example | 15 +++++++----- docs/architecture.md | 2 +- server/utils/edge.ts | 20 ++++++++++++---- server/utils/internal-headers.ts | 23 ++++++++++++++----- tests/server/edge.test.ts | 19 +++++++++++++++ tests/server/internal-request.test.ts | 33 +++++++++++++++++++++++---- utils/edge-presets.ts | 13 +++++++++++ 7 files changed, 103 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index c2eee47c2..ba535b694 100644 --- a/.env.example +++ b/.env.example @@ -10,13 +10,16 @@ DOPPLER_ENVIRONMENT=dev # (DOPPLER_ENVIRONMENT=prd): the server refuses to boot when unset there. EDGE_PROVIDER= -# Origin-auth shared secret (optional, 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-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. Configure the edge to stamp the header -# BEFORE setting this, and to strip client-supplied x-edge-internal. +# check the app enforces itself. Optional for the cloudflare/none presets; +# REQUIRED for google/cloudfront (their edges don't overwrite the internal +# sentinel header, so internal fetches must authenticate with the secret — +# the server refuses to boot without it). 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 diff --git a/docs/architecture.md b/docs/architecture.md index 1b4fe7ad2..e9eebc13e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -376,7 +376,7 @@ The server never reads vendor edge headers directly. `getEdgeContext(event)` (`s **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. -**Origin auth** (`EDGE_ORIGIN_SECRET`, optional): 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. Until the secret is set, the edge headers are trusted on the historical assumption that the origin is only reachable through the edge — configuring the secret is what closes direct-to-origin spoofing. +**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, and internal fetches use a loopback sentinel that the edge overwrites in transit. It is **required** for `google` and `cloudfront` (the server refuses to boot without it): those edges forward client headers untouched, so the sentinel would be forgeable and is never honoured under them. Configuring the secret is what closes direct-to-origin spoofing in every preset. Fail-closed behaviour per environment (geo-capable presets): diff --git a/server/utils/edge.ts b/server/utils/edge.ts index 606c519ff..2dc195f85 100644 --- a/server/utils/edge.ts +++ b/server/utils/edge.ts @@ -1,6 +1,7 @@ import type { H3Event } from 'h3' import { EDGE_ORIGIN_AUTH_HEADER, + edgeHonorsInternalSentinel, edgeProvidesGeo, extractEdgeInputs, normalizeCountry, @@ -77,13 +78,16 @@ export function getEdgeContext(event: H3Event): EdgeContext { * 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`), and when production boots without a - * preset: under `none` geo-blocking is off and rate limiting falls back to + * 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. + * production deployment), and when a preset that cannot honour the + * loopback sentinel runs without EDGE_ORIGIN_SECRET (its edge forwards + * client headers untouched, so unauthenticated internal detection would be + * forgeable — and internal fetches could not pass the gates at all). */ export function assertEdgeConfig(): void { - parseEdgeProvider(process.env.EDGE_PROVIDER) + const provider = parseEdgeProvider(process.env.EDGE_PROVIDER) if (process.env.DOPPLER_ENVIRONMENT === 'prd' && !process.env.EDGE_PROVIDER?.trim()) { throw new Error( 'EDGE_PROVIDER must be set in production (DOPPLER_ENVIRONMENT=prd): ' @@ -92,4 +96,12 @@ export function assertEdgeConfig(): void { + 'for a deployment that intentionally runs without one.', ) } + if (!edgeHonorsInternalSentinel(provider) && !process.env.EDGE_ORIGIN_SECRET?.trim()) { + throw new Error( + `EDGE_PROVIDER=${provider} requires EDGE_ORIGIN_SECRET: this edge does not ` + + 'overwrite the internal sentinel header in transit, so internal fetches must ' + + 'authenticate with the origin-auth secret instead. Configure the edge to stamp ' + + 'x-edge-origin-auth and set the secret.', + ) + } } diff --git a/server/utils/internal-headers.ts b/server/utils/internal-headers.ts index 1f41cc0c4..f3ea42dc5 100644 --- a/server/utils/internal-headers.ts +++ b/server/utils/internal-headers.ts @@ -1,9 +1,11 @@ import type { H3Event } from 'h3' import { EDGE_ORIGIN_AUTH_HEADER, + edgeHonorsInternalSentinel, INTERNAL_MARKER_HEADER, INTERNAL_SENTINEL_HEADER, INTERNAL_SENTINEL_VALUE, + parseEdgeProvider, } from '~/utils/edge-presets' import { timingSafeEqualStrings } from '~/server/utils/timing-safe' @@ -30,12 +32,16 @@ import { timingSafeEqualStrings } from '~/server/utils/timing-safe' * * - No secret: fall back to a loopback sentinel in a header the edge * overwrites in transit (see the sentinel constants and their rationale - * in utils/edge-presets.ts). SECURITY: this sentinel - * is only sound while the edge overwrites that header in transit AND the - * origin is not directly reachable — an attacker who bypasses the edge - * can spoof it to skip rate limiting AND geo-blocking. Configure - * EDGE_ORIGIN_SECRET to close that hole. Do not add these headers to - * anything that forwards user input into the downstream URL. + * in utils/edge-presets.ts). The sentinel is only honoured under presets + * where that overwrite actually happens or where no edge-derived trust + * exists at all (`edgeHonorsInternalSentinel`); the remaining presets + * forward client headers untouched and therefore require + * EDGE_ORIGIN_SECRET — enforced at boot by edge-guard. SECURITY: even + * where honoured, the sentinel relies on the origin not being directly + * reachable — an attacker who bypasses the edge can spoof it to skip + * rate limiting AND geo-blocking. Configure EDGE_ORIGIN_SECRET to close + * that hole. Do not add these headers to anything that forwards user + * input into the downstream URL. */ export function getInternalFetchHeaders(): Record { const secret = process.env.EDGE_ORIGIN_SECRET?.trim() @@ -61,5 +67,10 @@ export function isInternalRequest(event: H3Event): boolean { const marker = headers[INTERNAL_MARKER_HEADER] return typeof marker === 'string' && timingSafeEqualStrings(marker, secret) } + // Defense-in-depth backstop: presets that require the secret never honour + // the sentinel, even if a deployment somehow reaches this state. + if (!edgeHonorsInternalSentinel(parseEdgeProvider(process.env.EDGE_PROVIDER))) { + return false + } return headers[INTERNAL_SENTINEL_HEADER] === INTERNAL_SENTINEL_VALUE } diff --git a/tests/server/edge.test.ts b/tests/server/edge.test.ts index 735f75d9f..c533a9e47 100644 --- a/tests/server/edge.test.ts +++ b/tests/server/edge.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { H3Event } from 'h3' import { + edgeHonorsInternalSentinel, edgeProvidesGeo, edgeProvidesVpnEvidence, extractEdgeInputs, @@ -90,6 +91,13 @@ describe('preset capabilities', () => { expect(edgeProvidesVpnEvidence('cloudfront')).toBe(false) expect(edgeProvidesVpnEvidence('none')).toBe(false) }) + + it('only presets whose edge overwrites (or never trusts) the sentinel header honour it', () => { + expect(edgeHonorsInternalSentinel('cloudflare')).toBe(true) + expect(edgeHonorsInternalSentinel('none')).toBe(true) + expect(edgeHonorsInternalSentinel('google')).toBe(false) + expect(edgeHonorsInternalSentinel('cloudfront')).toBe(false) + }) }) describe('extractEdgeInputs — cloudflare', () => { @@ -284,4 +292,15 @@ describe('assertEdgeConfig', () => { 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/internal-request.test.ts b/tests/server/internal-request.test.ts index d696e1ecf..b94b8d2f1 100644 --- a/tests/server/internal-request.test.ts +++ b/tests/server/internal-request.test.ts @@ -22,16 +22,22 @@ import { getInternalFetchHeaders, isInternalRequest } from '~/server/utils/inter const eventWithHeaders = (headers: Record): H3Event => ({ node: { req: { headers } } }) as unknown as H3Event -let secretSnapshot: string | undefined +const ENV_KNOBS = ['EDGE_ORIGIN_SECRET', 'EDGE_PROVIDER'] as const + +const envSnapshot: Record = {} beforeEach(() => { - secretSnapshot = process.env.EDGE_ORIGIN_SECRET - Reflect.deleteProperty(process.env, 'EDGE_ORIGIN_SECRET') + for (const key of ENV_KNOBS) { + envSnapshot[key] = process.env[key] + Reflect.deleteProperty(process.env, key) + } }) afterEach(() => { - if (secretSnapshot === undefined) Reflect.deleteProperty(process.env, 'EDGE_ORIGIN_SECRET') - else process.env.EDGE_ORIGIN_SECRET = secretSnapshot + 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 (sentinel mode)', () => { @@ -55,6 +61,23 @@ describe('without EDGE_ORIGIN_SECRET (sentinel mode)', () => { it('ignores the secret marker header entirely (nothing to verify it against)', () => { expect(isInternalRequest(eventWithHeaders({ 'x-edge-internal': 'anything' }))).toBe(false) }) + + it('honours the sentinel only under presets whose edge overwrites it (or the none preset)', () => { + const sentinel = eventWithHeaders({ 'cf-connecting-ip': '127.0.0.1' }) + + process.env.EDGE_PROVIDER = 'cloudflare' + expect(isInternalRequest(sentinel)).toBe(true) + process.env.EDGE_PROVIDER = 'none' + expect(isInternalRequest(sentinel)).toBe(true) + + // These edges forward client headers untouched — a forged sentinel must + // never grant internal status (they require EDGE_ORIGIN_SECRET at boot; + // this is the defense-in-depth backstop). + process.env.EDGE_PROVIDER = 'google' + expect(isInternalRequest(sentinel)).toBe(false) + process.env.EDGE_PROVIDER = 'cloudfront' + expect(isInternalRequest(sentinel)).toBe(false) + }) }) describe('with EDGE_ORIGIN_SECRET (marker mode)', () => { diff --git a/utils/edge-presets.ts b/utils/edge-presets.ts index d060b4fe9..daec1a766 100644 --- a/utils/edge-presets.ts +++ b/utils/edge-presets.ts @@ -70,6 +70,19 @@ export function edgeProvidesVpnEvidence(provider: EdgeProvider): boolean { return provider === 'cloudflare' } +/** + * Whether the no-secret loopback sentinel is a sound internal-request + * signal under this preset. Only true where the edge overwrites the + * sentinel header in transit (cloudflare) or where no edge-derived trust + * exists at all (none — forks, previews). The other presets forward + * client-supplied headers untouched, so an external caller could forge + * internal status with one header; they must authenticate internal fetches + * with EDGE_ORIGIN_SECRET instead (enforced at boot by edge-guard). + */ +export function edgeHonorsInternalSentinel(provider: EdgeProvider): boolean { + return provider === 'cloudflare' || provider === 'none' +} + /** * 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. From 323eaf9db64fad7d1aa5f8ab6f1e1a00790c30c3 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Fri, 21 Aug 2026 14:59:06 +0200 Subject: [PATCH 09/16] fix: probe /healthz for container liveness instead of a gated internal route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker healthcheck authenticated with the loopback sentinel, which the origin-auth mode deliberately ignores — enabling EDGE_ORIGIN_SECRET would mark a healthy container unhealthy. /healthz lives outside /api/ so it is exempt from the geo-gate, rate limiting, and internal-request authentication, and the probe never carries the secret. --- Dockerfile | 6 ++++- server/routes/healthz.get.ts | 17 +++++++++++++ tests/server/healthz.test.ts | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 server/routes/healthz.get.ts create mode 100644 tests/server/healthz.test.ts 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/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/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') + }) +}) From 4a6d1f47179586d231adb556280f8e356a9493fb Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Fri, 21 Aug 2026 14:59:22 +0200 Subject: [PATCH 10/16] fix: replace the loopback internal sentinel with an unforgeable per-process marker Under presets whose edge does not overwrite cf-connecting-ip (notably none), a forged loopback sentinel granted internal status, bypassing rate-limit accounting and the internal-request exceptions in the CORS and geo middleware. Internal fetches now always authenticate with the x-edge-internal marker: EDGE_ORIGIN_SECRET when configured, otherwise a random per-process value that internal $fetch calls share by construction and external clients cannot guess. The sentinel is gone entirely; the google/cloudfront boot-time secret requirement stays, now justified by their forgeable trusted inputs rather than internal-fetch needs. --- .env.example | 11 +++-- docs/architecture.md | 4 +- server/middleware/cors.ts | 4 +- server/utils/edge.ts | 18 ++++---- server/utils/internal-headers.ts | 56 +++++++++-------------- tests/server/cors-internal-api.test.ts | 28 ++++++++++-- tests/server/edge.test.ts | 17 ++++--- tests/server/geo-gate.test.ts | 20 +++++++-- tests/server/internal-request.test.ts | 61 +++++++++++++------------- tests/server/rate-limit.test.ts | 26 ++++++++++- utils/edge-presets.ts | 36 ++++++--------- 11 files changed, 161 insertions(+), 120 deletions(-) diff --git a/.env.example b/.env.example index ba535b694..2d1b92522 100644 --- a/.env.example +++ b/.env.example @@ -16,10 +16,13 @@ EDGE_PROVIDER= # 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 don't overwrite the internal -# sentinel header, so internal fetches must authenticate with the secret — -# the server refuses to boot without it). Configure the edge to stamp the -# header BEFORE setting this, and to strip client-supplied x-edge-internal. +# 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 diff --git a/docs/architecture.md b/docs/architecture.md index e9eebc13e..7af1f420a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -376,7 +376,9 @@ The server never reads vendor edge headers directly. `getEdgeContext(event)` (`s **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. -**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, and internal fetches use a loopback sentinel that the edge overwrites in transit. It is **required** for `google` and `cloudfront` (the server refuses to boot without it): those edges forward client headers untouched, so the sentinel would be forgeable and is never honoured under them. Configuring the secret is what closes direct-to-origin spoofing in every preset. +**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): diff --git a/server/middleware/cors.ts b/server/middleware/cors.ts index 1ac8e68cc..82993fdef 100644 --- a/server/middleware/cors.ts +++ b/server/middleware/cors.ts @@ -67,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. // diff --git a/server/utils/edge.ts b/server/utils/edge.ts index 2dc195f85..fff6caa7a 100644 --- a/server/utils/edge.ts +++ b/server/utils/edge.ts @@ -1,8 +1,8 @@ import type { H3Event } from 'h3' import { EDGE_ORIGIN_AUTH_HEADER, - edgeHonorsInternalSentinel, edgeProvidesGeo, + edgeRequiresOriginSecret, extractEdgeInputs, normalizeCountry, parseEdgeProvider, @@ -81,10 +81,8 @@ export function getEdgeContext(event: H3Event): EdgeContext { * 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 cannot honour the - * loopback sentinel runs without EDGE_ORIGIN_SECRET (its edge forwards - * client headers untouched, so unauthenticated internal detection would be - * forgeable — and internal fetches could not pass the gates at all). + * production deployment), and when a preset that mandates origin auth + * runs without EDGE_ORIGIN_SECRET (see `edgeRequiresOriginSecret`). */ export function assertEdgeConfig(): void { const provider = parseEdgeProvider(process.env.EDGE_PROVIDER) @@ -96,12 +94,12 @@ export function assertEdgeConfig(): void { + 'for a deployment that intentionally runs without one.', ) } - if (!edgeHonorsInternalSentinel(provider) && !process.env.EDGE_ORIGIN_SECRET?.trim()) { + if (edgeRequiresOriginSecret(provider) && !process.env.EDGE_ORIGIN_SECRET?.trim()) { throw new Error( - `EDGE_PROVIDER=${provider} requires EDGE_ORIGIN_SECRET: this edge does not ` - + 'overwrite the internal sentinel header in transit, so internal fetches must ' - + 'authenticate with the origin-auth secret instead. Configure the edge to stamp ' - + 'x-edge-origin-auth and set the secret.', + `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.', ) } } diff --git a/server/utils/internal-headers.ts b/server/utils/internal-headers.ts index f3ea42dc5..7852893f9 100644 --- a/server/utils/internal-headers.ts +++ b/server/utils/internal-headers.ts @@ -1,11 +1,8 @@ import type { H3Event } from 'h3' +import { randomBytes } from 'node:crypto' import { EDGE_ORIGIN_AUTH_HEADER, - edgeHonorsInternalSentinel, INTERNAL_MARKER_HEADER, - INTERNAL_SENTINEL_HEADER, - INTERNAL_SENTINEL_VALUE, - parseEdgeProvider, } from '~/utils/edge-presets' import { timingSafeEqualStrings } from '~/server/utils/timing-safe' @@ -22,27 +19,24 @@ import { timingSafeEqualStrings } from '~/server/utils/timing-safe' * rate-limited (warm-cache issues at most ~240 requests per 5-min cycle * against a >=600/min-per-endpoint budget). * - * Two trust models, selected by whether EDGE_ORIGIN_SECRET is configured: + * 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.) * - * - Secret configured: internal fetches stamp the origin-auth header plus - * an internal marker header, both carrying the secret. External clients - * cannot forge the marker without knowing the secret, and the edge never - * stamps it (it should strip it from inbound traffic as defense-in-depth). - * This is the recommended mode for any deployment fronted by an edge. - * - * - No secret: fall back to a loopback sentinel in a header the edge - * overwrites in transit (see the sentinel constants and their rationale - * in utils/edge-presets.ts). The sentinel is only honoured under presets - * where that overwrite actually happens or where no edge-derived trust - * exists at all (`edgeHonorsInternalSentinel`); the remaining presets - * forward client headers untouched and therefore require - * EDGE_ORIGIN_SECRET — enforced at boot by edge-guard. SECURITY: even - * where honoured, the sentinel relies on the origin not being directly - * reachable — an attacker who bypasses the edge can spoof it to skip - * rate limiting AND geo-blocking. Configure EDGE_ORIGIN_SECRET to close - * that hole. Do not add these headers to anything that forwards user - * input into the downstream URL. + * 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. */ +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) { @@ -51,7 +45,7 @@ export function getInternalFetchHeaders(): Record { [INTERNAL_MARKER_HEADER]: secret, } } - return { [INTERNAL_SENTINEL_HEADER]: INTERNAL_SENTINEL_VALUE } + return { [INTERNAL_MARKER_HEADER]: PROCESS_INTERNAL_MARKER } } /** @@ -61,16 +55,6 @@ export function getInternalFetchHeaders(): Record { * See the trust model above. */ export function isInternalRequest(event: H3Event): boolean { - const secret = process.env.EDGE_ORIGIN_SECRET?.trim() - const headers = event.node.req.headers - if (secret) { - const marker = headers[INTERNAL_MARKER_HEADER] - return typeof marker === 'string' && timingSafeEqualStrings(marker, secret) - } - // Defense-in-depth backstop: presets that require the secret never honour - // the sentinel, even if a deployment somehow reaches this state. - if (!edgeHonorsInternalSentinel(parseEdgeProvider(process.env.EDGE_PROVIDER))) { - return false - } - return headers[INTERNAL_SENTINEL_HEADER] === INTERNAL_SENTINEL_VALUE + const marker = event.node.req.headers[INTERNAL_MARKER_HEADER] + return typeof marker === 'string' && timingSafeEqualStrings(marker, internalMarkerValue()) } diff --git a/tests/server/cors-internal-api.test.ts b/tests/server/cors-internal-api.test.ts index 4aac9e3fc..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,15 +309,37 @@ 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') diff --git a/tests/server/edge.test.ts b/tests/server/edge.test.ts index c533a9e47..29671b693 100644 --- a/tests/server/edge.test.ts +++ b/tests/server/edge.test.ts @@ -10,14 +10,15 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { H3Event } from 'h3' import { - edgeHonorsInternalSentinel, 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 @@ -92,11 +93,11 @@ describe('preset capabilities', () => { expect(edgeProvidesVpnEvidence('none')).toBe(false) }) - it('only presets whose edge overwrites (or never trusts) the sentinel header honour it', () => { - expect(edgeHonorsInternalSentinel('cloudflare')).toBe(true) - expect(edgeHonorsInternalSentinel('none')).toBe(true) - expect(edgeHonorsInternalSentinel('google')).toBe(false) - expect(edgeHonorsInternalSentinel('cloudfront')).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) }) }) @@ -247,7 +248,9 @@ describe('getEdgeContext', () => { }) it('flags internal requests', () => { - expect(getEdgeContext(eventWith({ 'cf-connecting-ip': '127.0.0.1' })).isInternal).toBe(true) + 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({ diff --git a/tests/server/geo-gate.test.ts b/tests/server/geo-gate.test.ts index 2fa618f74..dcc6aaf52 100644 --- a/tests/server/geo-gate.test.ts +++ b/tests/server/geo-gate.test.ts @@ -14,6 +14,7 @@ */ 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, @@ -117,13 +118,26 @@ describe('geo-gate log hygiene', () => { process.env.EDGE_PROVIDER = 'cloudflare' expect(() => - runHandler(makeEvent(`https://app.example/api/internal/proxy/merkl/users/${ADDRESS}/rewards`, { - 'cf-connecting-ip': '127.0.0.1', - })), + 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', () => { diff --git a/tests/server/internal-request.test.ts b/tests/server/internal-request.test.ts index b94b8d2f1..7c302e6db 100644 --- a/tests/server/internal-request.test.ts +++ b/tests/server/internal-request.test.ts @@ -4,10 +4,13 @@ * Server-internal $fetch calls (warm-cache, vaults-cache, etc.) do not * 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 headers that - * downstream middleware recognises via `isInternalRequest` to bypass those - * checks — a secret-based marker when EDGE_ORIGIN_SECRET is configured, a - * loopback sentinel otherwise. + * 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 headers stop being set, the helper stops * recognising them, or a middleware forgets to consult the helper — every @@ -40,9 +43,12 @@ afterEach(() => { } }) -describe('without EDGE_ORIGIN_SECRET (sentinel mode)', () => { - it('stamps the loopback sentinel', () => { - expect(getInternalFetchHeaders()).toEqual({ 'cf-connecting-ip': '127.0.0.1' }) +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('recognises requests decorated with getInternalFetchHeaders()', () => { @@ -51,36 +57,29 @@ describe('without EDGE_ORIGIN_SECRET (sentinel mode)', () => { expect(isInternalRequest(eventWithHeaders({ ...getInternalFetchHeaders() }))).toBe(true) }) - it('returns false when the sentinel is absent or different', () => { + it('rejects guessed, empty, or same-length marker values', () => { expect(isInternalRequest(eventWithHeaders({}))).toBe(false) - expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '203.0.113.1' }))).toBe(false) - expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '::1' }))).toBe(false) - expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '127.0.0.2' }))).toBe(false) - }) - - it('ignores the secret marker header entirely (nothing to verify it against)', () => { 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('honours the sentinel only under presets whose edge overwrites it (or the none preset)', () => { - const sentinel = eventWithHeaders({ 'cf-connecting-ip': '127.0.0.1' }) - - process.env.EDGE_PROVIDER = 'cloudflare' - expect(isInternalRequest(sentinel)).toBe(true) - process.env.EDGE_PROVIDER = 'none' - expect(isInternalRequest(sentinel)).toBe(true) - - // These edges forward client headers untouched — a forged sentinel must - // never grant internal status (they require EDGE_ORIGIN_SECRET at boot; - // this is the defense-in-depth backstop). - process.env.EDGE_PROVIDER = 'google' - expect(isInternalRequest(sentinel)).toBe(false) - process.env.EDGE_PROVIDER = 'cloudfront' - expect(isInternalRequest(sentinel)).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 (marker mode)', () => { +describe('with EDGE_ORIGIN_SECRET (shared-secret marker mode)', () => { beforeEach(() => { process.env.EDGE_ORIGIN_SECRET = 'shared-secret' }) @@ -102,7 +101,7 @@ describe('with EDGE_ORIGIN_SECRET (marker mode)', () => { expect(isInternalRequest(eventWithHeaders({ 'x-edge-internal': 'shared-secreX' }))).toBe(false) }) - it('no longer honours the loopback sentinel — spoofing it must not bypass gates', () => { + it('never honours the legacy loopback sentinel', () => { expect(isInternalRequest(eventWithHeaders({ 'cf-connecting-ip': '127.0.0.1' }))).toBe(false) }) diff --git a/tests/server/rate-limit.test.ts b/tests/server/rate-limit.test.ts index 6a78622b7..28ded3445 100644 --- a/tests/server/rate-limit.test.ts +++ b/tests/server/rate-limit.test.ts @@ -16,6 +16,7 @@ vi.mock('~/server/utils/logger', () => ({ })) const { createRateLimiter } = await import('~/server/utils/rate-limit') +const { getInternalFetchHeaders } = await import('~/server/utils/internal-headers') const eventWith = ( headers: Record, @@ -97,7 +98,7 @@ describe('trusted identity (cloudflare preset)', () => { it('never rate-limits internal server-to-server requests', () => { process.env.DOPPLER_ENVIRONMENT = 'prd' const limiter = makeLimiter(1) - const internal = () => eventWith({ 'cf-connecting-ip': '127.0.0.1' }) + const internal = () => eventWith({ ...getInternalFetchHeaders() }) expect(() => { for (let i = 0; i < 10; i++) limiter.consume(internal()) }).not.toThrow() @@ -123,6 +124,29 @@ describe('none preset', () => { 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', () => { diff --git a/utils/edge-presets.ts b/utils/edge-presets.ts index daec1a766..f81702d60 100644 --- a/utils/edge-presets.ts +++ b/utils/edge-presets.ts @@ -25,23 +25,15 @@ export type EdgeProvider = (typeof EDGE_PROVIDERS)[number] export const EDGE_ORIGIN_AUTH_HEADER = 'x-edge-origin-auth' /** - * Internal-fetch marker header: set (with the origin-auth secret as value) - * only by server-internal $fetch calls, never by the edge. Because the - * value is the secret itself, external clients cannot forge it. The edge - * should additionally strip this header from inbound traffic as + * 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' -/** - * No-secret internal sentinel. Deliberately reuses the cloudflare preset's - * trusted-IP header: that edge always overwrites it in transit, so external - * clients cannot smuggle the loopback value through the edge. See - * `server/utils/internal-headers.ts` for the full trust model. - */ -export const INTERNAL_SENTINEL_HEADER = 'cf-connecting-ip' -export const INTERNAL_SENTINEL_VALUE = '127.0.0.1' - type RawHeaders = Record /** Normalized trust inputs every preset reduces to. `null` = unmeasured. */ @@ -71,16 +63,16 @@ export function edgeProvidesVpnEvidence(provider: EdgeProvider): boolean { } /** - * Whether the no-secret loopback sentinel is a sound internal-request - * signal under this preset. Only true where the edge overwrites the - * sentinel header in transit (cloudflare) or where no edge-derived trust - * exists at all (none — forks, previews). The other presets forward - * client-supplied headers untouched, so an external caller could forge - * internal status with one header; they must authenticate internal fetches - * with EDGE_ORIGIN_SECRET instead (enforced at boot by edge-guard). + * 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 edgeHonorsInternalSentinel(provider: EdgeProvider): boolean { - return provider === 'cloudflare' || provider === 'none' +export function edgeRequiresOriginSecret(provider: EdgeProvider): boolean { + return provider === 'google' || provider === 'cloudfront' } /** From 84a8a1aba5fcab79f929a0f4cd52000c30c541ff Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Fri, 21 Aug 2026 16:05:01 +0200 Subject: [PATCH 11/16] fix: recorder V3 preflight authenticates as a first-party caller, not internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preflightV3Proxy still sent the retired loopback sentinel, which the new isInternalRequest deliberately ignores — the preflight 403'd at the CORS no-Origin rejection against non-dev servers. It now sends the app's own Origin (always in the CORS allowlist by construction) instead of trying to claim internal status, which is intentionally unavailable to external processes. A hygiene test keeps repo scripts free of the retired sentinel and the internal marker. --- scripts/execution-record.mjs | 12 ++++++++---- tests/server/internal-request.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) 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/tests/server/internal-request.test.ts b/tests/server/internal-request.test.ts index 7c302e6db..bf0e8b4ab 100644 --- a/tests/server/internal-request.test.ts +++ b/tests/server/internal-request.test.ts @@ -18,6 +18,8 @@ * shipped once (internal `/api/internal/vaults` → `/api/internal/euler-chains` * 451'd by geo-gate). Lock it down. */ +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 { getInternalFetchHeaders, isInternalRequest } from '~/server/utils/internal-headers' @@ -111,3 +113,20 @@ describe('with EDGE_ORIGIN_SECRET (shared-secret marker mode)', () => { 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([]) + }) +}) From 182086f6f7ccc02900976704a75952561453cb95 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Wed, 9 Sep 2026 19:33:37 +0100 Subject: [PATCH 12/16] fix: keep client positive VPN signal alongside edge-derived evidence --- services/vpn.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/vpn.ts b/services/vpn.ts index 83f498945..d85dc9d41 100644 --- a/services/vpn.ts +++ b/services/vpn.ts @@ -5,8 +5,8 @@ let cached: { value: boolean, 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 — the authoritative verdict is derived server-side from edge -// request headers, and the client-reported value is never trusted. +// noise — the server derives its verdict from edge request headers, and only +// a strict client `true` can add to it (never clear it). function edgeProvidesVpnEvidence(): boolean { if (typeof window === 'undefined') return false /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- server-injected window global */ From 9fb52a3c92c3cba2cb8bfdf32f691daf8e0ad38a Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Wed, 9 Sep 2026 22:39:55 +0100 Subject: [PATCH 13/16] fix: reject DEV_GEO_COUNTRY in production and document none/google preset trust limits - assertEdgeConfig refuses to boot prd with DEV_GEO_COUNTRY set: a synthetic country would let requests with an undetermined country skip the geo-gate's fail-closed 451 branch. - edge-guard warns at boot when prd opts into EDGE_PROVIDER=none; rate-limit and architecture docs state that none carries a forgeable identity. - Document that the google preset needs x-client-geo configured as an LB custom request header ({client_region}); origin auth does not prove the LB wrote it. - stripPort only removes a numeric trailing segment when the remainder is a well-formed address, so bare IPv6 viewer addresses keep their last hextet. - Qualify the geo-blocking doc's anti-spoofing claim (edge-only origin or origin auth). --- .env.example | 8 ++++++-- README.md | 2 +- docs/architecture.md | 10 +++++---- docs/geo-blocking.md | 4 ++-- server/plugins/edge-guard.ts | 21 +++++++++++-------- server/utils/edge.ts | 21 ++++++++++++++----- server/utils/rate-limit.ts | 7 +++++++ tests/server/edge.test.ts | 31 ++++++++++++++++++++++++++++ utils/edge-presets.ts | 40 +++++++++++++++++++++++++++++++++--- 9 files changed, 119 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 2d1b92522..cbf19192a 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,10 @@ DOPPLER_ENVIRONMENT=dev # 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 @@ -26,8 +30,8 @@ EDGE_PROVIDER= 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 — do -# not set in production. +# 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/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/docs/architecture.md b/docs/architecture.md index 7af1f420a..fdb1f5c5a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -370,11 +370,13 @@ The server never reads vendor edge headers directly. `getEdgeContext(event)` (`s | 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 header) | — | +| `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 | — | — | -**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. +**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. @@ -384,11 +386,11 @@ Fail-closed behaviour per environment (geo-capable presets): | Environment | Geo-gate | Rate limiter | |---|---|---| -| `prd` | Country required; fail-closed (HTTP 451) if undetermined. `DEV_GEO_COUNTRY` bypasses fail-closed if set. | Trusted identity required; fail-closed (HTTP 403) if absent. | +| `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. +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 c7983415d..53937f3ad 100644 --- a/docs/geo-blocking.md +++ b/docs/geo-blocking.md @@ -51,7 +51,7 @@ When both collateral AND borrow vault in a pair are restricted, the pair is trea The user's country is detected by sending a `HEAD` request to the application's origin and reading the `x-country-code` response header. The result is normalized to uppercase ISO 3166-1 alpha-2 (e.g. `US`, `DE`, `GB`). -The `x-country-code` response header is set by `server/middleware/cors.ts`, which reads 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`). Clients cannot modify the edge-set header, and any client-supplied `x-country-code` request header is stripped by `cors.ts` before processing, preventing bypass. Under the `none` preset (no edge — forks, previews) the placeholder `--` is emitted so client-side checks don't fail closed. +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. @@ -503,7 +503,7 @@ All blocking and restriction configuration lives outside the app codebase: | 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 | | 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); do not set 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. diff --git a/server/plugins/edge-guard.ts b/server/plugins/edge-guard.ts index f8938da78..b659c92b5 100644 --- a/server/plugins/edge-guard.ts +++ b/server/plugins/edge-guard.ts @@ -13,12 +13,17 @@ import { logger } from '~/server/utils/logger' export default defineNitroPlugin(() => { assertEdgeConfig() - logger.info( - { - ctx: 'edge-guard', - edgeProvider: parseEdgeProvider(process.env.EDGE_PROVIDER), - originAuth: process.env.EDGE_ORIGIN_SECRET?.trim() ? 'enforced' : 'off', - }, - 'edge provider configuration resolved', - ) + 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/utils/edge.ts b/server/utils/edge.ts index fff6caa7a..ce694361a 100644 --- a/server/utils/edge.ts +++ b/server/utils/edge.ts @@ -64,9 +64,10 @@ export function getEdgeContext(event: H3Event): EdgeContext { const inputs = extractEdgeInputs(provider, headers, event.node.req.socket?.remoteAddress) return { ...inputs, - // DEV_GEO_COUNTRY injects a country regardless of environment so - // deployments without a geo-capable edge (local dev, PR previews) are - // not universally fail-closed. Do not set it in production. + // 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, @@ -82,11 +83,14 @@ export function getEdgeContext(event: H3Event): EdgeContext { * 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`). + * 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) - if (process.env.DOPPLER_ENVIRONMENT === 'prd' && !process.env.EDGE_PROVIDER?.trim()) { + 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. ' @@ -102,4 +106,11 @@ export function assertEdgeConfig(): void { + '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/rate-limit.ts b/server/utils/rate-limit.ts index 1923dcc58..129c60e36 100644 --- a/server/utils/rate-limit.ts +++ b/server/utils/rate-limit.ts @@ -29,6 +29,13 @@ interface RateLimiterConfig { // 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). // +// 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. // diff --git a/tests/server/edge.test.ts b/tests/server/edge.test.ts index 29671b693..0c0410d01 100644 --- a/tests/server/edge.test.ts +++ b/tests/server/edge.test.ts @@ -164,6 +164,23 @@ describe('extractEdgeInputs — cloudfront', () => { }, 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() }) @@ -291,6 +308,20 @@ describe('assertEdgeConfig', () => { 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/) diff --git a/utils/edge-presets.ts b/utils/edge-presets.ts index f81702d60..34eeb5f9d 100644 --- a/utils/edge-presets.ts +++ b/utils/edge-presets.ts @@ -123,11 +123,33 @@ function deriveVpnEvidence(headers: RawHeaders): boolean | null { return isTruthyHeader(vpn) || isTruthyHeader(proxyOrVpn) } -// cloudfront-viewer-address is always ":", IPv6 included -// (e.g. "2001:db8::1:41768"), so stripping after the last colon is safe. +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(':') - return separator === -1 ? address : address.slice(0, separator) + 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( @@ -147,6 +169,15 @@ export function extractEdgeInputs( // 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, @@ -166,6 +197,9 @@ export function extractEdgeInputs( // 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, From e0ee9a43c42efaab6b2687f8b32c6182b6a96f35 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Wed, 9 Sep 2026 22:39:57 +0100 Subject: [PATCH 14/16] refactor: type window.__APP_CONFIG__ with the shared EnvConfig shape --- composables/useEnvConfig.ts | 15 ++++++++++----- services/vpn.ts | 3 +-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/composables/useEnvConfig.ts b/composables/useEnvConfig.ts index dc60b8ba9..232bb88d6 100644 --- a/composables/useEnvConfig.ts +++ b/composables/useEnvConfig.ts @@ -24,7 +24,7 @@ import { } from '~/utils/announcement-config' import { edgeProvidesVpnEvidence, parseEdgeProvider } from '~/utils/edge-presets' -interface EnvConfig { +export interface EnvConfig { appTitle: string appDescription: string logoUrl: string @@ -48,6 +48,13 @@ interface EnvConfig { vpnDetection: boolean } +declare global { + interface Window { + /** Server-injected runtime config (server/plugins/app-config.ts). */ + __APP_CONFIG__?: EnvConfig + } +} + const DEFAULTS: EnvConfig = { appTitle: 'Euler Lite', appDescription: 'Lightweight interface for Euler Finance lending and borrowing.', @@ -149,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/services/vpn.ts b/services/vpn.ts index d85dc9d41..4169c2291 100644 --- a/services/vpn.ts +++ b/services/vpn.ts @@ -9,8 +9,7 @@ let cached: { value: boolean, timestamp: number } | null = null // a strict client `true` can add to it (never clear it). function edgeProvidesVpnEvidence(): boolean { if (typeof window === 'undefined') return false - /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- server-injected window global */ - return (window as any).__APP_CONFIG__?.vpnDetection === true + return window.__APP_CONFIG__?.vpnDetection === true } export async function detectVpn(): Promise { From cd56ec352855b7e870a0bed20d99f121bfd02434 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:21:59 +0000 Subject: [PATCH 15/16] chore(deps): bump js-yaml from 4.3.1 to 4.3.2 Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.1 to 4.3.2. - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...4.3.2) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b136e406..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", From 40834da9f0fbc8904962466df1c69d94ce587f5a Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:19:08 +0100 Subject: [PATCH 16/16] fix: keep VPN usage as audit metadata Continue address screening for VPN users and record unavailable VPN measurements as unknown. Preserve country restrictions and fail-closed address screening. --- composables/useAddressScreen.ts | 10 +---- docs/architecture.md | 2 +- services/screening.ts | 2 +- services/vpn.ts | 17 +++++---- tests/composables/useAddressScreen.test.ts | 16 ++++---- tests/services/screening.test.ts | 10 +++-- tests/services/vpn.test.ts | 43 +++++++++++++++++++--- 7 files changed, 64 insertions(+), 36 deletions(-) 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/docs/architecture.md b/docs/architecture.md index fdb1f5c5a..2a6e8ddad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -357,7 +357,7 @@ 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: 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 4169c2291..c6f29062f 100644 --- a/services/vpn.ts +++ b/services/vpn.ts @@ -1,20 +1,20 @@ 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 — the server derives its verdict from edge request headers, and only -// a strict client `true` can add to it (never clear it). +// 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 { +export async function detectVpn(): Promise { if (!edgeProvidesVpnEvidence()) { - return false + return null } if (cached !== null && Date.now() - cached.timestamp < CACHE_TTL_5MIN_MS) { @@ -26,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/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 fba481e6c..6d78fada6 100644 --- a/tests/services/vpn.test.ts +++ b/tests/services/vpn.test.ts @@ -18,17 +18,48 @@ describe('detectVpn', () => { vi.unstubAllGlobals() }) - it('reads the VPN edge header', async () => { + 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('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('fails closed when VPN detection stalls', async () => { + it('records a timeout as unknown', async () => { vi.useFakeTimers() stubWindow(true) vi.stubGlobal('fetch', vi.fn((_url: string, init?: RequestInit) => @@ -41,7 +72,7 @@ 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 () => { @@ -50,7 +81,7 @@ describe('detectVpn', () => { for (const vpnDetection of [false, undefined] as const) { stubWindow(vpnDetection) - await expect(detectVpn()).resolves.toBe(false) + await expect(detectVpn()).resolves.toBeNull() } expect(fetchMock).not.toHaveBeenCalled() })