From 29b33d138feb375a9933ba647179d1ae333cb9f8 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 29 Jul 2026 13:57:08 +0100 Subject: [PATCH] fix: escape string values for date/date-time/time formats `asDateTime`, `asDate` and `asTime` concatenated string inputs straight between quotes, so a value containing `"`, `\`, a control character or a lone surrogate produced a document that is not valid JSON. Plain `type: string` fields already went through `asString`, so the behavior was inconsistent depending on `format`. Route the string branch of the three functions through the existing escaping routine, which is hoisted to module scope since the generated code destructures the serializer methods and calls them unbound. `Date` inputs keep the plain concatenation: `toISOString()` never needs escaping. Serializing a date-format string is now as expensive as serializing a regular string of the same length; `Date` values are unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TmhA1oTx5qUWc6UmEgWuar --- README.md | 2 +- lib/serializer.js | 78 +++++++++++++++++++++++++---------------------- test/date.test.js | 27 ++++++++++++++++ 3 files changed, 69 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 2b286443..5949c104 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ And nested ones, too. | `date` | `2020-04-03` | | `time` | `09:11:08` | -**Note**: In the case of a string formatted Date and not Date Object, there will be no manipulation on it. It should be properly formatted. +**Note**: In the case of a string formatted Date and not Date Object, the value is not reformatted nor validated: it should already be properly formatted. It is only escaped, so that the resulting document is always valid JSON. Example with a Date object: diff --git a/lib/serializer.js b/lib/serializer.js index d8ff4b87..1b3a77d8 100644 --- a/lib/serializer.js +++ b/lib/serializer.js @@ -3,6 +3,43 @@ // eslint-disable-next-line const STR_ESCAPE = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/ +function asString (str) { + const len = str.length + if (len === 0) { + return '""' + } else if (len < 42) { + // magically escape strings for json + // relying on their charCodeAt + // everything below 32 needs JSON.stringify() + // every string that contain surrogate needs JSON.stringify() + // 34 and 92 happens all the time, so we + // have a fast case for them + let result = '' + let last = -1 + let point = 255 + for (let i = 0; i < len; i++) { + point = str.charCodeAt(i) + if ( + point === 0x22 || // '"' + point === 0x5c // '\' + ) { + last === -1 && (last = 0) + result += str.slice(last, i) + '\\' + last = i + } else if (point < 32 || (point >= 0xD800 && point <= 0xDFFF)) { + // The current character is non-printable characters or a surrogate. + return JSON.stringify(str) + } + } + return (last === -1 && ('"' + str + '"')) || ('"' + result + str.slice(last) + '"') + } else if (len < 5000 && STR_ESCAPE.test(str) === false) { + // Only use the regular expression for shorter input. The overhead is otherwise too much. + return '"' + str + '"' + } else { + return JSON.stringify(str) + } +} + module.exports = class Serializer { constructor (options) { switch (options && options.rounding) { @@ -63,7 +100,7 @@ module.exports = class Serializer { return '"' + date.toISOString() + '"' } if (typeof date === 'string') { - return '"' + date + '"' + return asString(date) } throw new Error(`The value "${date}" cannot be converted to a date-time.`) } @@ -74,7 +111,7 @@ module.exports = class Serializer { return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + '"' } if (typeof date === 'string') { - return '"' + date + '"' + return asString(date) } throw new Error(`The value "${date}" cannot be converted to a date.`) } @@ -85,46 +122,13 @@ module.exports = class Serializer { return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + '"' } if (typeof date === 'string') { - return '"' + date + '"' + return asString(date) } throw new Error(`The value "${date}" cannot be converted to a time.`) } asString (str) { - const len = str.length - if (len === 0) { - return '""' - } else if (len < 42) { - // magically escape strings for json - // relying on their charCodeAt - // everything below 32 needs JSON.stringify() - // every string that contain surrogate needs JSON.stringify() - // 34 and 92 happens all the time, so we - // have a fast case for them - let result = '' - let last = -1 - let point = 255 - for (let i = 0; i < len; i++) { - point = str.charCodeAt(i) - if ( - point === 0x22 || // '"' - point === 0x5c // '\' - ) { - last === -1 && (last = 0) - result += str.slice(last, i) + '\\' - last = i - } else if (point < 32 || (point >= 0xD800 && point <= 0xDFFF)) { - // The current character is non-printable characters or a surrogate. - return JSON.stringify(str) - } - } - return (last === -1 && ('"' + str + '"')) || ('"' + result + str.slice(last) + '"') - } else if (len < 5000 && STR_ESCAPE.test(str) === false) { - // Only use the regular expression for shorter input. The overhead is otherwise too much. - return '"' + str + '"' - } else { - return JSON.stringify(str) - } + return asString(str) } asUnsafeString (str) { diff --git a/test/date.test.js b/test/date.test.js index 3d143fa8..a3048f13 100644 --- a/test/date.test.js +++ b/test/date.test.js @@ -637,3 +637,30 @@ test('should serialize also an invalid string value, even if it is not a valid t t.assert.equal(output, JSON.stringify(toStringify)) t.assert.equal(validate(JSON.parse(output)), false, 'valid schema') }) + +test('should escape strings that are not valid dates', (t) => { + const formats = ['date-time', 'date', 'time'] + const values = [ + '2026-01-01T00:00:00Z","admin":true,"x":"', + 'back\\slash', + 'new\nline', + 'lone \ud800 surrogate' + ] + + t.plan(formats.length * values.length * 2) + + for (const format of formats) { + const stringify = build({ + type: 'object', + properties: { + ts: { type: 'string', format } + } + }) + + for (const value of values) { + const output = stringify({ ts: value }) + t.assert.equal(output, `{"ts":${JSON.stringify(value)}}`) + t.assert.deepStrictEqual(JSON.parse(output), { ts: value }) + } + } +})