Skip to content

Security model

Proxy boundary, nonce policy, cookie handling, and no-store requirements.

Security design

This repo is security-first by default.

This repo enforces two guardrail layers that work together:

  • Plugin guardrails (HWP side): response envelopes, middleware controls, auth modes, rate limiting, and suspicious-request handling inside the plugin.
  • Site proxy guardrails (this repo): /api/secure/** deny-by-default allowlisting, nonce enforcement policy, header/cookie forwarding limits, and response hardening before data reaches the browser.

The browser boundary remains strict: browser clients call Next.js routes only, and upstream WordPress requests stay server-side through /api/secure/**.

Threat model highlights

  • Prevent API sprawl and accidental exposure of admin or machine endpoints
  • Prevent SSRF via proxy
  • Prevent CSRF against cookie-authenticated WordPress sessions
  • Prevent cookie leakage or broken auth via bad Set-Cookie handling
  • Keep sensitive data out of logs and caches

Controls

  1. Strict allowlist (deny by default)
  • Implemented via src/lib/secure/endpointRegistry.ts (materialized in src/lib/secure/allowlist.ts)
  • Explicit method + path + query allowlist for HWP and PFP
  • HEAD is treated as GET for allowlist enforcement
  • The allowlist is derived from the endpoint registry only (endpointRegistry.ts -> allowlist.ts).
  • Internal-only endpoints (for example PFP) still require explicit registry entries and contract tests.
  1. Proxy hardening (/api/secure)
  • Reject paths not in allowlist

  • Reject path segments containing .. or \

  • Reject upstreamPath values that contain :// or start with // (SSRF guard)

  • Reject disallowed query parameters (no silent drops)

  • Forward request headers via allowlist only:

    • accept
    • content-type
    • accept-language (if present)
    • user-agent (if present)
    • x-wp-nonce (if present)
    • cart-token (if present)
    • x-requested-with (if present)
    • nonce (if present)
  • Route-scoped request headers:

    • x-pfp-reauth-token

    x-pfp-reauth-token is forwarded only for routeId pfp.me.licenseKey.get.

  • Always inject correlation headers:

    • X-Correlation-Id
    • X-CorrelationId
  • Failure logging is structured and low-noise:

    • The proxy emits exactly one console.error({ ... }) object per failed proxy request path.
    • Logged fields include correlationId, endpoint routeId (from endpoint registry), request method, high-level failure reason, and upstreamStatus when present.
    • No request bodies are logged.
    • No cookie values are logged.
    • No nonce values are logged.
    • No authorization headers are logged.
    • No passwords, license keys, coupon codes, or other secrets are logged.
    • No query values are logged.

Optional proxy debug logging

  • When HWP_SECURE_PROXY_DEBUG=1, the proxy emits exactly one SECURE_PROXY_DEBUG line per proxied request (success or failure).
  • The debug payload may include cookie names (not values) and presence flags for the Cookie header and X-WP-Nonce header.
  • The debug payload includes upstreamStatus and a small allowlisted subset of upstream cache-related headers.
  • Debug logging does not include cookie values, nonce values, request bodies, credentials, or query values (debug logs upstreamPath only, not the query string).
  • Filter cookies by name prefix allowlist before forwarding:
    • wordpress_
    • wp-settings-
    • wp-settings-time-
    • wp_woocommerce_session_
    • woocommerce_
    • wfwaf-authcookie- (Wordfence auth cookie prefix required in environments where Wordfence firewall is present)
  • Filter cookies by exact name allowlist before forwarding:
    • affwp_ref
    • affwp_campaign
    • affwp_ref_visit_id
  • Route-scoped cookie restrictions:
    • routeId hwp.auth.login.post forwards no cookies by design to keep credential login anonymous and avoid cookie-auth nonce mismatches
    • because login is cookie-stripped, login nonce bootstrap must also be anonymous (GET /wp-json/headlesswp/v1/nonce with credentials: "omit") so nonce/user context matches the upstream login request
  • Enforce request body size cap:
    • SECURE_PROXY_MAX_BODY_BYTES (default 1048576)
  • Enforce upstream timeout:
    • SECURE_PROXY_TIMEOUT_MS (default 15000)
  • Response headers are filtered by an explicit allowlist before returning to the browser:
    • only content-type, content-disposition, and safe rewritten location are forwarded
    • upstream Access-Control-*, server, x-powered-by, x-redirect-by, link, x-wp-total, and x-wp-totalpages are never forwarded
    • Location is deny-by-default rewritten: only secure-origin absolute URLs and /wp-json/* relative URLs are rewritten to /api/secure/*; all other locations are omitted
  • Always enforce no-store caching headers and X-Content-Type-Options: nosniff
  • Preserve multi Set-Cookie responses and rewrite cookie attributes for the app origin:
    • strip Domain= (host-only, more restrictive)
    • force Path=/ so auth cookies apply site-wide on the marketing site origin
    • enforce Secure + HttpOnly
    • enforce SameSite=Lax when missing and override SameSite=None to SameSite=Lax

Runtime configuration note

  • SECURE_ORIGIN is required when the secure proxy runs.
  • Build does not require SECURE_ORIGIN.
  • NEXT_PUBLIC_SITE_URL is for SEO metadata (metadataBase, sitemap, robots) and to anchor the allowed-host list for internal origin validation.
  • Internal proxy fetches use absolute URLs derived from the current request origin, validated against the allowed-host list (not defaulting to NEXT_PUBLIC_SITE_URL).
  • Server-side wrapper calls that intentionally self-call /api/secure/** can use HWP_SERVER_REQUEST_ORIGIN (fallback: NEXT_PUBLIC_SITE_URL) to avoid self-signed HTTPS TLS failures in local/live smoke environments while preserving the proxy boundary.
  1. CSRF nonce enforcement
  • The browser must fetch a nonce first:
    • GET /wp-json/headlesswp/v1/nonce
  • The browser must send the nonce on:
    • all mutating requests
    • all user-specific reads proxied through /api/secure (site policy)
  • Store cart bridge requests for Woo Store API actions also send Nonce (Store API nonce) when provided by meta.tokens.nonce; this is separate from proxy CSRF X-WP-Nonce.
  • Exceptions (public reads that remain nonce-free):
    • GET /wp-json/headlesswp/v1/nonce
    • GET /wp-json/headlesswp/v1/health
    • GET /wp-json/headlesswp/v1/store/offers
    • GET /wp-json/headlesswp/v1/store/wc/cart
    • GET /wp-json/headlesswp/v1/capabilities
  • This list is frozen by tests and must match the proxy registry (SECURE_ENDPOINT_REGISTRY entries where method === "GET" and requiresProxyNonce === false).
  • Everything else (including POST /wp-json/headlesswp/v1/auth/login) requires a nonce at the proxy boundary.
  • Login UX fail-closed policy: non-2xx responses still fail closed and surface canonical auth errors.
  • Login 2xx schema parsing is drift detection only (debug-warning path); session confirmation remains the authenticated nonce warm-up (/nonce) followed by authenticated /me reads.

Proxy error behavior

  • Missing nonce returns:
    • 403 with error.code=CSRF_FAILED and reason=missing_nonce
  • Blocked path returns:
    • 403 with error.code=SECURE_PROXY_PATH_BLOCKED
  • Blocked query returns:
    • 403 with error.code=SECURE_PROXY_QUERY_BLOCKED and reason=query_param_not_allowed
    • error.details.param is set to the first disallowed query key
  1. Analytics (opt-in, client-side only)
  • Analytics scripts are disabled by default and only load when explicitly configured.
  • Analytics loading is client-side only and never uses /api/secure.
  • The site does not forward auth cookies or X-WP-Nonce to third-party analytics.