Skip to content

fix: fall back to 500 when error status codes are out of range - #7024

Open
ndycode wants to merge 3 commits into
fastify:mainfrom
ndycode:fix/invalid-error-status-code
Open

fix: fall back to 500 when error status codes are out of range#7024
ndycode wants to merge 3 commits into
fastify:mainfrom
ndycode:fix/invalid-error-status-code

Conversation

@ndycode

@ndycode ndycode commented Sep 12, 2026

Copy link
Copy Markdown

Summary

Errors thrown or rejected with a status/statusCode outside the 400-599 range were previously applied to the response without validation, in two places that bypass reply.code()'s 100-599 check (FST_ERR_BAD_STATUS_CODE, #2078 / #2169):

  • setErrorHeaders (lib/error-handler.js) wrote error.status/error.statusCode straight to res.statusCode whenever the value was >= 400
  • setErrorStatusCode (lib/error-status.js) passed the unvalidated value to reply.code(), which throws from inside the error machinery

Observed failures on main @ d266f833 (all reproduced before the fix; the new tests fail red on the base):

Scenario Before After
async/sync handler throws err.statusCode = 600 HTTP/1.1 600 on the wire with an FST_ERR_BAD_STATUS_CODE body 500
err.statusCode = 69420 RangeError [ERR_HTTP_INVALID_STATUS_CODE] escapes the error pipeline as an unhandled rejection; the response never completes 500
same + tracing:fastify.request.handler subscriber (e.g. OTel-style tracing) FST_ERR_BAD_STATUS_CODE thrown from wrap-thenable.js before the error event is published: unhandled rejection on the async path, silently dropped tracing event on the sync path subscriber sees 500, response completes
reply.code(503) set, then throw with statusCode = 600 user's 503 overridden to 600 on the wire 503 preserved

The wrap-thenable.js / handle-request.js call sites were introduced by #6412, which fixed a different bug (the diagnostics channel reporting status 200) and did not account for out-of-range codes.

Changes

  • lib/error-status.js: add isValidErrorStatusCode (400-599 band, same lower bound the default error handler already enforced); setErrorStatusCode falls back to 500 for anything else
  • lib/error-handler.js: setErrorHeaders honors error.status/error.statusCode only within 400-599; a previously set valid status code is preserved
  • docs: state the 400-599 rule in docs/Reference/Errors.md, docs/Reference/Server.md (setErrorHandler), and the reply.send(error) note in docs/Reference/Reply.md
  • tests: 10 regression tests across test/reply-error.test.js (600 / 69420 / '600', async + sync, err.status, user-set code preservation) and test/diagnostics-channel/error-status.test.js (async + sync with a channel subscriber)

Validation

  • Red: on pristine d266f833 with only the test files applied, all 10 new tests fail with the mechanisms above; all 90 pre-existing tests in those files pass
  • Green: npm run unit — 2350 tests, 0 failures (Windows 11, Node 24.18.0); the two affected files 100/100 across 6 runs
  • npm run lint, npm run lint:markdown, npm run test:types (tstyche, 1282 assertions), npx borp --coverage --check-coverage --lines 100 — all pass locally
  • Not run locally: Node 26, macOS/Linux (covered by the CI matrix). Follow-up maintenance run (2026-09-13, Windows 11, Node 24.18.0): npm run test (unit + types) and npm run benchmark --if-present (1.46M requests in 30s) both pass, including the OWASP link correction and the setErrorHandler/reply.send(error) preservation wording.

The affected code paths also exist on 5.x (#6412 was backported); happy to prepare a backport if wanted.


This change was developed with AI assistance (opencode / GLM). The analysis, reproduction, red/green test evidence, and final diff were reviewed by the branch author before opening this pull request.

Checklist

Errors thrown or rejected with a status/statusCode outside the 400-599
range were applied to the response without validation: values between
600 and 999 were written to the wire as-is, and larger values made
res.writeHead throw ERR_HTTP_INVALID_STATUS_CODE from inside the error
pipeline, escaping as an unhandled rejection and leaving the response
hanging. With a tracing:fastify.request.handler subscriber present,
setErrorStatusCode threw FST_ERR_BAD_STATUS_CODE before publishing the
error event, crashing the async path and silently dropping the event on
the sync path.

Validate error-provided status codes with the same 400-599 band already
used by the default error handler, falling back to the reply's current
status code or 500, matching reply.code()'s documented 100-599 contract.
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 12, 2026
Comment thread lib/error-status.js
if (!reply[kReplyHasStatusCode] || reply.statusCode === 200) {
const statusCode = err && (err.statusCode || err.status)
reply.code(statusCode >= 400 ? statusCode : 500)
reply.code(isValidErrorStatusCode(statusCode) ? statusCode : 500)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Falling back to 500 is correct for the HTTP response, but an out-of-range status code is still a server-side programming error. We should surface it with a dedicated FST_ERR_BAD_ERROR_STATUS_CODE, preserving the original error as its cause.

The following is me thinking out loud, I recommend to wait for further review.

We create a new Fastify Internal Error:

FST_ERR_BAD_ERROR_STATUS_CODE: createError(
  'FST_ERR_BAD_ERROR_STATUS_CODE',
  'Invalid status code on Error: %s'
)

We update the signature of setErrorStatusCode

function setErrorStatusCode (reply, err) {
  if (!reply[kReplyHasStatusCode] || reply.statusCode === 200) {
    const statusCode = err && (err.statusCode || err.status)

    if (statusCode !== undefined && !isValidErrorStatusCode(statusCode)) {
      reply.code(500)
      // err is replaced by `FST_ERR_BAD_ERROR_STATUS_CODE` keeping the orginal
     // error reference.
      return new FST_ERR_BAD_ERROR_STATUS_CODE(statusCode, {
        cause: err
      })
    }

    reply.code(statusCode || 500)
  }

  return err
}

We then can use it for diagnostics so monitoring tools can catch it.:

// Set status code before publishing so subscribers see the correct value
const withErrorWrongStatus = setErrorStatusCode(reply, err)
channels.error.publish(withErrorWrongStatus)

See:

setErrorStatusCode(reply, err)

The previous https://owasp.org/www-community/... URL now returns a
308 redirect to community.owasp.org, which the External Link Checker
(redirects: error) reports as broken. Link to the canonical article
directly.
The setErrorStatusCode path keeps a valid status code already set on
the reply instead of forcing 500, so say that in the setErrorHandler
and reply.send(error) notes, matching the Errors.md wording.
@ndycode

ndycode commented Sep 13, 2026

Copy link
Copy Markdown
Author

The broken OWASP link is fixed (commit 35a9655) and the full suite, including the link checker, was green on that head after approval. The latest head (6d308ee) only adjusts two wording lines in Server.md and Reply.md to state that an already-set valid status code is preserved rather than forced to 500, matching the implemented behavior.

Could a maintainer re-approve the workflow runs on the updated head? Thanks!

@jean-michelet

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants