Docs / Security and data

API response headers and outbound request guard

The security headers every API answer carries, and the guard that stops the API calling private addresses on behalf of a user-supplied URL.

Two small controls on the NestJS API (apps/services). Code: shared/security-headers.ts, shared/ssrf-guard.ts, wired in bootstrap.ts (configureApp, shared by main.ts and the integration harness).

Response headers#

Every response, including errors, redirects and the root-level OAuth discovery documents, carries:

HeaderValueWhy
X-Content-Type-OptionsnosniffA JSON or download answer is never sniffed into HTML.
Referrer-Policyno-referrerToken-bearing URLs (reference and signing links) are not leaked from API-served pages.
X-Frame-Options, Content-Security-PolicyDENY, frame-ancestors 'none'The API is never framed. Only frame-ancestors is set; the API serves no scripted pages.
Cross-Origin-Resource-Policysame-site; cross-origin under /.well-known/The web app and the API share a site. The discovery documents must be readable by any MCP client.
Cache-Controlno-storeDefault for every answer. A route that sets its own value (@Header(...)) replaces it.
Strict-Transport-Securitymax-age=31536000; includeSubDomainsOnly when the proxy forwards X-Forwarded-Proto: https.

X-Powered-By is removed. There is no CORS: browsers reach /v1 on the same host as the web app (the Caddy proxy, or the Next rewrite in pnpm dev), and native clients ignore CORS. Presigned-URL redirects and downloads are unaffected (the object store answers those requests, not the API). apps/web sets its own browser-side set in next.config.mjs and its CSP in middleware.ts.

Outbound requests to user-influenced URLs#

guardedFetch (shared/ssrf-guard.ts) is the only way to call a destination a person or tenant can influence. Today that is a connector's docs URL, which staff register in the connector registry (connect-docs.service.ts).

  • Only http and https, no credentials in the URL.
  • Every address the host resolves to must be public. Refused: loopback, private (RFC 1918), link-local and cloud metadata (169.254.0.0/16), CGNAT, multicast, reserved and documentation ranges, and the IPv6 equivalents including IPv4-mapped, NAT64, 6to4, unique-local and link-local. Decimal, hex, octal and short IPv4 spellings are normalized by URL parsing before the check.
  • The check runs again inside the socket's DNS lookup, so a name that changes its answer between check and connect (DNS rebinding) is refused too.
  • Redirects are refused, not followed.

Destinations that deployment configures (S3, Redis, the connectors on the private network, provider API URLs) are trusted and do not use the guard. Do not pass a user-supplied URL to fetch or fetchWithAddressFallback; the async-boundary ratchet lists every file that makes outbound calls.

OUTBOUND_ALLOW_PRIVATE_HOSTS (comma-separated hostnames, empty by default) exempts exactly those names from the address check. Set it to localhost for a local stack whose APP_URL is localhost; the integration harness and pnpm dev:local set it for loopback. Leave it empty on a server.

The Firecrawl website scrape in the vendor finder sends a supplier's website to Firecrawl, which fetches it from its own network; the API makes no request to that URL.