Skip to content

[ENG-4058] Apply header-only rules when the body is not screened - #297

Merged
patchstackdave merged 8 commits into
mainfrom
fix/header-redaction-without-body
Sep 29, 2026
Merged

patchstackdave merged 8 commits into
mainfrom
fix/header-redaction-without-body

Conversation

@patchstackdave

@patchstackdave patchstackdave commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Response rules that read only response headers are now enforced on responses whose body is not screened: over the cap, binary, a live stream, or content-encoded. Both the fetch and Node paths are covered. These rules are decided from the headers alone, so the outcome doesn't depend on whether the body was read.

Header redactions

  • An eligible redact rule reads at least one response.header.* / response.headers parameter and nothing that depends on the body, has a span to mask, and carries no decoding mutations. It masks only the header values it matched. Protecting the body takes a rule on response.body.

Where a redaction masks

  • Each span redaction now masks where its condition read, on every path (ordinary, early flush, and unscreened body; fetch and Node).
  • A response.header.<name> condition masks that header only, entry by entry for a multi-valued header. A response.headers condition masks the headers only. Neither rewrites the body, so a header rule no longer changes body text, touches an unrelated header, or withholds a JSON response whose keys contain the same text.
  • A response.body condition masks the body and the same text in the headers, as before. A rule with both masks each where it reads. encode applies to the body only when its condition reads the body. block is unaffected.
  • A parameter list masks the union of its members' scopes, and a condition inside a group masks where that condition reads, so a string and a one-item list behave the same. Structural masking also applies when the body is read through a list.
  • The status and the request sources (get, post, request, cookie, files, server, raw, all) name no place in the response, and keep the broad mask they have always had: the body and every header.
  • A condition with no place to mask masks nothing and is reported once through onError when the rules load. That covers a parameter the rule contract refuses (an empty or nested list, an unknown source or key, a value that is not a parameter), egress.*, or no parameter at all. A rule left with nothing to mask withholds the response rather than reporting a mask it didn't make.
  • A list member that isn't a non-empty string (for example null), or a condition that isn't an object, is handled the same way. Rule loading never fails because of one: on the first load, a refresh or a push, the condition masks nothing and is reported through onError, and any error while reading a rule's redactions is reported and the rule loaded without them. A delivered update the rule contract refuses is still rejected whole by the rule source, keeping the previous rules.
  • The default, demo and scaffolded response rules all read response.body, so their behaviour is unchanged.

Header blocks

  • A rule with an explicit block action that reads only response headers withholds the response with the existing generic response.
  • On the Node path, anything the application writes afterwards is discarded, and its end callback still runs once.

Both kinds

  • Rules that read the body, the status alone, that encode, or that have no explicit action are still left out when there is no body.
  • On the Node path they run once per response: at an explicit flushHeaders(), before the head goes out, or on a branch that doesn't screen the body. The screen at end leaves them out, so a match is reported once.
  • With a flush, a streamed response gets the withheld status or the masked header value.
  • A head that was already sent before the guard saw the response can take neither. The match is still reported and recorded as a headers-sent skip. The skip names the headers that could not be masked, or action: "block" for a block that could not be enforced.
  • After a flush, the rest of the body is screened at end as before. The head has gone, so a body rule that matches then can only rewrite a chunked body; otherwise the response is cut off rather than sent under a length that no longer fits.

Also

  • The Node withheld response now carries only its own framing headers (content-type, content-length), matching the fetch path. Headers the application set no longer go out beside it.
  • A rule's prefilter anchors are now looked for in header values as well as the body, so a header rule with a prefilter is no longer gated on the body's contents.
  • The screenResponse and responseRules documentation in protect.d.ts now says this: a rule that reads only response headers redacts or blocks on the headers and is enforced even when the body can't be screened; a redaction masks only the header it matched; body protection needs a response.body rule, which also masks the same text in headers; and a withheld response carries only its own content-type and content-length.

