Skip to content

fix(response): do not render non-Error throws as successful responses#1485

Merged
pi0 merged 10 commits into
mainfrom
fix/thrown-obj
Jul 22, 2026
Merged

fix(response): do not render non-Error throws as successful responses#1485
pi0 merged 10 commits into
mainfrom
fix/thrown-obj

Conversation

@pi0x

@pi0x pi0x commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Resolves #1479.

Problem

In toResponse, only typeof r === "number" rejections were special-cased into an HTTPError. Every other non-Error rejection value re-entered toResponse as if it were a normal handler return value, so it was rendered as a successful response body:

app.get("/boom", () => {
  throw { status: 400, secret: "db-password" };
});
// before → 200 {"status":400,"secret":"db-password"}

Note the status: 400 was never honored — it was just a field in a 200 body. Same for throw "not-an-error" (200 with the string as body) and throw undefined (200, empty). A caught-and-rethrown non-Error from a DB driver or third-party lib leaked its full contents to the client with a success status, and onError / unhandled logging never saw it.

Fix

A toError() normalizer applied at both throw-handling sites — the promise rejection path in toResponse, and the synchronous catch in handlerWithFetch, which had the same shape.

thrown status unhandled + logged
new HTTPError(…) / new Error(…) unchanged unchanged
404 (feature from #1372) 404 no
{ status: 400, secret, message } 400 no
{ statusCode: 429 } 429 no
{ status: 999 }, { status: {…} } 500 yes
"not-an-error", undefined, { secret } 500 yes

Two rules:

  1. A valid status is deliberate shorthand. throw { status: 404 } is the object form of throw 404 from feat(error): coerce thrown number/string to HTTPError #1372, so it renders identically and is not reported as unhandled. An invalid or out-of-range status is more likely a mistake than intent, so it falls back to a logged 500 rather than quietly becoming the shorthand.
  2. Only the status is ever inherited. message, data, statusText and headers are dropped; the original value is kept as cause, which is never serialized. So the leak is closed on both paths.

The status is read explicitly off the value rather than passed to the HTTPError constructor as cause. That matters: the constructor deliberately falls back to cause.status / cause.statusText / cause.headers (tested at test/errors.test.ts — "can inherit deprecated statusCode/statusMessage from cause"), which is right for an Error the app built, but would let an arbitrary thrown object forge the status text and response headers. There are regression tests for both.

kHandled / kNotFound are passed through: callNodeHandler deliberately does reject(kHandled) on a Node stream error, and without the passthrough a JSON error body gets appended to an already-streamed response.

Incidental: sanitizeStatusCode hardening

Honoring a caller-supplied status routes untrusted input into sanitizeStatusCode, which turned out to range-check without first confirming it held a number. Comparisons against an object are all false, so it returned the object itself, after which new Response threw init["status"] must be in the range of 200 to 599 — the exact leak its own comment warns about. [500] likewise returned the array.

Number.isInteger closes objects, arrays, floats and NaN in one check. This is a shared helper used well beyond this path, so it's the highest-risk part of the diff despite being three lines: floats like 400.5 and array-ish values now fall back to the default instead of being coerced downstream.

Tests

New non-Error throws block in test/errors.test.ts, across the web/node matrix:

  • object / async object / string / undefined no longer render as a body
  • explicit status and deprecated statusCode are honored
  • invalid statuses (999, -1, 0, "abc", NaN, null, an object) fall back to 500
  • a thrown object cannot forge statusText or response headers
  • throw { status: 404 } renders identically to throw 404 (asserted against each other, not a hardcoded body, so it stays pinned to the feat(error): coerce thrown number/string to HTTPError #1372 feature)
  • onError receives the wrapped error with cause intact
  • a cross-realm Error is not rendered as a body — toError must use the same instanceof Error check as prepareResponse; a broader one (e.g. Error.isError) would pass it through only for prepareResponse to reject it and render it as a body again. This was a pre-existing 200 leak that the fix also closes.

Note on bundle size

Thresholds needed bumping: H3 18_100→18_300 (gzip 6_850→6_950), H3Core 7_350→7_500, defineHandler 6_450→6_650 (gzip 2_600→2_700). Actuals are 18231/6898, 7460/2957, 6578/2642. Happy to golf toError smaller if the cost is a concern.

Behavior change worth flagging

A third-party object that happens to carry .status — e.g. { status: 403, code: "AccessDenied", requestId } from an AWS-style client — now returns a silent 403 with no log trace. Nothing leaks (code / requestId are dropped), but #1479's "logging never sees it" complaint still applies to that specific shape. h3 can't distinguish it from a deliberate throw { status: 403 }, and this PR chose consistency with the #1372 shorthand. Flagging it since it's a judgement call, not a forced one.

🤖 Generated with Claude Code

Only numeric rejections were coerced into an `HTTPError`. Any other
non-`Error` thrown value re-entered `toResponse` as a normal handler
return value, producing a successful 200 response whose body was the
thrown value, with `onError` and unhandled logging never seeing it.

Normalize thrown values via a shared `toError()`: `Error` instances and
the `kHandled`/`kNotFound` sentinels pass through, numbers still coerce
to a status, and anything else becomes an unhandled 500 error keeping
the original value as `cause` only.

`cause` is assigned after construction on purpose: passing it to the
`HTTPError` constructor would let the thrown object's own `statusText`
and `headers` fields flow into the response.

Resolves #1479

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@pi0x
pi0x requested a review from pi0 as a code owner July 18, 2026 08:11
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Handlers now normalize thrown and rejected non-Error values into unhandled 500 HTTPError instances, preserve causes without exposing payloads, update fetch handling, validate integer status codes, add regression coverage, and tighten bundle-size thresholds.

Changes

Error normalization

Layer / File(s) Summary
Response error normalization
src/response.ts, src/utils/sanitize.ts
Adds toError, applies it to rejected thenables, preserves errors and sentinels, wraps other values as 500 HTTPError instances, and rejects non-integer status codes.
Fetch error integration
src/handler.ts
Normalizes caught fetch errors before response conversion.
Error handling and bundle validation
test/errors.test.ts, test/bench/bundle.test.ts
Adds non-Error, cross-realm, cause, body, and header safety coverage, and lowers bundle-size thresholds.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • h3js/h3#1372: Updates response rejection normalization for non-Error values.
  • h3js/h3#1420: Also changes sanitizeStatusCode validation logic.

Suggested reviewers: pi0

Poem

A rabbit wrapped stray errors tight,
In five-hundred code by moonlit night.
Secrets stayed beneath the hay,
Forged headers hopped away.
Tests thumped: “All responses right!”

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Bundle-size benchmark thresholds were lowered in test/bench/bundle.test.ts, which is unrelated to the non-Error throw fix. Remove the benchmark threshold edits unless they are required for this fix and explicitly part of the issue scope.
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #1479 by normalizing non-Error throws into unhandled HTTP errors and preventing leaked payloads.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: non-Error throws are no longer rendered as successful responses.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/thrown-obj

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

pi0 and others added 5 commits July 18, 2026 09:03
`toError` must use the same `instanceof Error` check as `prepareResponse`.
A broader check (e.g. the standard `Error.isError`) would pass a
cross-realm error through, only for `prepareResponse` to reject it and
render it as a successful response body again.

Also document why `cause` is assigned after construction rather than
passed to the `HTTPError` constructor.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
# Conflicts:
#	test/bench/bundle.test.ts
Clarify that the thrown value is untrusted, unlike the `Error` branch of
`prepareResponse`, so it must not be placed where the `HTTPError`
constructor reads `status`/`statusText`/`headers` from it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Preserve an explicit `status` (or deprecated `statusCode`) from a thrown
non-Error value instead of forcing 500, while still dropping everything
else: `message`, `data`, `statusText` and `headers` are never inherited
and the value is kept as `cause` for logs only.

The status is read explicitly rather than via the constructor's `cause`
fallback, which would also pull in `statusText` and `headers`.

Also harden `sanitizeStatusCode`, which this newly routes untrusted input
into. It range-checked without confirming it held a number, so an object
or array passed straight through and made `Response` throw -- the exact
leak its own comment warns about. `Number.isInteger` closes that along
with floats and `NaN`.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Align object throws with the `throw 404` feature from #1372: an object
carrying a valid `status` (or deprecated `statusCode`) is a deliberate
status shorthand, so `throw { status: 404 }` now renders exactly like
`throw 404` and is not reported as an unhandled error.

Values without a valid status -- strings, `undefined`, objects with no
or out-of-range status -- stay unhandled 500s so they are logged rather
than rendered as a successful body.

Only the status is ever taken from the thrown value; `message`, `data`,
`statusText` and `headers` are still dropped.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@pi0x pi0x changed the title fix(response): wrap non-Error throws into unhandled 500 errors fix(response): do not render non-Error throws as successful responses Jul 18, 2026
pi0 and others added 3 commits July 22, 2026 09:31
Drop the `throw { status }` shorthand: a non-Error object is never
trusted to shape the response, so its `status`/`statusCode` is no longer
honored. Any non-Error, non-number throw is now always an unhandled 500
with the original value kept as `cause` only.

Removes the reliance on `sanitizeStatusCode` in this path (the helper's
own hardening stays, it is independent). Re-tightens bundle thresholds
now that the branch is gone.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Symbol identity checks are cheaper than `instanceof Error`; order them
first.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
# Conflicts:
#	test/bench/bundle.test.ts
@pi0
pi0 merged commit d5a5c7a into main Jul 22, 2026
9 checks passed
@pi0
pi0 deleted the fix/thrown-obj branch July 22, 2026 09:54
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.

Non-Error throws produce 200 responses exposing the thrown value

2 participants