OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / gateway / edge-headers.js

164 lines · javascript ref:
/**
 * Gateway edge header & advertisement policy (R3/R5/R6/R7).
 *
 * One module defines which isolation/privacy headers the HTTPS gateway emits
 * per lane and per ingress, so the policy cannot drift between the response
 * paths (JSON errors, directory listings, transformed HTML, streamed bytes,
 * and R1 verify bundles all pass through HyperGateway._handleRequest).
 *
 * Lanes and ingresses (docs/HTTPS-GATEWAY.md):
 *   - path lane       — shared-origin /v1/hyper/<key>/<path> (clearnet + onion)
 *   - app-origin lane — per-app z32 subdomains, exact bytes (clearnet)
 *   - onion ingress   — the Tor read plane; vport 80 forwards to the same
 *                       gateway, so ingress is detected from the `.onion`
 *                       Host. A spoofed `.onion` Host on clearnet only earns
 *                       the stricter CSP — the policy fails safe.
 *
 * Header matrix (lane × ingress):
 *
 *   header                       path lane        app-origin lane   onion ingress
 *   Service-Worker-Allowed       stripped (R3)    untouched*        stripped (R3)
 *   Content-Security-Policy      —                —                 ONION_READ_PLANE_CSP (R5)
 *   Cross-Origin-Opener-Policy   same-origin      same-origin       same-origin (R6)
 *   Cross-Origin-Resource-Policy cross-origin     same-origin       cross-origin (R6)
 *   Referrer-Policy              no-referrer      no-referrer       no-referrer (R6)
 *   Link                         hive:// canonical hint (R7, both lanes)
 *
 *   *R3 scopes the strip to the shared origin: on the app-origin lane the app
 *   owns its whole origin, so a worker there shadows only that same app (and
 *   the lane's bytes stay exact/untouched by design). On the shared path lane
 *   one app's Service-Worker-Allowed could widen a worker's scope beyond its
 *   own /<key>/ prefix and shadow every other app on the origin, so the lane
 *   is stateless-only.
 *
 * R3 — path-lane stateless-only policy. The gateway never copies drive entry
 * metadata into response headers and response bodies cannot carry headers, so
 * no app can set Service-Worker-Allowed through content today. The strip is
 * still installed as a structural guard at response commit (writeHead), so a
 * future emission path cannot accidentally reintroduce it.
 *
 * R5 — onion-ingress CSP default. The onion read plane exists to READ
 * declared-public bytes with client-IP privacy; the default below denies
 * script execution and network beacons outright while still letting a static
 * document render its own images/styles/fonts/media. 'self' is exactly what
 * the path lane's absolute→relative HTML rewrite produces, so the rewrite
 * and the CSP compose instead of fighting. An app may layer its own CSP via
 * <meta http-equiv>, but a meta policy can only tighten the effective one —
 * never loosen this header floor — so the shared-origin posture cannot be
 * weakened by content.
 *
 * R6 — COOP/CORP/Referrer-Policy, matching the existing Origin-Agent-Cluster:
 * ?1 / nosniff discipline. COOP same-origin is the browsing-context-group
 * counterpart of origin-keyed agent clusters. Referrer-Policy no-referrer
 * because the path names the drive key and the exact file being read — the
 * same no-referrer discipline as the operator dashboard. CORP follows each
 * lane's established cross-origin posture: the compatibility path lane
 * already answers CORS clients with Access-Control-Allow-Origin: * (the
 * house rule for public credential-free read planes — blind evidence GETs,
 * blind-edge), so it declares cross-origin; app origins never inherit
 * compatibility CORS, so they additionally deny no-cors subresource
 * embedding with same-origin.
 *
 * R7 — canonical upgrade hint. The app-origin lane already emitted
 * Link: <hive://<key>/<path>>; rel="canonical"; the path lane now emits the
 * same hint so a capable client can leave HTTPS for the native P2P scheme
 * (the blind/tier 403s even point there: "use the authorized native P2P
 * transport"). The signed hiverelay-gateway-advertisement-v1 record remains
 * the spec-level Phase-2 operator advertisement; the signed capability doc
 * plus these per-resource canonical links are the implemented surfaces.
 */

export const SERVICE_WORKER_ALLOWED_HEADER = 'Service-Worker-Allowed'

/**
 * Restrictive CSP default for the onion read plane (R5). Pinned as a constant
 * so tests assert the exact advertised value instead of a shape.
 */
export const ONION_READ_PLANE_CSP =
  "default-src 'none'; script-src 'none'; connect-src 'none'; " +
  "img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; media-src 'self'; " +
  "form-action 'none'; base-uri 'none'; frame-ancestors 'none'"

export const GATEWAY_CROSS_ORIGIN_OPENER_POLICY = 'same-origin'
export const PATH_LANE_CROSS_ORIGIN_RESOURCE_POLICY = 'cross-origin'
export const APP_ORIGIN_CROSS_ORIGIN_RESOURCE_POLICY = 'same-origin'
export const GATEWAY_REFERRER_POLICY = 'no-referrer'

/**
 * The onion read plane terminates inside tor and forwards plain HTTP to the
 * gateway, so the only honest ingress signal at this layer is the Host the
 * client asked Tor for: a `.onion` name. Port suffixes and a trailing root
 * dot still count; lookalikes (evil.com suffix games) do not.
 */
export function isOnionReadPlaneHost (hostHeader) {
  if (typeof hostHeader !== 'string') return false
  let hostname = hostHeader.trim().toLowerCase()
  if (!hostname || hostname.startsWith('[')) return false // IPv6 literal, never an onion name
  const colon = hostname.lastIndexOf(':')
  if (colon !== -1) hostname = hostname.slice(0, colon)
  if (hostname.endsWith('.')) hostname = hostname.slice(0, -1)
  return hostname.endsWith('.onion')
}

/**
 * Apply the R5/R6 edge headers for one gateway response. Idempotent — the
 * GatewayServer app-origin lane and HyperGateway may both run it for one
 * request without changing the result.
 */
export function applyGatewayEdgeHeaders (res, { exactBytes = false, onionIngress = false } = {}) {
  res.setHeader('Cross-Origin-Opener-Policy', GATEWAY_CROSS_ORIGIN_OPENER_POLICY)
  res.setHeader(
    'Cross-Origin-Resource-Policy',
    exactBytes ? APP_ORIGIN_CROSS_ORIGIN_RESOURCE_POLICY : PATH_LANE_CROSS_ORIGIN_RESOURCE_POLICY
  )
  res.setHeader('Referrer-Policy', GATEWAY_REFERRER_POLICY)
  if (onionIngress) res.setHeader('Content-Security-Policy', ONION_READ_PLANE_CSP)
}

/**
 * R3 structural strip: wrap writeHead so a shared-origin path-lane response
 * can never commit a Service-Worker-Allowed header — neither one queued via
 * setHeader nor one handed to writeHead directly (that argument bypasses the
 * setHeader queue removeHeader sees).
 */
export function guardPathLaneStatelessHeaders (res) {
  const writeHead = res.writeHead
  res.writeHead = function (...args) {
    res.removeHeader(SERVICE_WORKER_ALLOWED_HEADER)
    return writeHead.apply(this, args.map(withoutServiceWorkerAllowed))
  }
  return res
}

function withoutServiceWorkerAllowed (arg) {
  if (Array.isArray(arg)) {
    // Raw [name, value, name, value] form.
    const filtered = []
    for (let i = 0; i < arg.length; i += 2) {
      if (String(arg[i]).toLowerCase() === 'service-worker-allowed') continue
      filtered.push(arg[i], arg[i + 1])
    }
    return filtered
  }
  if (arg && typeof arg === 'object') {
    const filtered = {}
    for (const [name, value] of Object.entries(arg)) {
      if (name.toLowerCase() !== 'service-worker-allowed') filtered[name] = value
    }
    return filtered
  }
  return arg
}

/**
 * R7 path-lane upgrade hint: the canonical hive:// URI of the requested
 * resource. The app-origin lane additionally points rel="describedby" at its
 * per-app /.well-known/hiverelay-app.json document; the path lane has no
 * per-app document, so it emits the plain canonical form.
 */
export function buildHivePathLinkHeader (keyHex, filePath) {
  const canonical = new URL(`hive://${keyHex}/`)
  canonical.pathname = filePath
  return `<${canonical.href}>; rel="canonical"`
}