Skip to content

fix(varlock): finish the response when leak detection throws on end - #1065

Merged
theoephraim merged 7 commits into
mainfrom
leak-scan-finish-response
Sep 3, 2026
Merged

fix(varlock): finish the response when leak detection throws on end#1065
theoephraim merged 7 commits into
mainfrom
leak-scan-finish-response

Conversation

@theoephraim

@theoephraim theoephraim commented Sep 3, 2026

Copy link
Copy Markdown
Member

Fixes #897.

Split 2 of 3 out of #1061 by @WalksWithASwagger, who wrote the original fix. Commit authorship is preserved; the three slices touch disjoint files so they review and land independently.

scanForLeaks threw before the original ServerResponse.end ran, so the response was never finished. In a Next.js Pages Router res.json() the client got a 200 whose Content-Length promised more bytes than were ever sent, and sat waiting for the rest. This matches the TODO already sitting in patch-server-response.ts.

Now the response is finished before the leak error is rethrown, so the client is not left waiting:

  • headers not yet sent: a plaintext 500. This is what a vite dev middleware hits, and the existing vite leak scenario asserts that 500.
  • headers already sent: the connection is destroyed. This is what a next.js production api route hits, because its compression layer emits the headers before varlock sees the body.

The replacement clears the whole header table rather than the few obviously-wrong entries, so nothing describing the rejected body rides onto it: Cache-Control: s-maxage=... would have a CDN cache the error, Set-Cookie would still be set, and next.js computes an ETag from the body before calling end().

Ending the response means framework error handling can end it a second time (apiResolver catches and calls sendError), which node turns into an ERR_STREAM_WRITE_AFTER_END error event on a response that has no listener. The response is flagged as finalized, so later writes and ends through the patched methods are no-ops.

Covered on both the compressed and uncompressed end paths with a real HTTP client, including the case where nothing else finishes the response, which is the reported hang. Next.js gets a pages-router API route fixture (res.json() reaches the scanner at end() with no preceding write()): dev redacts and still completes, production kills the response and keeps serving.

The framework harness gained expectedFailure: 'network' | 'timeout' for this: allowRequestFailure collapsed every error into the same empty synthetic response, so a "does not hang" scenario would have passed on a hang.

Note this is wired at end only. A leak caught mid-stream in write still throws as before.

Test plan

  • cd packages/varlock && bunx vitest run (123 files, 1953 passed)
  • cd framework-tests && bunx vitest run frameworks/vite frameworks/nextjs/nextjs-v15.test.ts (276 passed)
  • verified against a real Next.js 15.5 production server: main stalls the client on a truncated 200, this branch fails the request immediately and the server keeps serving

scanForLeaks threw before the original ServerResponse.end ran, so the
client hung. Send a 500 (clearing stale Content-Length and
Content-Encoding) or destroy the socket when headers are already sent,
then rethrow so callers still see the leak error.

Fixes #897
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

bumpy-frog

The changes in this PR will be included in the next version bump.

patch Patch releases

  • @varlock/native-helper-darwin 1.18.0 → 1.18.1
  • @varlock/native-helper-linux-arm64 1.18.0 → 1.18.1
  • @varlock/native-helper-linux-x64 1.18.0 → 1.18.1
  • @varlock/native-helper-win32-x64 1.18.0 → 1.18.1
  • varlock 1.18.0 → 1.18.1

Bump files in this PR

Click here if you want to add another bump file to this PR


This comment is maintained by bumpy.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle size

⚠️ grows the bundle by 15.3 KB (+0.3%)

Metric main This PR Δ
Total dist 4452.0 KB 4467.3 KB +15.3 KB (+0.3%)
JS 1691.6 KB 1698.5 KB +6.9 KB (+0.4%)
Sourcemaps 2652.2 KB 2660.6 KB +8.4 KB (+0.3%)
Type defs 108.2 KB 108.2 KB
Other 0.0 KB 0.0 KB

dist/ only; native binaries are versioned separately and not counted here.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
varlock-website 196d52d Commit Preview URL

Branch Preview URL
Sep 03 2026, 08:51 PM

@pkg-pr-new

pkg-pr-new Bot commented Sep 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@env-spec/parser

npm i https://pkg.pr.new/dmno-dev/varlock/@env-spec/parser@1065

varlock

npm i https://pkg.pr.new/dmno-dev/varlock@1065

@varlock/native-helper-darwin

npm i https://pkg.pr.new/dmno-dev/varlock/@varlock/native-helper-darwin@1065

@varlock/native-helper-linux-arm64

