/** * Shared security headers (Helmet + Content-Security-Policy) for Oxy backends. * * WHY THIS EXISTS * --------------- * A CSP only governs an origin that serves DOCUMENTS; on a JSON API it governs * no browsing context. The Oxy origins that serve HTML through Cloudflare have * so far either hand-written their own policy or shipped none at all, and two * bugs follow from that: * * 1. THE CLOUDFLARE INSIGHTS BEACON IS BLOCKED BY A HAND-WRITTEN POLICY. * Cloudflare injects ``, * which is what tells the client entry to call `hydrateRoot` instead of * `createRoot().render()`. Nothing in app code puts it there, so — like the * Cloudflare beacon above — an app cannot allowlist it from the app side. * Measured on `accounts.oxy.so` 2026-08-21: blocked, so every visit threw * away the server-rendered markup and re-rendered from scratch, with only a * console error to show for it. The hashes are therefore DERIVED from the * built output rather than hand-written (see {@link extractInlineScripts}): * a hash pasted into config is correct exactly until the build changes one * byte, and then it fails the same silent way. * * WHAT IT PROVIDES * ---------------- * `createOxySecurityHeaders(options)` returns the Helmet middleware with the * Oxy-wide CSP baseline applied, plus per-app extensions merged (and deduped) * into it. Everything Helmet does that is NOT the CSP (HSTS, frameguard, * referrer policy, CORP/COOP, …) is passed straight through, so an app keeps * full control of those. * * `buildOxyCspDirectives(extensions)` is the same resolution as a pure * function, for the Oxy document origins that are NOT Express — a Next.js * `headers()`, a Cloudflare Pages `_headers` generator — so one policy can * cover them without a second implementation. * * SCOPE: mount this on backends that serve HTML. A JSON-only API gains nothing * from a source-list CSP; harden those with the non-CSP headers instead * (`hsts`, `noSniff`, `frameguard`, CORP) rather than adding directives that * apply to no document. * * Node/Express-only: exported solely from `@oxy.so/core/server`. */ import { createHash } from 'node:crypto'; import type { RequestHandler } from 'express'; import helmet, { type HelmetOptions } from 'helmet'; /** CSP keyword for "this origin". Always present in every open baseline directive. */ const SELF = "'self'"; /** CSP keyword for a fully closed directive. Meaningless alongside any other source. */ const NONE = "'none'"; /** * Cloudflare Web Analytics. Injected at the edge into proxied HTML — no Oxy app * loads it, and no Oxy app should have to know these hostnames. Both are * required: the script host, and the host the beacon reports to. */ const CLOUDFLARE_INSIGHTS_SCRIPT_ORIGIN = 'https://static.cloudflareinsights.com'; const CLOUDFLARE_INSIGHTS_REPORT_ORIGIN = 'https://cloudflareinsights.com'; /** * Oxy platform origins. Every Oxy web origin runs the SDK, which calls the Oxy * API over HTTPS and Socket.IO, and resolves all canonical media through the * Oxy CDN (`getFileDownloadUrl` → `cloud.oxy.so`). */ const OXY_API_ORIGIN = 'https://api.oxy.so'; const OXY_API_WEBSOCKET_ORIGIN = 'wss://api.oxy.so'; const OXY_CDN_ORIGIN = 'https://cloud.oxy.so'; /** * Where a browser sends an upload's bytes: the Oxy Files R2 bucket (OxyHQ/oxy * ADR 0047). `assets.upload` over 64 MiB and `assets.uploadLarge` PUT every * part to a presigned URL on this host, never through the API, so a page whose * `connect-src` lacks it fails every large upload before a byte is sent. The * API signs virtual-hosted URLs — the bucket is the first label of the host, * `oxy-files..r2.cloudflarestorage.com` — and R2 serves the S3 API * only on the account's hosts (a custom domain cannot carry it). The Oxy API's * tests sign a real part URL with production's endpoint and bucket and require * its origin to equal this. */ export const OXY_FILES_UPLOAD_ORIGIN = 'https://oxy-files.fa4392797e57d4e63ceaaf7b1dc687ed.r2.cloudflarestorage.com'; /** The CSP directives an Oxy app may extend, in Helmet's camelCase spelling. */ export type OxyCspDirective = | 'baseUri' | 'connectSrc' | 'defaultSrc' | 'fontSrc' | 'formAction' | 'frameAncestors' | 'frameSrc' | 'imgSrc' | 'manifestSrc' | 'mediaSrc' | 'objectSrc' | 'scriptSrc' | 'scriptSrcAttr' | 'scriptSrcElem' | 'styleSrc' | 'styleSrcElem' | 'workerSrc'; /** * Per-app ADDITIONS to the Oxy baseline, keyed by directive. Values are merged * into the baseline and deduped — they never replace it, so `'self'` (and the * Cloudflare beacon hosts) cannot be lost. Extending a directive the baseline * does not define seeds it with `'self'` first, for the same reason. */ export type OxyCspExtensions = Partial>; /** * The Oxy-wide CSP baseline. Deliberately the floor every Oxy origin needs, not * a superset of what any one app allows — permissive sources an individual app * wants (`https:` images, `blob:` media, embed hosts, LiveKit) are that app's * extension, so each widening stays visible at its call site. * * `style-src` carries `'unsafe-inline'` because react-native-web injects its * stylesheet as inline `