Tests:

  • tests/protect/redaction-scope-malformed-parameters.test.ts covers malformed list members and conditions on the first load, a refresh and the push endpoint.
  • tests/protect/redaction-scope-parameter-forms.test.ts covers string, list and group forms on the ordinary, early-flush and unscreened-body paths (fetch and Node), legacy broad parameters, and each shape with no place to mask.
  • tests/protect/response-redaction-scope.test.ts covers redaction scope on both paths: header, all-headers, body and combined rules; multi-valued and mixed-case headers; literal and token-claim matches; encode; early flush and unscreened bodies.
  • tests/protect/header-redaction-without-a-body.test.ts and tests/protect/header-block-without-a-body.test.ts cover:
    • each unscreened body kind on both paths, with headers set before the body and through writeHead();
    • flushHeaders() before a streamed text or binary body;
    • a head sent before the guard saw it;
    • dry-run, including single reporting across a flush, and ineligible rule shapes;
    • a body crossing the cap across writes or in its final chunk, with or without an explicit head;
    • block precedence over redaction, and prefilters on both paths.

Validation: full suite (3,931 passed, 7 skipped), typecheck, and build.

Part of ENG-4058.

🤖 Generated with Claude Code

@coderbuds

coderbuds Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Comprehensive feature addition with extensive streaming and header-only screening logic.

🎯 Quality: 75% Good · 📦 Size: Oversized — strongly consider breaking this down

🛡️ Standards: Not checked — 1,672 lines changed, over your team's 400-line limit, and nothing checked before it was opened. Coding agents can call the assess-change-fit tool first, while a change this size is still cheap to split.

🤖 Authorship: Agent-written — Claude Code, going by its own attribution. Whether a person read it is unknown; coding agents can call the report-ai-usage tool to say.

📈 This month: Your 164th PR — above team average · Averaging Good

See how your team is trending →

@patchstackdave patchstackdave changed the title [ENG-4058] Apply header redactions when the body is not screened [ENG-4058] Apply header-only rules when the body is not screened Sep 28, 2026
@patchstackdave
patchstackdave added this pull request to stack #317 September 29, 2026 08:03
@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

patchstackdave and others added 8 commits September 29, 2026 11:21
A redaction that reads response headers only is now applied to responses
whose body is not screened (over the cap, binary, a live stream, or
content-encoded), on both the fetch and Node paths. A rule's prefilter
anchors are looked for in header values as well as the body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An explicit block rule that reads response headers only is now enforced
on responses whose body is not screened, on both paths, while the head
can still change. If it has already been sent, the match is reported and
recorded as a headers-sent skip. The Node withheld response carries only
its own framing headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An explicit flushHeaders() now runs the rules decided on headers alone
before the head goes out: a block withholds the response and a
redaction masks the header. They run once per response, so the screen
at end does not report them again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A span redaction from a response.header.<name> condition now masks that
header only, and one from response.headers masks the headers only;
neither rewrites the body. A body condition masks the body and the same
text in the headers, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A condition that names a list of parameters now masks the union of its
members' scopes, so a list of response headers masks those headers only.
Structural masking also applies when the body is read through a list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The status and request sources keep their broad mask, listed
explicitly. A condition whose parameter the contract refuses, or that
names no place in the response, now masks nothing and is reported
through onError when the rules load; a rule left with nothing to mask
withholds the response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A parameter list member that is not a non-empty string, or a condition
that is not an object, no longer makes rule loading throw. Such a
condition masks nothing and is reported through onError, on the first
load and on a refresh, and any error while reading a rule's redactions
is reported without failing the load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patchstackdave
patchstackdave force-pushed the fix/header-redaction-without-body branch from 6d3c704 to af79e62 Compare September 29, 2026 09:25
@patchstackdave
patchstackdave merged commit aab71e3 into main Sep 29, 2026
18 checks passed
@patchstackdave
patchstackdave deleted the fix/header-redaction-without-body branch September 29, 2026 09:30
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.

2 participants