npm i https://pkg.pr.new/dmno-dev/varlock/@varlock/native-helper-linux-arm64@1065

@varlock/native-helper-linux-x64

npm i https://pkg.pr.new/dmno-dev/varlock/@varlock/native-helper-linux-x64@1065

@varlock/native-helper-win32-x64

npm i https://pkg.pr.new/dmno-dev/varlock/@varlock/native-helper-win32-x64@1065

@varlock/aws-sigv4-plugin

npm i https://pkg.pr.new/dmno-dev/varlock/@varlock/aws-sigv4-plugin@1065

commit: 196d52d

@pullfrog pullfrog 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.

Important

The replacement response itself completes, but the synchronous rethrow triggers a second end() in the primary Next.js Pages Router flow and produces ERR_STREAM_WRITE_AFTER_END. The response metadata also retains the original Next-generated ETag.

Reviewed changes in c143a334, covering the response finalization helper, both end() scan paths, socket-level regression tests, documentation, and release metadata.

  • Leak response finalization: Replaces an unsent leaking response with a plaintext 500, or destroys an already-started response, before rethrowing the detection error.
  • Framing cleanup: Rewrites Content-Length and removes stale compression and transfer encoding headers for the replacement body.
  • Regression coverage: Adds detached-response and real HTTP tests for response completion and stale headers.
  • User-facing guidance: Documents the new behavior and records a varlock patch release entry.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏

Comment thread packages/varlock/src/runtime/patch-server-response.ts
Comment thread packages/varlock/src/runtime/patch-server-response.ts Outdated
… response

Rewriting the rejected response into a plaintext 500 meant inheriting the
headers that described the body being thrown away: Cache-Control (an s-maxage
would have a CDN cache the error), Set-Cookie, the ETag next.js computes from
the body before calling end(), and anything route-specific. It was also mostly
unreachable - next.js in production emits the headers from its compression
layer before varlock ever sees the body, so headersSent is already true.

Destroy the connection in both cases, which is what the write() path already
does mid-stream, and leave the leak report on the server log as the diagnostic.
This also absorbs the second end() that framework error handling makes in
response to the rethrown error (next.js pages router apiResolver -> sendError),
which node ignores on a destroyed response.

Adds next.js coverage for a pages-router API route, where res.json() reaches
the scanner at end() with no preceding write(): dev redacts and still completes,
production kills the response and keeps serving.

@pullfrog pullfrog 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.

Important

The production Next.js regression scenario still accepts the original client hang as a successful outcome.

Reviewed changes since the prior Pullfrog review at c143a334, covering the revised connection teardown and its new integration coverage.

  • Destroyed rejected responses: Replaced the plaintext 500 path with ServerResponse.destroy() before rethrowing, avoiding stale representation metadata and absorbing framework retries.
  • Expanded regression coverage: Added direct HTTP tests and a Next.js Pages Router API fixture for uncompressed, compressed, and second-end() behavior.
  • Updated user guidance: Revised the documentation and release note to describe failed requests caused by connection closure.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏

Comment thread framework-tests/frameworks/nextjs/nextjs-shared.ts Outdated
… hang

allowRequestFailure recorded the same empty synthetic response for every error, so
the pages-router API route scenario accepted a client-side timeout exactly as it
accepted a promptly killed connection. A regression back to the original hang would
have passed it. Requests can now declare `expectedFailure: 'network' | 'timeout'`,
which keeps the failure kind on the result and asserts it (and shortens the
per-attempt timeout, since an expected failure should be prompt).

Verified live: the scenario reports 'network', and flipping the expectation to
'timeout' fails it.

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes since the prior Pullfrog review at 35397275, covering the harness assertion added for the no-hang regression.

  • Classified request failures: Preserved whether an allowed fetch failure came from a client timeout or a network error.
  • Strengthened the Next.js regression: Required the leaking Pages Router request to fail with a network error, so the original timeout hang can no longer satisfy the scenario.

Pullfrog  | View workflow run | Using azure/gpt-5.6-sol𝕏

fetchWithRetry defaults to 3 retries with a 500ms delay, so a request whose expected
outcome is a failure spent 4 attempts and a full second of sleeping on the path where
the test passes. Retrying something that is supposed to fail buys nothing.

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes since the prior Pullfrog review at 1698c0ea, covering the expected-failure request retry adjustment.

  • Limited expected failures to one attempt: Set retries: 0 for requests that should fail, avoiding unnecessary retry delays while retaining the shortened timeout that detects a restored hang.
  • Preserved regression integrity: Confirmed the surrounding successful requests and required leak diagnostic prevent an unrelated transient network failure from satisfying the complete Next.js scenario.

