Skip to content

Name authentication middleware and add http.securityHeaders config - #1568

Merged
kriszyp merged 6 commits into
mainfrom
kris/waf-core-enablers
Jul 17, 2026
Merged

Name authentication middleware and add http.securityHeaders config#1568
kriszyp merged 6 commits into
mainfrom
kris/waf-core-enablers

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

Two small core enablers for an upcoming WAF middleware component:

  1. Explicitly named authentication middleware — honesty note: this is contract-hardening, not new behavior. Middleware entries already default their name to the registering component's name (name: options?.name ?? getComponentName() in server/http.ts), and the auth plugin loads under the authentication config key — so before: 'authentication' / after: 'authentication' ordering (used today by REST, MQTT, GraphQL, MCP, and static.ts) already resolved correctly. This change makes the name an explicit, test-locked contract so a future rename of the config/registry key can't silently break every consumer that targets 'authentication'. No chain-order behavior changes.

    The operations-API registrations in server/operationsServer.ts are a genuine (if inert) name change: they previously defaulted to the operationsApi component name and now register as authentication. Nothing in-repo targets 'operationsApi' as a middleware name, and those chains are bypassed for real ops traffic (see caveat below), so this is forward-compat consistency only.

  2. http.securityHeaders config block — a zero-code way for ops to add response headers like X-Frame-Options / X-Content-Type-Options:

    http:
      securityHeaders:
        X-Frame-Options: SAMEORIGIN
        X-Content-Type-Options: nosniff

    Applied on app ports across all response paths of the Harper-native request handler: the normal writeHead path, handlesHeaders streams (static files — where the send() stream writes its own headers, universal headers are pre-set so the stream can still override its own names), thrown-error responses, and the Fastify status === -1 cascade. Node and Bun handlers both covered.

    • Precedence: app wins. Configured headers are defaults — a header the application/route already set on a response is never overridden (a route's X-Frame-Options: DENY is not loosened by a configured SAMEORIGIN).
    • Validation: names/values validated with node:http's validateHeaderName/validateHeaderValue; invalid entries (and non-object block values) are logged and skipped, never thrown.
    • Hot-reload: via the existing scope.options.on('change') path; the feature tracks exactly the entries it pushed into universalHeaders and splices only those, preserving entries pushed by other components. Only the first (root) http scope owns the config — an application config.yaml with an http: block cannot wipe root-configured headers.
    • Opt-in / no behavior change: absent block ⇒ nothing added; all per-response loops are guarded by universalHeaders.length so unconfigured deployments skip them.

Where to look

  • server/http.tsapplySecurityHeaders() (ownership-tracked splice, root-scope guard) and the four response-path application sites.
  • Scope caveat: the operations API doesn't carry these headers in normal mode — not because ops requests bypass the request handler (they don't; they cascade to Fastify through it), but because the ops API runs on the main thread, which loads components with resources.isWorker = false, so http's handleApplication never populates the main thread's per-thread universalHeaders. With threads: 0 the ops API shares a worker and would carry them (benign). Detailed in DESIGN.md. Flagging in case ops-API parity is wanted later.
  • server/operationsServer.ts — ops-API auth registrations get name: 'authentication' for consistency (forward-compat naming, not a behavior change).

Tests

  • unitTests/server/securityHeaders.test.js (new): apply/coerce/validate/non-object-reject/hot-reload-swap semantics, "don't clobber other components' entries", and root-scope ownership vs a second app scope.
  • unitTests/server/middlewareChain.test.js: new case proving a before: 'authentication' entry executes before auth regardless of registration order.
  • unitTests/security/auth.test.js: asserts handleApplication registers the auth listener under name: 'authentication'.
  • unitTests/validation/configValidator.test.js: 4 new schema cases (absent/map/coercible/rejected).
  • integrationTests/server/security-headers.test.ts + fixture app (new): real Harper instance — headers on 200 REST, 404 cascade, static-file (handlesHeaders), and thrown-error responses; app-set header wins over config; absent config adds nothing.

Generated by KrAIs (Claude, Fable 5 model) via Claude Code.

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the http.securityHeaders configuration option, allowing users to define custom response headers (such as X-Frame-Options) that are appended to REST/app HTTP responses. It includes robust hot-reloading logic to update these headers dynamically without clobbering headers registered by other components, alongside comprehensive unit and integration tests. The review feedback suggests two key improvements: using Object.entries() instead of a for...in loop to avoid iterating over inherited properties, and wrapping test cleanup in a try...finally block to prevent test pollution in case of assertion failures.

Comment thread server/http.ts Outdated
Comment thread unitTests/server/securityHeaders.test.js Outdated
Comment thread unitTests/server/securityHeaders.test.js
@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp

kriszyp commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Cross-model review disposition (Gemini via agy, full diff):

  • Accepted + fixed (d405d48): Bun thrown-error path now merges error.headers (previously dropped on Bun — pre-existing gap, fixed while touching the path; error headers win over configured universal headers, mirroring Node); applySecurityHeaders rejects arrays.
  • Documented as known limitation: a handler that synchronously calls writeHead before returning handlesHeaders: true cannot receive universal headers (headers already sent; no in-tree component does this).
  • Refuted: claimed app-header clobber on the Bun handlesHeaders path — responseHeaders is empty at the point universal headers are set, and the stream's later setHeader calls overwrite, so app/stream wins by ordering.
  • Declined (nit): universal headers on the catastrophic Fastify-inject-failure handlers — dead-rare path, not worth the code.

