Skip to content

fix: drop unmatched properties when additionalProperties is false - #879

Merged
Tony133 merged 2 commits into
fastify:mainfrom
MaxFreedomPollard:fix/additional-properties-false-with-pattern-properties
Sep 8, 2026
Merged

fix: drop unmatched properties when additionalProperties is false#879
Tony133 merged 2 commits into
fastify:mainfrom
MaxFreedomPollard:fix/additional-properties-false-with-pattern-properties

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

Problem

A schema that combines patternProperties with additionalProperties: false serializes every property the patterns did not match, instead of ignoring it.

const build = require('fast-json-stringify')

const stringify = build({
  type: 'object',
  properties: { nickname: { type: 'string' } },
  patternProperties: { num: { type: 'number' } },
  additionalProperties: false
})

stringify({ nickname: 'nick', matchnum: 3, leaked: 'secret' })
// ACTUAL:   {"nickname":"nick","matchnum":3,"leaked":"secret"}
// EXPECTED: {"nickname":"nick","matchnum":3}

Deleting the additionalProperties: false line from that same schema produces the correct {"nickname":"nick","matchnum":3}, so writing the stricter setting is what widens the output. README "Additional properties" states the intended rule: "If additionalProperties is not present or is set to false, every property that is not explicitly listed in the properties and patternProperties objects will be ignored".

This matters beyond a formatting difference. additionalProperties: false is the way a response schema is written to guarantee that only declared fields reach the client, and here it is the one spelling that does not hold.

Root cause

buildExtraObjectPropertiesSerializer gates its additionalProperties branch on additionalPropertiesSchema !== undefined (index.js:387 on main). A boolean false schema is not undefined, so it passes that guard, and it is not === true, so it lands in the else branch that calls buildValue(context, propertyLocation, 'value') on it. buildValue serializes any boolean schema as json += JSON.stringify(${input}) (index.js:1306), which emits the property verbatim.

buildInnerObject calls the function only when schema.patternProperties || schema.additionalProperties is truthy (index.js:532). With additionalProperties: false and no patternProperties that guard is false and the function never runs, which is why the leak requires a sibling patternProperties to surface and why additionalProperties: false on its own has always been correct.

Here is the generated loop body for the schema above, before the fix. The final three lines are the emitted false branch:

for (const key of Object.keys(obj)) {
  if (key === "nickname" || obj[key] === undefined || typeof obj[key] === 'function' || typeof obj[key] === 'symbol') continue
  const value = obj[key]
  if (/num/.test(key)) {
    !addComma_0 && (addComma_0 = true) || (json += JSON_STR_COMMA)
    json += asString(key) + JSON_STR_COLONS
    json += asNumber(value)
    continue
  }
  !addComma_0 && (addComma_0 = true) || (json += JSON_STR_COMMA)
  json += asString(key) + JSON_STR_COLONS
  json += JSON.stringify(value)
}

Fix

Exclude false from the guard, so no branch is emitted for it and an unmatched key simply falls off the end of the loop body and is dropped:

if (additionalPropertiesSchema !== undefined && additionalPropertiesSchema !== false) {

additionalProperties: true still goes through its JSON.stringify fast path, object schemas still go through buildValue, and additionalProperties: false without patternProperties is untouched because that path never reached this function.

Tests

Two cases added to test/additionalProperties.test.js, one with a declared properties map and one without, each asserting the exact output string and that it round-trips through JSON.parse. Both fail on main ('{"str1":"a","leaked":"secret"}' == '{"str1":"a"}') and pass with the fix.

Verification run on Node 22.23.2, macOS arm64, from a clone of main at e02b5bb:

npx c8 --100 --all --include=index.js --include='lib/**/*.js' --exclude=lib/schema-validator.js node --test --test-concurrency=2 gives 506/506 passing and holds 100% statements, branches, functions and lines on index.js and every file under lib/, which the --100 threshold enforces. npm run lint is clean and npm run test:typescript passes 14/14 assertions. No type change is needed: types/index.d.ts:136 already declares additionalProperties?: Schema | boolean.

npm run benchmark completes with no errors. I am not reporting a delta from it, because no scenario in benchmark/ uses patternProperties, so the changed branch is never generated there and any difference in those numbers would be machine noise rather than an effect of this patch.

No documentation change: README already describes the behaviour this restores.


Checklist

A schema that combines `patternProperties` with `additionalProperties: false`
leaked every property that the patterns did not match, instead of ignoring it.
`build({ type: 'object', patternProperties: { '^str': { type: 'string' } },
additionalProperties: false })({ str1: 'a', leaked: 'secret' })` returned
`{"str1":"a","leaked":"secret"}`. Removing `additionalProperties` entirely made
the same schema behave correctly, so the stricter setting produced the looser
output. README "Additional properties" states that when `additionalProperties`
is absent or `false`, every property not listed in `properties` and not matched
by `patternProperties` is ignored.

`buildExtraObjectPropertiesSerializer` in index.js gated its
`additionalProperties` branch on `additionalPropertiesSchema !== undefined`, so
the boolean `false` schema passed the guard and fell through to the non-`true`
else branch, which calls `buildValue` on it. `buildValue` serializes any
boolean schema as `json += JSON.stringify(value)`, emitting the property
verbatim. `buildInnerObject` only calls the function when
`schema.patternProperties || schema.additionalProperties` is truthy, which is
why the leak needed a sibling `patternProperties` to appear.

Exclude `false` from that guard so no branch is emitted for it and unmatched
keys fall off the end of the generated loop body. `additionalProperties: true`
and object schemas are unchanged.

Signed-off-by: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com>

@mcollina mcollina left a comment

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.

lgtm

@gurgunday gurgunday left a comment

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.

lgtm

@Tony133
Tony133 merged commit 99bc4e8 into fastify:main Sep 8, 2026
17 checks passed
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.

4 participants