Pullfrog  | View workflow run | Using azure/gpt-5.6-sol𝕏

Destroying unconditionally regressed vite: its dev middleware reaches end() with the
headers still unsent, and the existing leak scenario asserts a 500 from /api/leak
(frameworks/vite: 8 failures across v5-v8). Both branches are load-bearing. Next.js in
production reaches end() through a compression layer that has already emitted the
headers, so an api route there is destroyed; a vite middleware still has a response to
replace, and a plaintext 500 beats a dead socket when one is achievable.

The replacement clears the whole header table rather than the few obviously-wrong
entries, so nothing describing the rejected body survives onto it: Cache-Control (an
s-maxage would have a CDN cache the error), Set-Cookie, the ETag next.js computes from
the body, and route-specific headers.

Ending the response also brings back the second-end hazard, so the response is flagged
as finalized and later writes/ends through the patched methods are no-ops (next.js
pages router apiResolver -> sendError ends it again, which node turns into an
ERR_STREAM_WRITE_AFTER_END error event on a response that has no listener).

Tests cover both branches over a real connection, and all seven fail against main.

@pullfrog pullfrog 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.

ℹ️ No critical issues; one minor suggestion inline.

Reviewed changes since the prior Pullfrog review at 42a91b2b, covering the restored pre-header response replacement behavior.

  • Restored the unsent-header 500 path: Finished an uncommitted leaking response with a complete plaintext 500 while continuing to destroy responses whose headers are already sent.
  • Cleared rejected response metadata: Removed all existing headers before declaring the replacement body's content type and length.
  • Expanded branch-specific coverage: Added real-connection assertions for the replacement 500, stale-header removal, compressed bodies, and the post-header connection teardown path.
  • Updated user guidance: Documented when clients receive a plaintext 500 versus a closed connection.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏

Comment thread packages/varlock/src/runtime/patch-server-response.ts
Node only falls back to the standard reason when statusMessage is unset, so a route
that set its own kept it across the status code change and the replacement went out
as `500 <their reason>`.

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes since the prior Pullfrog review at 8e503ea8, covering the replacement response reason phrase fix.

  • Reset the replacement reason phrase: Set statusMessage to Internal Server Error alongside the 500 status so route-provided text cannot survive onto the rejected response.
  • Added wire-level coverage: Added a real HTTP route with a custom reason phrase and asserted the client receives 500 Internal Server Error.

Pullfrog  | View workflow run | Using azure/gpt-5.6-sol𝕏

The leak prevention paragraph spelled out which branch a caught leak takes, which is
implementation detail a reader does not need. What matters is that the body is never
delivered, the request fails rather than hanging, and the diagnostic is on the server
log.

Also: the ServerResponse scanner handles deflate, brotli and zstd, not just gzip; split
the dense --redact-stdout sentence; "Node.js internal console internals"; a missing
space in the scan output description.

@pullfrog pullfrog 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.

ℹ️ No critical issues; one documentation correction inline.

Reviewed changes since the prior Pullfrog review at 57048071, covering the secrets-guide cleanup in 196d52d8.

  • Simplified leak-prevention guidance: Replaced implementation-level response-finalization details with a shorter description of the client-visible outcome.
  • Corrected adjacent documentation: Clarified stdout redaction flags, generalized compressed-response wording, simplified the Node console description, and fixed punctuation.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏

_Only available in JavaScript/Node.js projects using varlock's runtime integrations._

Varlock scans outgoing HTTP responses at runtime to detect if any sensitive values are being accidentally sent to clients. If a leak is detected, varlock throws an error with a detailed diagnostic message including the config item key and where the leak was detected.
Varlock scans outgoing HTTP responses at runtime to detect if any sensitive values are being accidentally sent to clients. If a leak is detected, varlock throws an error with a detailed diagnostic message including the config item key and where the leak was detected. The rejected body is never delivered and the request fails rather than hanging, so look to your server log for the diagnostic.

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.

This now says every detected leak makes the request fail, but integrations using redactInsteadOfThrow complete the request with a redacted body, as the new development-mode Next.js scenario demonstrates. Please scope the failure behavior to throw mode or mention the redaction outcome so this guide remains accurate in development.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

its mostly internal, used during development only - so probably ok

@theoephraim
theoephraim merged commit f989363 into main Sep 3, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: Leak detection hang when ServerResponse.end throws on leaked secret

2 participants