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
- 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.
- Proxy hardening (/api/secure)
-
Reject paths not in allowlist
-
Reject path segments containing
..or\ -
Reject
upstreamPathvalues 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, endpointrouteId(from endpoint registry), requestmethod, high-level failurereason, andupstreamStatuswhen 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.
- The proxy emits exactly one
Optional proxy debug logging
- When
HWP_SECURE_PROXY_DEBUG=1, the proxy emits exactly oneSECURE_PROXY_DEBUGline per proxied request (success or failure). - The debug payload may include cookie names (not values) and presence flags for the
Cookieheader andX-WP-Nonceheader. - The debug payload includes
upstreamStatusand 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
upstreamPathonly, 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.postforwards 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/noncewithcredentials: "omit") so nonce/user context matches the upstream login request
- routeId
- 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 rewrittenlocationare forwarded - upstream
Access-Control-*,server,x-powered-by,x-redirect-by,link,x-wp-total, andx-wp-totalpagesare never forwarded Locationis deny-by-default rewritten: only secure-origin absolute URLs and/wp-json/*relative URLs are rewritten to/api/secure/*; all other locations are omitted
- only
- 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 useHWP_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.
- 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 bymeta.tokens.nonce; this is separate from proxy CSRFX-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_REGISTRYentries wheremethod === "GET"andrequiresProxyNonce === 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/mereads.
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
- 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.