Pin the 4xx branch of the error handler with a thrown error

A mutant that put the message back into the non-validation 4xx answer
survived: Fastify 5.8.5's own 4xx errors (invalid or empty JSON body, 415,
413) carry fixed messages, so the JSON-parse and media-type cases could not
tell. The branch protects against any error thrown with a 4xx status, whose
message is whatever its thrower wrote. A test route now throws one with the
canary, and the answer must be the code alone.

The handler's comment and the earlier commit's message said JSON.parse and
the 415 quote input; in this Fastify they do not. The comment is corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 15:37:11 +02:00
commit 869802126c
2 changed files with 20 additions and 7 deletions

View file

@ -736,12 +736,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
...(options.oauthRuntimes ?? {}),
};
const app = Fastify({ logger: false });
// Fastify's default handlers answer with the error's message, and those
// messages carry input: pg and Node name the database host or the database
// (which a misbound URL fills with part of a secret), JSON.parse quotes the
// request body, and the media-type and not-found errors quote a header and
// the path. So every framework answer here is built from fixed text, the
// error code and the route's own schema, never from the message.
// Fastify's default handlers answer with the error's message, and messages
// carry input: pg and Node name the database host or the database (which a
// misbound URL fills with part of a secret), the default not-found answer
// quotes the path, and any error thrown with a 4xx status says whatever its
// thrower wrote. So every framework answer here is built from fixed text,
// the error code and the route's own schema, never from the message.
app.setErrorHandler((error: FastifyError, request, reply) => {
const status = error.statusCode !== undefined && error.statusCode >= 400 && error.statusCode < 600 ? error.statusCode : 500;
const code = typeof error.code === "string" ? error.code : "error";

View file

@ -1224,7 +1224,7 @@ describe("Heimdall service", () => {
// An answer never carries an input value. The canary stands in for whatever a
// failure could quote: part of a misbound database URL in a pg or resolver
// error, a request body in a JSON parse error, a header or a path.
// error, a request body, a header, a path, or a 4xx error's own message.
describe("error answers never echo an input", () => {
const CANARY = "CANARY";
@ -1276,6 +1276,19 @@ describe("error answers never echo an input", () => {
expect(response.body).not.toContain(CANARY);
});
it("answers any other 4xx error by its code, not its message", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
app.get("/v1/probe-4xx", async () => {
throw Object.assign(new Error(`refused ${CANARY}input`), { statusCode: 409, code: "PROBE_REFUSED" });
});
const response = await app.inject({ method: "GET", url: "/v1/probe-4xx" });
expect(response.statusCode).toBe(409);
expect(response.json()).toEqual({ error: "invalid_request", code: "PROBE_REFUSED" });
});
it("answers an unsupported media type without quoting the header", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);