From 8ef7c3feef50d35b0aca799317692a11baffb9c4 Mon Sep 17 00:00:00 2001 From: "Ibrahim Bamidele. S" <238790935+bamiebot-maker@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:50:35 +0100 Subject: [PATCH 1/2] fix: split CORS policy across public, credentialed, and admin routes Closes #268 - Implement dynamic CORS delegator classifying routes into public, credentialed, and admin tiers - Keep public reads open to any origin with explicit credentials: false - Restrict credentialed routes (/usage/me and requests carrying auth credentials) to CORS_ALLOWED_ORIGINS with credentials: true - Close operator admin endpoints (/admin/*) against cross-origin browser requests by default - Document route security matrix and CORS policies in docs/routes.md and README.md - Add unit and integration tests covering allowed and refused origins on each class --- .env.example | 7 + README.md | 2 + docs/routes.md | 88 ++++++++++ src/__tests__/cors.test.ts | 323 +++++++++++++++++++++++++++++++++++++ src/app.ts | 7 +- src/cors.ts | 266 ++++++++++++++++++++++++++++++ 6 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 docs/routes.md create mode 100644 src/__tests__/cors.test.ts create mode 100644 src/cors.ts diff --git a/.env.example b/.env.example index d37f04a6..e7d733ea 100644 --- a/.env.example +++ b/.env.example @@ -119,6 +119,13 @@ REQUIRE_API_KEY=true # Fallback rate limit (requests per minute per IP) for unauthenticated or public requests (default: 100) RATE_LIMIT_IP_MAX=100 +# Comma-separated list of trusted web origins permitted to call credentialed routes (/usage/me). +# Empty/unset refuses cross-origin requests on credentialed routes. +CORS_ALLOWED_ORIGINS=https://app.lens.stellar.org,http://localhost:3000 +# Comma-separated list of web origins permitted to call operator /admin/* routes. +# Default empty: closed to cross-origin browsers. +CORS_ADMIN_ALLOWED_ORIGINS= + # --- x402 Payment Gate --- # Stellar public key where API payments should be sent. # If unset, x402 gating is disabled. diff --git a/README.md b/README.md index 2201d343..4b182451 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ missing from the spec. Operator-only and non-HTTP routes (`/admin/keys*`, `/admin/usage*`, `/metrics`, `/ws`, `/graphiql*`) are deliberately excluded via the commented allow-list in [`src/openapi/coverage.ts`](src/openapi/coverage.ts). +All routes are partitioned into three security and CORS tiers (`public`, `credentialed`, and `admin`) with a split CORS policy — see the [Route Classification & CORS Guide](docs/routes.md). + The `?network=testnet\|mainnet` query param (or `x-network` header) selects the Stellar network. It is validated on every request — an unrecognised value gets a `400` — and defaults to this instance's `STELLAR_NETWORK`. Only the routes whose diff --git a/docs/routes.md b/docs/routes.md new file mode 100644 index 00000000..d54a0ca9 --- /dev/null +++ b/docs/routes.md @@ -0,0 +1,88 @@ +# Route Classification and Security Architecture + +Lens categorizes its API routes into three explicit security and access tiers: +1. **Public Reads (`public`)** +2. **Credentialed Routes (`credentialed`)** +3. **Operator Admin Routes (`admin`)** + +This classification governs Cross-Origin Resource Sharing (CORS), rate limiting, authentication enforcement, and OpenAPI spec publication. + +--- + +## Route Classification Matrix + +| HTTP Method | Route Template | Classification | Authentication / Gate | CORS Policy | Description | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **GET** | `/status` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | System and ingestion lag health check | +| **GET** | `/metrics` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Prometheus scrape endpoint (text exposition) | +| **GET** | `/price/:assetA/:assetB` | `public` | Optional x402 Micropayment | Open to any origin (`origin: true`, `credentials: false`) | Unified SDEX+AMM price and VWAP | +| **GET** | `/price/:assetA/:assetB/route` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Best execution routing | +| **GET** | `/price/:assetA/:assetB/depth` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Simulated order-book depth and slippage | +| **GET** | `/price/:assetA/:assetB/history`| `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | OHLCV history buckets | +| **GET** | `/price/twap/:assetA/:assetB` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Time-weighted average price (TWAP) | +| **GET** | `/price/vwap/:assetA/:assetB` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Volume-weighted average price (VWAP) | +| **GET** | `/candles/:assetA/:assetB` | `public` | Optional x402 Micropayment | Open to any origin (`origin: true`, `credentials: false`) | OHLCV candlestick time series | +| **GET** | `/prices/history` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Historical 1-minute price snapshots | +| **GET** | `/pools` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Active AMM liquidity pools | +| **GET** | `/pairs` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Indexed Stellar trading pairs | +| **GET** | `/volumes/:asset` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Cross-venue traded volume statistics | +| **GET** | `/spreads/:asset` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Bid/ask spreads across venues | +| **GET** | `/compare/:asset` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Multi-venue & oracle deviation comparison | +| **GET** | `/screener` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Top markets ranked by volume/liquidity | +| **GET** | `/benchmark/:asset` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Asset peg stability & tracking | +| **GET** | `/basket` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Multi-asset weighted index price | +| **GET** | `/discovery/resources` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Bazaar catalog of discoverable x402 resources | +| **GET** | `/supported` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | Supported x402 payment schemes & networks | +| **POST**| `/verify` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | x402 payment verification | +| **POST**| `/settle` | `public` | None (Public) | Open to any origin (`origin: true`, `credentials: false`) | x402 payment settlement (idempotent) | +| **POST**| `/webhooks` | `public`* | None (Public by default) | Open to any origin (`origin: true`, `credentials: false`) | Subscribe to price threshold notifications | +| **DELETE**|`/webhooks/:id` | `public`* | None (Public by default) | Open to any origin (`origin: true`, `credentials: false`) | Cancel webhook subscription | +| **POST**| `/graphql` | `public` | None (Public query resolver) | Open to any origin (`origin: true`, `credentials: false`) | GraphQL query endpoint | +| **GET** | `/graphiql` | `public` | None (Internal UI) | Open to any origin (`origin: true`, `credentials: false`) | Interactive GraphiQL IDE | +| **GET** | `/ws` | `public` | Optional x402 Header | Open to any origin (`origin: true`, `credentials: false`) | WebSocket real-time price feed | +| **GET** | `/usage/me` | `credentialed` | `Authorization: Bearer ` | Restricted to `CORS_ALLOWED_ORIGINS`, `credentials: true` | Calling key's usage, quotas, and limits | +| **ANY** | Any endpoint with `Authorization` | `credentialed` | API key or Bearer token | Restricted to `CORS_ALLOWED_ORIGINS`, `credentials: true` | Dynamic narrowing when caller credentials are sent | +| **POST**| `/admin/keys` | `admin` | `X-Admin-Token` | Closed by default (`origin: false`, `credentials: false`) | Mint new API keys (returns plaintext key once) | +| **DELETE**|`/admin/keys/:id` | `admin` | `X-Admin-Token` | Closed by default (`origin: false`, `credentials: false`) | Revoke existing API keys | +| **GET** | `/admin/usage` | `admin` | `X-Admin-Token` | Closed by default (`origin: false`, `credentials: false`) | Operator usage report for all API keys | +| **GET** | `/admin/usage/:keyId` | `admin` | `X-Admin-Token` | Closed by default (`origin: false`, `credentials: false`) | Operator usage report for specific key | + +\* *Note: If `REQUIRE_API_KEY=true` is enabled, non-exempt routes inherit API key verification requirements and dynamically adopt credentialed CORS protections.* + +--- + +## CORS Security Model + +### 1. Public Reads (`public`) +- **Intent**: Public financial data, order book states, oracle rates, and service discovery are intentionally world-readable. DApps, browser extensions, block explorers, and analytics tools running on any domain must be able to query these endpoints without friction. +- **Access-Control-Allow-Origin**: Reflects the requesting origin (or `*`), allowing universal browser access. +- **Access-Control-Allow-Credentials**: Explicitly omitted / `false`. Browsers will strictly deny sharing sensitive cookies or ambient authentication with cross-origin callers. + +### 2. Credentialed Routes (`credentialed`) +- **Intent**: Endpoints that expose account-level information, usage quotas, or billing history (such as `/usage/me`) require strict boundary control. +- **Access-Control-Allow-Origin**: Checked against the configured `CORS_ALLOWED_ORIGINS` environment variable (e.g. `https://app.lens.stellar.org,http://localhost:3000`). If the origin matches, it is allowed; otherwise, cross-origin access is refused (no `Access-Control-Allow-Origin` header emitted). +- **Access-Control-Allow-Credentials**: Explicitly set to `true` for permitted origins. + +### 3. Admin Routes (`admin`) +- **Intent**: Key issuance, key revocation, and system-wide usage metrics are restricted to infrastructure operators via `ADMIN_TOKEN`. +- **Policy**: Closed to cross-origin browsers by default (`origin: false`). +- **Access-Control-Allow-Origin**: None. Browser preflight requests return `404` and actual requests lack CORS headers, preventing malicious or CSRF-style browser interactions. +- **Operator Overrides**: If operators use an internal dashboard, authorized origins can be explicitly whitelisted using `CORS_ADMIN_ALLOWED_ORIGINS`. + +--- + +## Configuration + +Add the following environment variables to `.env` as needed: + +```bash +# Comma-separated list of trusted web app origins permitted to call credentialed routes (/usage/me) +CORS_ALLOWED_ORIGINS=https://app.lens.stellar.org,https://portal.lens.stellar.org,http://localhost:3000 + +# Optional: Comma-separated list of internal admin origins permitted to call /admin/* endpoints +# Leave unset or empty to close all cross-origin browser access to admin routes (recommended) +CORS_ADMIN_ALLOWED_ORIGINS= + +# Optional: Comma-separated list of blocked origins to deny even on public routes +CORS_PUBLIC_BLOCKED_ORIGINS= +``` diff --git a/src/__tests__/cors.test.ts b/src/__tests__/cors.test.ts new file mode 100644 index 00000000..5d8b49cf --- /dev/null +++ b/src/__tests__/cors.test.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { FastifyInstance } from 'fastify' + +vi.mock('../db', () => ({ + pgPool: { + query: vi.fn().mockResolvedValue({ + rows: [{ last_ledger: 1000, last_time: new Date() }], + }), + }, + prisma: { + apiKey: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, +})) + +import { buildApp } from '../app' +import { + classifyRoute, + parseOrigins, + isOriginAllowed, + ADMIN_ROUTES, + CREDENTIALED_ROUTES, + PUBLIC_ROUTES, +} from '../cors' + +describe('CORS route classification', () => { + it('classifies admin paths as admin', () => { + expect(classifyRoute('/admin/keys')).toBe('admin') + expect(classifyRoute('/admin/keys/123')).toBe('admin') + expect(classifyRoute('/admin/usage')).toBe('admin') + expect(classifyRoute('/admin/usage/key-1')).toBe('admin') + expect(classifyRoute('/admin/keys?foo=bar')).toBe('admin') + }) + + it('classifies /usage/me as credentialed', () => { + expect(classifyRoute('/usage/me')).toBe('credentialed') + expect(classifyRoute('/usage/me?network=testnet')).toBe('credentialed') + }) + + it('classifies requests carrying Authorization headers as credentialed', () => { + expect(classifyRoute('/status', { authorization: 'Bearer lens_123' })).toBe('credentialed') + expect(classifyRoute('/price/XLM/USDC', { authorization: 'Bearer lens_123' })).toBe('credentialed') + }) + + it('classifies preflight requesting Authorization header as credentialed', () => { + expect( + classifyRoute('/price/XLM/USDC', { + 'access-control-request-headers': 'Content-Type, Authorization', + }) + ).toBe('credentialed') + }) + + it('classifies public read routes as public', () => { + expect(classifyRoute('/status')).toBe('public') + expect(classifyRoute('/metrics')).toBe('public') + expect(classifyRoute('/price/XLM/USDC')).toBe('public') + expect(classifyRoute('/candles/XLM/USDC')).toBe('public') + expect(classifyRoute('/discovery/resources')).toBe('public') + expect(classifyRoute('/supported')).toBe('public') + }) + + it('exports documented route constants', () => { + expect(ADMIN_ROUTES).toContain('/admin/keys') + expect(CREDENTIALED_ROUTES).toContain('/usage/me') + expect(PUBLIC_ROUTES).toContain('/status') + expect(PUBLIC_ROUTES).toContain('/price/{assetA}/{assetB}') + }) +}) + +describe('parseOrigins and isOriginAllowed', () => { + it('parses comma-separated origins', () => { + expect(parseOrigins('https://example.com, https://lens.org')).toEqual([ + 'https://example.com', + 'https://lens.org', + ]) + expect(parseOrigins('')).toEqual([]) + expect(parseOrigins(undefined)).toEqual([]) + }) + + it('matches exact origins case-insensitively', () => { + const allowed = ['https://App.Lens.Org', 'http://localhost:3000'] + expect(isOriginAllowed('https://app.lens.org', allowed)).toBe(true) + expect(isOriginAllowed('http://localhost:3000', allowed)).toBe(true) + expect(isOriginAllowed('https://evil.com', allowed)).toBe(false) + }) + + it('handles wildcard origins', () => { + expect(isOriginAllowed('https://any.com', ['*'])).toBe(true) + expect(isOriginAllowed('https://sub.lens.org', ['*.lens.org'])).toBe(true) + expect(isOriginAllowed('https://other.com', ['*.lens.org'])).toBe(false) + }) +}) + +describe('Split CORS policy integration', () => { + let app: FastifyInstance + const ORIGINAL_ENV = { ...process.env } + + beforeEach(() => { + process.env.ADMIN_TOKEN = 'test-admin-secret' + process.env.CORS_ALLOWED_ORIGINS = 'https://app.lens.org,https://portal.lens.org' + }) + + afterEach(async () => { + await app?.close() + process.env = { ...ORIGINAL_ENV } + }) + + describe('Public reads class', () => { + it('allows any origin on public reads with credentials: false', async () => { + app = await buildApp() + await app.ready() + + const res = await app.inject({ + method: 'GET', + url: '/status', + headers: { + origin: 'https://random-third-party-dapp.xyz', + }, + }) + + expect(res.statusCode).toBe(200) + expect(res.headers['access-control-allow-origin']).toBe('https://random-third-party-dapp.xyz') + // Must NOT include credentials header on public reads + expect(res.headers['access-control-allow-credentials']).toBeUndefined() + }) + + it('handles preflight OPTIONS on public reads', async () => { + app = await buildApp() + await app.ready() + + const res = await app.inject({ + method: 'OPTIONS', + url: '/price/XLM/USDC', + headers: { + origin: 'https://analytics-dashboard.com', + 'access-control-request-method': 'GET', + }, + }) + + expect(res.statusCode).toBe(204) + expect(res.headers['access-control-allow-origin']).toBe('https://analytics-dashboard.com') + expect(res.headers['access-control-allow-credentials']).toBeUndefined() + }) + + it('refuses blocked origins on public reads when configured', async () => { + app = await buildApp({ + corsConfig: { + publicBlockedOrigins: ['https://malicious-scam.com'], + }, + }) + await app.ready() + + const res = await app.inject({ + method: 'GET', + url: '/status', + headers: { + origin: 'https://malicious-scam.com', + }, + }) + + expect(res.statusCode).toBe(200) + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + }) + + describe('Credentialed routes class', () => { + it('allows permitted origin on /usage/me with credentials: true', async () => { + app = await buildApp() + await app.ready() + + const res = await app.inject({ + method: 'GET', + url: '/usage/me', + headers: { + origin: 'https://app.lens.org', + }, + }) + + // Status is 401 (missing API key), but CORS headers are evaluated + expect(res.headers['access-control-allow-origin']).toBe('https://app.lens.org') + expect(res.headers['access-control-allow-credentials']).toBe('true') + }) + + it('refuses unpermitted origin on /usage/me', async () => { + app = await buildApp() + await app.ready() + + const res = await app.inject({ + method: 'GET', + url: '/usage/me', + headers: { + origin: 'https://untrusted-origin.org', + }, + }) + + expect(res.headers['access-control-allow-origin']).toBeUndefined() + expect(res.headers['access-control-allow-credentials']).toBeUndefined() + }) + + it('allows preflight OPTIONS on credentialed route for permitted origin', async () => { + app = await buildApp() + await app.ready() + + const res = await app.inject({ + method: 'OPTIONS', + url: '/usage/me', + headers: { + origin: 'https://portal.lens.org', + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'authorization', + }, + }) + + expect(res.statusCode).toBe(204) + expect(res.headers['access-control-allow-origin']).toBe('https://portal.lens.org') + expect(res.headers['access-control-allow-credentials']).toBe('true') + }) + + it('refuses preflight OPTIONS on credentialed route for unpermitted origin', async () => { + app = await buildApp() + await app.ready() + + const res = await app.inject({ + method: 'OPTIONS', + url: '/usage/me', + headers: { + origin: 'https://attacker.com', + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'authorization', + }, + }) + + // Unpermitted origin preflight is rejected without CORS allow headers + expect(res.headers['access-control-allow-origin']).toBeUndefined() + expect(res.headers['access-control-allow-credentials']).toBeUndefined() + }) + + it('narrows policy when credentials are supplied to public routes', async () => { + app = await buildApp() + await app.ready() + + // Request carrying Authorization header from an untrusted origin + const res = await app.inject({ + method: 'GET', + url: '/status', + headers: { + origin: 'https://untrusted-origin.org', + authorization: 'Bearer lens_key', + }, + }) + + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + }) + + describe('Admin routes class', () => { + it('refuses cross-origin requests to /admin/keys by default (closed policy)', async () => { + app = await buildApp() + await app.ready() + + const res = await app.inject({ + method: 'POST', + url: '/admin/keys', + headers: { + origin: 'https://attacker.com', + 'content-type': 'application/json', + }, + payload: { label: 'malicious key' }, + }) + + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + + it('refuses preflight OPTIONS to /admin/usage by default', async () => { + app = await buildApp() + await app.ready() + + const res = await app.inject({ + method: 'OPTIONS', + url: '/admin/usage', + headers: { + origin: 'https://any-site.com', + 'access-control-request-method': 'GET', + }, + }) + + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + + it('allows origin when CORS_ADMIN_ALLOWED_ORIGINS is explicitly configured', async () => { + app = await buildApp({ + corsConfig: { + adminAllowedOrigins: ['https://internal-admin.lens.org'], + }, + }) + await app.ready() + + const allowedRes = await app.inject({ + method: 'GET', + url: '/admin/usage', + headers: { + origin: 'https://internal-admin.lens.org', + }, + }) + + expect(allowedRes.headers['access-control-allow-origin']).toBe('https://internal-admin.lens.org') + expect(allowedRes.headers['access-control-allow-credentials']).toBeUndefined() + + const refusedRes = await app.inject({ + method: 'GET', + url: '/admin/usage', + headers: { + origin: 'https://other-internal.org', + }, + }) + + expect(refusedRes.headers['access-control-allow-origin']).toBeUndefined() + }) + }) +}) diff --git a/src/app.ts b/src/app.ts index da11e200..c145d336 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,6 +26,7 @@ import { registerBasketRoutes } from './routes/basket' import { registerDiscoveryRoutes } from './routes/discovery' import { registerSettleRoute } from './routes/facilitator' import { getMetrics } from './metrics' +import { createCorsDelegator, type CorsConfig } from './cors' export interface BuildAppOptions { /** @@ -36,6 +37,10 @@ export interface BuildAppOptions { * routes — a duplicated list would drift and defeat the point. */ onRoute?: (routeOptions: RouteOptions) => void + /** + * Optional CORS configuration override (useful in testing). + */ + corsConfig?: CorsConfig } /** @@ -58,7 +63,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise o.trim()) + .filter(o => o.length > 0) +} + +/** + * Resolves the configured allowed origins for credentialed routes. + */ +export function getCredentialedAllowedOrigins(config?: CorsConfig): string[] { + if (config?.credentialedAllowedOrigins !== undefined) { + return config.credentialedAllowedOrigins + } + return parseOrigins(process.env.CORS_ALLOWED_ORIGINS) +} + +/** + * Resolves the configured allowed origins for admin routes. + */ +export function getAdminAllowedOrigins(config?: CorsConfig): string[] { + if (config?.adminAllowedOrigins !== undefined) { + return config.adminAllowedOrigins + } + return parseOrigins(process.env.CORS_ADMIN_ALLOWED_ORIGINS) +} + +/** + * Resolves the configured blocked origins for public routes. + */ +export function getPublicBlockedOrigins(config?: CorsConfig): string[] { + if (config?.publicBlockedOrigins !== undefined) { + return config.publicBlockedOrigins + } + return parseOrigins(process.env.CORS_PUBLIC_BLOCKED_ORIGINS) +} + +/** + * Checks if a given origin matches any of the allowed origins. + */ +export function isOriginAllowed(origin: string, allowedOrigins: readonly string[]): boolean { + if (allowedOrigins.length === 0) return false + const normalizedOrigin = origin.trim().toLowerCase() + return allowedOrigins.some(allowed => { + const norm = allowed.trim().toLowerCase() + if (norm === '*' || norm === normalizedOrigin) return true + if (norm.startsWith('*.') || norm.startsWith('http://*.') || norm.startsWith('https://*.')) { + const domainPart = norm.replace(/^https?:\/\/\*\./, '').replace(/^\*\./, '') + try { + const u = new URL(origin) + return u.hostname === domainPart || u.hostname.endsWith(`.${domainPart}`) + } catch { + return false + } + } + return false + }) +} + +/** + * Classifies a request path and headers into one of three security classes: + * - `admin`: /admin/* routes + * - `credentialed`: /usage/me or requests carrying Authorization / Cookie credentials + * - `public`: unauthenticated reads and open endpoints + */ +export function classifyRoute( + path: string, + headers?: Record +): RouteClass { + const normalizedPath = (path || '/').split('?')[0].replace(/\/+$/, '') || '/' + + // 1. Admin endpoints + if (normalizedPath === '/admin' || normalizedPath.startsWith('/admin/')) { + return 'admin' + } + + // 2. Credentialed endpoints + if (normalizedPath === '/usage/me' || normalizedPath.startsWith('/usage/')) { + return 'credentialed' + } + + // Requests carrying credentials in headers + if (headers) { + const authHeader = headers['authorization'] + const cookieHeader = headers['cookie'] + if (authHeader || cookieHeader) { + return 'credentialed' + } + + const requestHeaders = headers['access-control-request-headers'] + if (typeof requestHeaders === 'string') { + const lower = requestHeaders.toLowerCase() + if (lower.includes('authorization') || lower.includes('cookie')) { + return 'credentialed' + } + } + } + + // 3. Public endpoints + return 'public' +} + +/** + * Evaluates the CORS configuration for an incoming request based on its classification. + */ +export function getCorsOptionsForRequest( + req: FastifyRequest, + config?: CorsConfig +): FastifyCorsOptions { + const origin = req.headers.origin + const routeClass = classifyRoute(req.url, req.headers) + + if (routeClass === 'admin') { + // Admin routes: closed by default unless explicitly configured + const adminAllowed = getAdminAllowedOrigins(config) + if (origin && adminAllowed.length > 0 && isOriginAllowed(origin, adminAllowed)) { + return { + origin: origin, + credentials: false, + methods: ['GET', 'HEAD', 'POST', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'X-Admin-Token', 'Authorization'], + } + } + return { + origin: false, + credentials: false, + } + } + + if (routeClass === 'credentialed') { + // Credentialed routes: restricted to allowed origins + const allowed = getCredentialedAllowedOrigins(config) + if (origin && isOriginAllowed(origin, allowed)) { + return { + origin: origin, + credentials: true, + methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'X-Admin-Token', + 'x-network', + 'X-PAYMENT', + ], + } + } + return { + origin: false, + credentials: false, + } + } + + // Public reads: open to any origin + const blocked = getPublicBlockedOrigins(config) + if (origin && blocked.length > 0 && isOriginAllowed(origin, blocked)) { + return { + origin: false, + credentials: false, + } + } + + return { + origin: true, + credentials: false, + methods: ['GET', 'HEAD', 'POST', 'OPTIONS'], + allowedHeaders: [ + 'Content-Type', + 'x-network', + 'X-PAYMENT', + ], + } +} + +/** + * Creates the delegator function for `@fastify/cors`. + */ +export function createCorsDelegator(config?: CorsConfig) { + return async (req: FastifyRequest): Promise => { + return getCorsOptionsForRequest(req, config) + } +} From 1794c8471825700f69b6b002871a863061180d66 Mon Sep 17 00:00:00 2001 From: "Ibrahim Bamidele. S" <238790935+bamiebot-maker@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:40:05 +0100 Subject: [PATCH 2/2] docs(env): document CORS_PUBLIC_BLOCKED_ORIGINS in .env.example --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index e7d733ea..6b5a11c8 100644 --- a/.env.example +++ b/.env.example @@ -125,6 +125,9 @@ CORS_ALLOWED_ORIGINS=https://app.lens.stellar.org,http://localhost:3000 # Comma-separated list of web origins permitted to call operator /admin/* routes. # Default empty: closed to cross-origin browsers. CORS_ADMIN_ALLOWED_ORIGINS= +# Comma-separated list of web origins explicitly blocked from calling public routes (/price, /health, /openapi.json). +# Empty/unset means all public cross-origin requests are permitted (* without credentials). +CORS_PUBLIC_BLOCKED_ORIGINS= # --- x402 Payment Gate --- # Stellar public key where API payments should be sent.