Skip to content

Name authentication middleware and add http.securityHeaders config#1568

Draft
kriszyp wants to merge 3 commits into
mainfrom
kris/waf-core-enablers
Draft

Name authentication middleware and add http.securityHeaders config#1568
kriszyp wants to merge 3 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

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>

@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
Comment on lines +141 to +142
for (const name in securityHeaders) {
const value = '' + securityHeaders[name];

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.

medium

Using a for...in loop without a hasOwnProperty check can lead to iterating over inherited properties if the prototype chain is modified or polluted. It is safer and more idiomatic to use Object.entries() to iterate over the object's own enumerable properties.

Suggested change
for (const name in securityHeaders) {
const value = '' + securityHeaders[name];
for (const [name, rawValue] of Object.entries(securityHeaders)) {
const value = '' + rawValue;

Comment on lines +119 to +133
const scope = mockScope({ securityHeaders: { 'X-Frame-Options': 'SAMEORIGIN' } });
handleApplication(scope);
assert.ok(universalHeaders.some(([name]) => name === 'X-Frame-Options'));
assert.ok(universalHeaders.includes(foreignEntry));

// Hot-reload with a different config: old owned entry should be gone, new one present,
// and the foreign entry must survive untouched.
scope._reload({ securityHeaders: { 'X-Content-Type-Options': 'nosniff' } });
assert.ok(!universalHeaders.some(([name]) => name === 'X-Frame-Options'));
assert.ok(universalHeaders.some(([name]) => name === 'X-Content-Type-Options'));
assert.ok(universalHeaders.includes(foreignEntry));

// Clean up the foreign entry so it doesn't leak into other tests.
const index = universalHeaders.indexOf(foreignEntry);
if (index !== -1) universalHeaders.splice(index, 1);

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.

medium

If any of the assertions fail, the cleanup code at the end of the test will be skipped, causing the foreignEntry to leak into subsequent tests. Wrapping the test logic in a try...finally block ensures that the cleanup always runs, preventing test pollution and potential flakiness.

		try {
			const scope = mockScope({ securityHeaders: { 'X-Frame-Options': 'SAMEORIGIN' } });
			handleApplication(scope);
			assert.ok(universalHeaders.some(([name]) => name === 'X-Frame-Options'));
			assert.ok(universalHeaders.includes(foreignEntry));

			// Hot-reload with a different config: old owned entry should be gone, new one present,
			// and the foreign entry must survive untouched.
			scope._reload({ securityHeaders: { 'X-Content-Type-Options': 'nosniff' } });
			assert.ok(!universalHeaders.some(([name]) => name === 'X-Frame-Options'));
			assert.ok(universalHeaders.some(([name]) => name === 'X-Content-Type-Options'));
			assert.ok(universalHeaders.includes(foreignEntry));
		} finally {
			// Clean up the foreign entry so it doesn't leak into other tests.
			const index = universalHeaders.indexOf(foreignEntry);
			if (index !== -1) universalHeaders.splice(index, 1);
		}

testUtils.preTestPrep();

const assert = require('assert');
const sinon = require('sinon');

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.

Suggestion (non-blocking): AGENTS.md says "do not add new uses of sinon or rewire" for new test files. The two tests that use sandbox.stub(harperLogger, 'error') (the "rejects invalid header names" and "rejects invalid header values" cases) already assert the meaningful side effects — the bad entry is absent from universalHeaders, and the valid entry is present. Dropping errorStub.calledOnce and removing the sinon import/sandbox would bring this file into line with the house style without losing coverage.

@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

kriszyp and others added 2 commits July 2, 2026 10:05
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>
@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

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.

1 participant