From 412ae27e7f8164e391c93807cd3ec116980612a7 Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:56:49 -0400 Subject: [PATCH] fix: drop unmatched properties when additionalProperties is false 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> --- index.js | 7 +++++- test/additionalProperties.test.js | 40 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index b0f82f93..f3d6a155 100644 --- a/index.js +++ b/index.js @@ -384,7 +384,12 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma, objV const additionalPropertiesLocation = location.getPropertyLocation('additionalProperties') const additionalPropertiesSchema = additionalPropertiesLocation.schema - if (additionalPropertiesSchema !== undefined) { + // `additionalProperties: false` means every property that is not declared in + // `properties` nor matched by `patternProperties` is dropped, so no branch is + // emitted for it. Without this guard the `false` schema reaches buildValue, + // which serializes any boolean schema with `JSON.stringify(value)` and lets + // the property through. + if (additionalPropertiesSchema !== undefined && additionalPropertiesSchema !== false) { if (additionalPropertiesSchema === true) { code += ` ${addComma} diff --git a/test/additionalProperties.test.js b/test/additionalProperties.test.js index 4a44a8c3..980dd00b 100644 --- a/test/additionalProperties.test.js +++ b/test/additionalProperties.test.js @@ -377,3 +377,43 @@ test('required key not in properties + additionalProperties produces valid JSON' t.assert.equal(out, '{"str":"x"}') t.assert.deepStrictEqual(JSON.parse(out), { str: 'x' }) }) + +test('additionalProperties set to false ignores properties not matched by patternProperties', (t) => { + // Regression: `additionalProperties: false` is documented to drop every + // property that is not listed in `properties` or matched by + // `patternProperties`. Combined with `patternProperties`, the generated + // code used to fall through to the additionalProperties branch with a + // boolean `false` schema, which serialized unmatched properties with + // `JSON.stringify(value)` and leaked them into the output: + // {"nickname":"nick","matchnum":3,"leaked":"secret"} + t.plan(2) + const stringify = build({ + type: 'object', + properties: { + nickname: { type: 'string' } + }, + patternProperties: { + num: { type: 'number' } + }, + additionalProperties: false + }) + + const out = stringify({ nickname: 'nick', matchnum: 3, leaked: 'secret' }) + t.assert.equal(out, '{"nickname":"nick","matchnum":3}') + t.assert.deepStrictEqual(JSON.parse(out), { nickname: 'nick', matchnum: 3 }) +}) + +test('additionalProperties set to false without declared properties ignores unmatched properties', (t) => { + t.plan(2) + const stringify = build({ + type: 'object', + patternProperties: { + '^str': { type: 'string' } + }, + additionalProperties: false + }) + + const out = stringify({ str1: 'a', leaked: 'secret' }) + t.assert.equal(out, '{"str1":"a"}') + t.assert.deepStrictEqual(JSON.parse(out), { str1: 'a' }) +})