fix(response): do not render non-Error throws as successful responses#1485
Conversation
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]>
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughHandlers now normalize thrown and rejected non-Error values into unhandled 500 ChangesError normalization
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
`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]>
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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
Resolves #1479.
Problem
In
toResponse, onlytypeof r === "number"rejections were special-cased into anHTTPError. Every other non-Errorrejection value re-enteredtoResponseas if it were a normal handler return value, so it was rendered as a successful response body:Note the
status: 400was never honored — it was just a field in a 200 body. Same forthrow "not-an-error"(200 with the string as body) andthrow undefined(200, empty). A caught-and-rethrown non-Errorfrom a DB driver or third-party lib leaked its full contents to the client with a success status, andonError/ unhandled logging never saw it.Fix
A
toError()normalizer applied at both throw-handling sites — the promise rejection path intoResponse, and the synchronouscatchinhandlerWithFetch, which had the same shape.new HTTPError(…)/new Error(…)404(feature from #1372){ status: 400, secret, message }{ statusCode: 429 }{ status: 999 },{ status: {…} }"not-an-error",undefined,{ secret }Two rules:
throw { status: 404 }is the object form ofthrow 404from 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.message,data,statusTextandheadersare dropped; the original value is kept ascause, 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
HTTPErrorconstructor ascause. That matters: the constructor deliberately falls back tocause.status/cause.statusText/cause.headers(tested attest/errors.test.ts— "can inherit deprecated statusCode/statusMessage from cause"), which is right for anErrorthe app built, but would let an arbitrary thrown object forge the status text and response headers. There are regression tests for both.kHandled/kNotFoundare passed through:callNodeHandlerdeliberately doesreject(kHandled)on a Node stream error, and without the passthrough a JSON error body gets appended to an already-streamed response.Incidental:
sanitizeStatusCodehardeningHonoring 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 allfalse, so it returned the object itself, after whichnew Responsethrewinit["status"] must be in the range of 200 to 599— the exact leak its own comment warns about.[500]likewise returned the array.Number.isIntegercloses objects, arrays, floats andNaNin 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 like400.5and array-ish values now fall back to the default instead of being coerced downstream.Tests
New
non-Error throwsblock intest/errors.test.ts, across the web/node matrix:undefinedno longer render as a bodystatusand deprecatedstatusCodeare honored999,-1,0,"abc",NaN,null, an object) fall back to 500statusTextor response headersthrow { status: 404 }renders identically tothrow 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)onErrorreceives the wrapped error withcauseintactErroris not rendered as a body —toErrormust use the sameinstanceof Errorcheck asprepareResponse; a broader one (e.g.Error.isError) would pass it through only forprepareResponseto 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
toErrorsmaller 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/requestIdare dropped), but #1479's "logging never sees it" complaint still applies to that specific shape. h3 can't distinguish it from a deliberatethrow { 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