An independent fresh-context review earlier drove commit 6300e32 (response-path coverage + app-wins precedence). — KrAIs (Claude)

🤖 Generated with Claude Code

kriszyp and others added 4 commits July 14, 2026 17:07
Enablers for an upcoming WAF middleware component:

- Register the authentication middleware under the name
  'authentication' (both the app-worker registration in
  security/auth.ts and the operations-API registrations in
  server/operationsServer.ts), so other middleware can order relative
  to it with before/after in server.http() options.

- Add an opt-in http.securityHeaders config block (map of header
  name -> value) appended to every REST/app HTTP response via
  universalHeaders, with hot-reload support. Header names/values are
  validated with node:http validateHeaderName/validateHeaderValue;
  invalid entries are logged and skipped. Hot reload tracks the
  entries this feature owns and splices only those, so entries pushed
  by other components are preserved. No defaults are applied when the
  block is absent (no behavior change for existing deployments).

Note: the operations API is served directly by Fastify's own
http.Server and does not flow through the Harper-native onRequest
handler, so universalHeaders (and thus securityHeaders) do not apply
to operations API responses. Documented in DESIGN.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses adversarial-review findings on the initial implementation:

- handlesHeaders responses (e.g. static's send() stream) now receive
  universal headers: pre-set on nodeResponse (Node) / the pipe-shim
  responseHeaders (Bun) so streams can still override their own names.
- Thrown-error responses now receive universal headers on both the
  Node onError path and the Bun catch path.
- Precedence is now app-wins: universal headers are defaults and never
  override a header the application/route already set (has/set checks;
  works with both Harper Headers and web Headers responses).
- Only the first (root) http scope owns securityHeaders: an app
  config.yaml with an http: block can no longer wipe root-configured
  headers on load or hot-reload.
- Non-object securityHeaders values are rejected instead of for-in
  iterating (a string would yield digit-named headers).
- DESIGN.md ops-API rationale corrected: ops requests DO flow through
  the Harper-native requestHandler and cascade to Fastify; the real
  reason headers are absent is that the main thread loads components
  with resources.isWorker=false, so http's handleApplication never
  populates the main thread's universalHeaders (threads:0 mode would
  carry them — benign).
- Integration tests now cover the 200 writeHead path, static
  (handlesHeaders) path, thrown-error path, and app-wins precedence
  via a dedicated fixture app, plus the existing 404-cascade and
  opt-in-absent cases.

All new per-response loops are guarded by universalHeaders.length so
unconfigured deployments skip them entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…array securityHeaders

- Bun thrown-error responses now carry error-provided headers (e.g.
  WWW-Authenticate) merged with universal headers, error headers winning —
  mirrors the Node onError path's toWriteHeadHeaders semantics. (Previously
  the Bun catch dropped error.headers entirely, a pre-existing gap made
  worth fixing while touching this path.)
- applySecurityHeaders rejects arrays (typeof object, would iterate indices).
- Document the known limitation that a handler calling writeHead
  synchronously before returning handlesHeaders cannot receive universal
  headers.

Refuted (no change): the claim that the Bun handlesHeaders path clobbers
app-set headers — responseHeaders is empty at that point and the stream's
later setHeader calls overwrite, so app/stream wins by ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- server/http.ts: use Object.entries() instead of for...in in
  applySecurityHeaders and the Bun error-headers merge path
  (no hasOwnProperty guard previously).
- unitTests/server/securityHeaders.test.js: wrap the hot-reload
  ownership test in try/finally so a failed assertion can't leak
  foreignEntry state into other tests; drop sandbox.stub(harperLogger,
  'error') from the two tests whose meaningful assertions don't need
  it, per AGENTS.md's no-new-sinon convention.
- integrationTests/server/security-headers.test.ts: migrate
  node:assert/strict -> node:assert; the rebase onto main pulled in
  oxlint's no-restricted-imports rule banning the strict import,
  which this file (authored before that rule landed) violated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/waf-core-enablers branch from d405d48 to ee20f70 Compare July 14, 2026 23:33
Each transport's status===-1 branch (uWS's fastify-inject/bare-404
fallbacks, and the equivalent Bun paths) builds a fresh Headers object
for the fallback response instead of reusing the one universalHeaders
had already been merged into, so the merge was silently discarded.
The uWS handler additionally merged unconditionally (headers.set)
instead of app-wins (has-check), so a route's own X-Frame-Options was
overwritten by the configured default. Extracted a shared
applyUniversalHeaders() helper (app-wins semantics) and apply it on
whichever Headers object each response path actually returns, across
Node, Bun, and uWS.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review July 15, 2026 12:51
@github-actions
github-actions Bot requested a review from heskew July 15, 2026 12:51
Comment thread server/http.ts
Co-Authored-By: Codex <noreply@openai.com>

@heskew heskew left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header application traced across all four response paths (Node http/http2, Bun, uWS, error); case-mismatch bypass ruled out; the operationsApiauthentication rename has no old-name consumers. Checked the threads: 0 duplicate-registration concern: handleApplication is worker-only with a started idempotency guard, the ops-server registration is main-only (deliberate, per the code comment), and they target different ports — worst case is redundant fail-closed auth, never a bypass. LGTM.

🤖 Reviewed via cross-model pipeline; approved by @heskew.

@kriszyp
kriszyp merged commit 62c8e8e into main Jul 17, 2026
95 of 96 checks passed
@kriszyp
kriszyp deleted the kris/waf-core-enablers branch July 17, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants