@blamejs/core 0.7.4 → 0.7.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -0
- package/index.js +27 -1
- package/lib/api-key.js +2 -5
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +27 -1
- package/lib/auth/password.js +34 -0
- package/lib/codepoint-class.js +196 -0
- package/lib/csv.js +25 -36
- package/lib/db-declare-view.js +3 -4
- package/lib/file-upload.js +213 -10
- package/lib/framework-error.js +78 -0
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/mail-dkim.js +13 -6
- package/lib/mail.js +19 -0
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +79 -0
- package/lib/middleware/index.js +3 -0
- package/lib/numeric-bounds.js +20 -0
- package/lib/session.js +61 -4
- package/lib/static.js +184 -4
- package/lib/validate-opts.js +21 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-json — JSON content-safety primitive (b.guardJson).
|
|
4
|
+
*
|
|
5
|
+
* Threat catalog grounded in current research (multiple 2025–2026 CVEs
|
|
6
|
+
* + ongoing prototype-pollution vulnerability series):
|
|
7
|
+
* - CVE-2025-55182 React/Next.js Server Functions deserialization → RCE
|
|
8
|
+
* - CVE-2025-57820 / CVE-2026-30226 Svelte devalue prototype pollution
|
|
9
|
+
* - CVE-2026-35209 defu prototype pollution
|
|
10
|
+
* - CVE-2026-28794 @orpc/client prototype pollution via deserialization
|
|
11
|
+
* - CVE-2025-13465 Lodash prototype-chain path traversal
|
|
12
|
+
* - CVE-2025-25014 Kibana prototype pollution → RCE
|
|
13
|
+
* - CVE-2024-38984 json-override prototype pollution
|
|
14
|
+
* - CVE-2022-42743 deep-parse-json prototype pollution
|
|
15
|
+
* - GHSA-9c47-m6qq-7p4h JSON5 prototype pollution via parse method
|
|
16
|
+
*
|
|
17
|
+
* var rv = b.guardJson.validate(input, { profile: "strict" });
|
|
18
|
+
* var safe = b.guardJson.parse(input, { profile: "strict" });
|
|
19
|
+
* var g = b.guardJson.gate({ profile: "strict" });
|
|
20
|
+
*
|
|
21
|
+
* Threat catalog covered:
|
|
22
|
+
*
|
|
23
|
+
* 1. Prototype pollution — `__proto__` / `constructor` / `prototype`
|
|
24
|
+
* keys anywhere in the tree. Refused regardless of profile under
|
|
25
|
+
* strict; balanced strips; permissive audits.
|
|
26
|
+
*
|
|
27
|
+
* 2. Array-index pollution — non-numeric keys ON array values
|
|
28
|
+
* (Svelte devalue CVE-2025-57820 class). When operator JSON
|
|
29
|
+
* structurally has arrays, keys like `push` / `toString` injected
|
|
30
|
+
* via reviver/parser quirks pollute Array.prototype.
|
|
31
|
+
*
|
|
32
|
+
* 3. Depth bombs — nested arrays/objects past safe stack. Refused
|
|
33
|
+
* at the configured cap.
|
|
34
|
+
*
|
|
35
|
+
* 4. Breadth / key-count bombs — single object with massive key count
|
|
36
|
+
* (10⁶ keys → CPU + memory blow-up).
|
|
37
|
+
*
|
|
38
|
+
* 5. Duplicate keys — RFC 8259 says keys SHOULD be unique; in
|
|
39
|
+
* practice JSON.parse silently last-wins, which lets attackers
|
|
40
|
+
* smuggle duplicate-key payloads past validation that ran on the
|
|
41
|
+
* first occurrence (Bishop Fox JSON-interoperability research).
|
|
42
|
+
*
|
|
43
|
+
* 6. NaN / Infinity / -Infinity — RFC 8259 forbids; JSON5 / lenient
|
|
44
|
+
* parsers accept. Refused under strict.
|
|
45
|
+
*
|
|
46
|
+
* 7. Comments (single-line // and block-style) — RFC 8259 forbids;
|
|
47
|
+
* JSON5 / JSONC accept. Refused under strict.
|
|
48
|
+
*
|
|
49
|
+
* 8. Trailing commas — JSON5 accepts; refused under strict.
|
|
50
|
+
*
|
|
51
|
+
* 9. BOM injection — leading or mid-stream U+FEFF.
|
|
52
|
+
*
|
|
53
|
+
* 10. Bidi / null / control chars in string values — same codepoint
|
|
54
|
+
* catalog as guard-csv/html/svg via lib/codepoint-class.
|
|
55
|
+
*
|
|
56
|
+
* 11. Numeric precision loss — values above
|
|
57
|
+
* `Number.MAX_SAFE_INTEGER` silently lose precision when round-
|
|
58
|
+
* tripped through Number. Detected via raw-text scan for digit
|
|
59
|
+
* runs longer than safe-int width.
|
|
60
|
+
*
|
|
61
|
+
* 12. Total size cap (anti-DoS).
|
|
62
|
+
*
|
|
63
|
+
* 13. Top-level-key allowlist — strict profile requires the operator
|
|
64
|
+
* to declare allowed top-level keys; balanced/permissive skip.
|
|
65
|
+
*
|
|
66
|
+
* Source files MUST be pure ASCII per the codepoint-table programmatic
|
|
67
|
+
* regex pattern; threat-detection regexes use lib/codepoint-class.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
var codepointClass = require("./codepoint-class");
|
|
71
|
+
var lazyRequire = require("./lazy-require");
|
|
72
|
+
var gateContract = require("./gate-contract");
|
|
73
|
+
var C = require("./constants");
|
|
74
|
+
var numericBounds = require("./numeric-bounds");
|
|
75
|
+
var safeJson = require("./safe-json");
|
|
76
|
+
var { GuardJsonError } = require("./framework-error");
|
|
77
|
+
|
|
78
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
79
|
+
void observability;
|
|
80
|
+
|
|
81
|
+
var _err = GuardJsonError.factory;
|
|
82
|
+
|
|
83
|
+
// ---- Compiled detectors ----
|
|
84
|
+
|
|
85
|
+
var BIDI_RE = codepointClass.BIDI_RE;
|
|
86
|
+
var C0_CTRL_RE = codepointClass.C0_CTRL_RE;
|
|
87
|
+
var ZW_RE = codepointClass.ZERO_WIDTH_RE;
|
|
88
|
+
var NULL_BYTE = codepointClass.NULL_BYTE;
|
|
89
|
+
var BOM_CHAR = codepointClass.BOM_CHAR;
|
|
90
|
+
|
|
91
|
+
// Prototype-pollution key denylist. Operator-supplied JSON containing
|
|
92
|
+
// any of these keys at any depth is refused under strict.
|
|
93
|
+
var POLLUTION_KEYS = Object.freeze(["__proto__", "constructor", "prototype"]);
|
|
94
|
+
|
|
95
|
+
// Comment / NaN / Infinity / hex / single-quote markers — pre-parse
|
|
96
|
+
// scan on the raw source. JSON.parse rejects most of these, but JSON5
|
|
97
|
+
// / JSONC parsers accept and silently coerce; we surface the source
|
|
98
|
+
// shape so operators can refuse the whole input regardless of which
|
|
99
|
+
// parser their downstream code uses.
|
|
100
|
+
var COMMENT_LINE_RE = /(^|[^"\\])\/\/[^\r\n]*/m;
|
|
101
|
+
var COMMENT_BLOCK_RE = /\/\*[\s\S]*?\*\//;
|
|
102
|
+
var BARE_NAN_RE = /(^|[\s,:[{(])(NaN|Infinity|-Infinity|undefined)\b/;
|
|
103
|
+
var TRAILING_COMMA_RE = /,(\s*[\]}])/;
|
|
104
|
+
var SINGLE_QUOTED_KEY_RE = /(^|[\s,{])'[^']*'\s*:/;
|
|
105
|
+
var HEX_LITERAL_RE = /(^|[\s,:[{(])-?0[xX][0-9a-fA-F]+\b/;
|
|
106
|
+
|
|
107
|
+
// Numeric precision — runs of 16+ digits in a number context (above
|
|
108
|
+
// Number.MAX_SAFE_INTEGER ≈ 9.007e15 ≈ 16 digits).
|
|
109
|
+
var BIG_INTEGER_RE = /(^|[\s,:[{(])(-?\d{17,})(?:[\s,\]}]|$)/;
|
|
110
|
+
|
|
111
|
+
// Prototype-pollution key detector — SOURCE-level. After JSON.parse,
|
|
112
|
+
// __proto__ disappears from Object.keys() (it routes through the
|
|
113
|
+
// prototype setter), so a tree-walk after parse misses it. Scan the
|
|
114
|
+
// raw source for `"__proto__":`, `"constructor":`, `"prototype":`
|
|
115
|
+
// patterns to catch hostile sources before any parser sees them. The
|
|
116
|
+
// detection is conservative (matches inside strings too — operator's
|
|
117
|
+
// downstream code may run a JSON.parse without the safeJson reviver
|
|
118
|
+
// and the pollution still lands).
|
|
119
|
+
var POLLUTION_KEY_SOURCE_RE = /"(__proto__|constructor|prototype)"\s*:/;
|
|
120
|
+
|
|
121
|
+
// ---- Profile presets ----
|
|
122
|
+
|
|
123
|
+
var PROFILES = Object.freeze({
|
|
124
|
+
"strict": {
|
|
125
|
+
pollutionPolicy: "reject",
|
|
126
|
+
duplicateKeyPolicy: "reject",
|
|
127
|
+
nanInfinityPolicy: "reject",
|
|
128
|
+
commentPolicy: "reject",
|
|
129
|
+
trailingCommaPolicy: "reject",
|
|
130
|
+
json5SyntaxPolicy: "reject", // single-quoted / hex / unquoted-key
|
|
131
|
+
bomPolicy: "reject",
|
|
132
|
+
bidiPolicy: "reject",
|
|
133
|
+
controlPolicy: "reject",
|
|
134
|
+
nullBytePolicy: "reject",
|
|
135
|
+
zeroWidthPolicy: "reject",
|
|
136
|
+
numericPrecisionPolicy: "reject",
|
|
137
|
+
requireTopLevelKeyAllowlist: false, // operator opts in via topLevelKeyAllowlist
|
|
138
|
+
topLevelKeyAllowlist: null,
|
|
139
|
+
maxBytes: C.BYTES.mib(2),
|
|
140
|
+
maxDepth: 8, // allow:raw-byte-literal — recursion depth, not byte size
|
|
141
|
+
maxKeysPerObject: 256, // allow:raw-byte-literal — key count cap, not byte size
|
|
142
|
+
maxArrayLength: 1024, // allow:raw-byte-literal — array length cap, not byte size
|
|
143
|
+
maxStringLength: C.BYTES.kib(8),
|
|
144
|
+
maxTotalNodes: 0x2000, // allow:raw-byte-literal — node count cap, not byte size
|
|
145
|
+
},
|
|
146
|
+
"balanced": {
|
|
147
|
+
pollutionPolicy: "strip", // remove __proto__ keys silently
|
|
148
|
+
duplicateKeyPolicy: "audit",
|
|
149
|
+
nanInfinityPolicy: "reject",
|
|
150
|
+
commentPolicy: "audit",
|
|
151
|
+
trailingCommaPolicy: "audit",
|
|
152
|
+
json5SyntaxPolicy: "audit",
|
|
153
|
+
bomPolicy: "strip",
|
|
154
|
+
bidiPolicy: "strip",
|
|
155
|
+
controlPolicy: "strip",
|
|
156
|
+
nullBytePolicy: "strip",
|
|
157
|
+
zeroWidthPolicy: "strip",
|
|
158
|
+
numericPrecisionPolicy: "audit",
|
|
159
|
+
requireTopLevelKeyAllowlist: false,
|
|
160
|
+
topLevelKeyAllowlist: null,
|
|
161
|
+
maxBytes: C.BYTES.mib(8),
|
|
162
|
+
maxDepth: 32, // allow:raw-byte-literal — recursion depth, not byte size
|
|
163
|
+
maxKeysPerObject: 4096, // allow:raw-byte-literal — key count cap, not byte size
|
|
164
|
+
maxArrayLength: 65536, // allow:raw-byte-literal — array length cap, not byte size
|
|
165
|
+
maxStringLength: C.BYTES.kib(64),
|
|
166
|
+
maxTotalNodes: 0x10000, // allow:raw-byte-literal — node count cap, not byte size
|
|
167
|
+
},
|
|
168
|
+
"permissive": {
|
|
169
|
+
pollutionPolicy: "audit",
|
|
170
|
+
duplicateKeyPolicy: "audit",
|
|
171
|
+
nanInfinityPolicy: "audit",
|
|
172
|
+
commentPolicy: "audit",
|
|
173
|
+
trailingCommaPolicy: "audit",
|
|
174
|
+
json5SyntaxPolicy: "audit",
|
|
175
|
+
bomPolicy: "strip",
|
|
176
|
+
bidiPolicy: "audit",
|
|
177
|
+
controlPolicy: "strip",
|
|
178
|
+
nullBytePolicy: "reject",
|
|
179
|
+
zeroWidthPolicy: "strip",
|
|
180
|
+
numericPrecisionPolicy: "audit",
|
|
181
|
+
requireTopLevelKeyAllowlist: false,
|
|
182
|
+
topLevelKeyAllowlist: null,
|
|
183
|
+
maxBytes: C.BYTES.mib(64),
|
|
184
|
+
maxDepth: 64, // allow:raw-byte-literal — recursion depth, not byte size
|
|
185
|
+
maxKeysPerObject: 65536, // allow:raw-byte-literal — key count cap, not byte size
|
|
186
|
+
maxArrayLength: 1048576, // allow:raw-byte-literal — array length cap, not byte size
|
|
187
|
+
maxStringLength: C.BYTES.kib(256),
|
|
188
|
+
maxTotalNodes: 0x40000, // allow:raw-byte-literal — node count cap, not byte size
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
193
|
+
mode: "enforce",
|
|
194
|
+
maxRuntimeMs: C.TIME.seconds(10),
|
|
195
|
+
}));
|
|
196
|
+
|
|
197
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
198
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
199
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
200
|
+
}),
|
|
201
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
202
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
203
|
+
}),
|
|
204
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
205
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
206
|
+
}),
|
|
207
|
+
"soc2-cc7": Object.assign({}, PROFILES["strict"], {
|
|
208
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---- Helpers ----
|
|
213
|
+
|
|
214
|
+
function _resolveOpts(opts) {
|
|
215
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
216
|
+
profiles: PROFILES,
|
|
217
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
218
|
+
defaults: DEFAULTS,
|
|
219
|
+
errorClass: GuardJsonError,
|
|
220
|
+
errCodePrefix: "json",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _isPollutionKey(key) {
|
|
225
|
+
return POLLUTION_KEYS.indexOf(key) !== -1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// _scanPollutionKeys — walks parsed JSON tree counting prototype-
|
|
229
|
+
// pollution keys and depth + breadth + node-count exhaustion. Operator
|
|
230
|
+
// can either reject the whole tree or strip pollution keys.
|
|
231
|
+
function _scanTree(value, opts, ctx) {
|
|
232
|
+
if (!ctx) ctx = { depth: 0, totalNodes: 0, pollutionHits: [],
|
|
233
|
+
duplicateKeyHits: [], breadthCapHits: [],
|
|
234
|
+
arrayLenCapHits: [], depthCapHits: [],
|
|
235
|
+
stringTooLongHits: [] };
|
|
236
|
+
ctx.totalNodes += 1;
|
|
237
|
+
if (ctx.totalNodes > opts.maxTotalNodes) {
|
|
238
|
+
ctx.depthCapHits.push({ kind: "node-count-cap",
|
|
239
|
+
snippet: "node count exceeds maxTotalNodes " + opts.maxTotalNodes });
|
|
240
|
+
return ctx;
|
|
241
|
+
}
|
|
242
|
+
if (ctx.depth > opts.maxDepth) {
|
|
243
|
+
ctx.depthCapHits.push({ kind: "depth-cap",
|
|
244
|
+
snippet: "depth " + ctx.depth + " exceeds maxDepth " + opts.maxDepth });
|
|
245
|
+
return ctx;
|
|
246
|
+
}
|
|
247
|
+
if (value === null || typeof value !== "object") {
|
|
248
|
+
if (typeof value === "string" && value.length > opts.maxStringLength) {
|
|
249
|
+
ctx.stringTooLongHits.push({
|
|
250
|
+
kind: "string-too-long",
|
|
251
|
+
snippet: "string length " + value.length +
|
|
252
|
+
" exceeds maxStringLength " + opts.maxStringLength,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return ctx;
|
|
256
|
+
}
|
|
257
|
+
if (Array.isArray(value)) {
|
|
258
|
+
if (value.length > opts.maxArrayLength) {
|
|
259
|
+
ctx.arrayLenCapHits.push({
|
|
260
|
+
kind: "array-length-cap",
|
|
261
|
+
snippet: "array length " + value.length +
|
|
262
|
+
" exceeds maxArrayLength " + opts.maxArrayLength,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
for (var i = 0; i < value.length; i += 1) {
|
|
266
|
+
ctx.depth += 1;
|
|
267
|
+
_scanTree(value[i], opts, ctx);
|
|
268
|
+
ctx.depth -= 1;
|
|
269
|
+
}
|
|
270
|
+
return ctx;
|
|
271
|
+
}
|
|
272
|
+
// Plain object.
|
|
273
|
+
var keys = Object.keys(value);
|
|
274
|
+
if (keys.length > opts.maxKeysPerObject) {
|
|
275
|
+
ctx.breadthCapHits.push({
|
|
276
|
+
kind: "key-count-cap",
|
|
277
|
+
snippet: "object key count " + keys.length +
|
|
278
|
+
" exceeds maxKeysPerObject " + opts.maxKeysPerObject,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
for (var ki = 0; ki < keys.length; ki += 1) {
|
|
282
|
+
var k = keys[ki];
|
|
283
|
+
if (_isPollutionKey(k)) {
|
|
284
|
+
ctx.pollutionHits.push({
|
|
285
|
+
kind: "prototype-pollution-key",
|
|
286
|
+
snippet: "prototype-pollution key " + JSON.stringify(k) +
|
|
287
|
+
" at depth " + ctx.depth,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
ctx.depth += 1;
|
|
291
|
+
_scanTree(value[k], opts, ctx);
|
|
292
|
+
ctx.depth -= 1;
|
|
293
|
+
}
|
|
294
|
+
return ctx;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// _scanRawSource — pre-parse text scan for syntax-level threats that
|
|
298
|
+
// vanish after JSON.parse normalizes them: comments, trailing commas,
|
|
299
|
+
// NaN/Infinity, hex literals, single-quoted keys, BOM, big-integer
|
|
300
|
+
// precision-loss candidates.
|
|
301
|
+
function _scanRawSource(text, opts) {
|
|
302
|
+
var issues = [];
|
|
303
|
+
if (text.indexOf(BOM_CHAR) === 0 && opts.bomPolicy !== "allow") {
|
|
304
|
+
issues.push({
|
|
305
|
+
kind: "bom-leading", severity: "high", ruleId: "json.bom",
|
|
306
|
+
snippet: "leading BOM (U+FEFF)",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (text.indexOf(BOM_CHAR) > 0 && opts.bomPolicy !== "allow") {
|
|
310
|
+
issues.push({
|
|
311
|
+
kind: "bom-mid-stream", severity: "high", ruleId: "json.bom",
|
|
312
|
+
snippet: "BOM mid-stream",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
// Commented forms.
|
|
316
|
+
if (opts.commentPolicy !== "allow") {
|
|
317
|
+
if (COMMENT_BLOCK_RE.test(text)) { // allow:regex-no-length-cap — text bounded by maxBytes above
|
|
318
|
+
issues.push({
|
|
319
|
+
kind: "comment-block", severity: "high", ruleId: "json.comment",
|
|
320
|
+
snippet: "block comment /* ... */ (RFC 8259 forbids; JSON5/JSONC accept)",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (COMMENT_LINE_RE.test(text)) { // allow:regex-no-length-cap — text bounded by maxBytes above
|
|
324
|
+
issues.push({
|
|
325
|
+
kind: "comment-line", severity: "high", ruleId: "json.comment",
|
|
326
|
+
snippet: "line comment // (RFC 8259 forbids; JSON5/JSONC accept)",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (opts.nanInfinityPolicy !== "allow" && BARE_NAN_RE.test(text)) { // allow:regex-no-length-cap — text bounded by maxBytes above
|
|
331
|
+
issues.push({
|
|
332
|
+
kind: "nan-infinity", severity: "high", ruleId: "json.nan-infinity",
|
|
333
|
+
snippet: "bare NaN / Infinity / undefined token (RFC 8259 forbids)",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (opts.trailingCommaPolicy !== "allow" && TRAILING_COMMA_RE.test(text)) { // allow:regex-no-length-cap — text bounded by maxBytes above
|
|
337
|
+
issues.push({
|
|
338
|
+
kind: "trailing-comma", severity: "high", ruleId: "json.trailing-comma",
|
|
339
|
+
snippet: "trailing comma (RFC 8259 forbids)",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (opts.json5SyntaxPolicy !== "allow") {
|
|
343
|
+
if (SINGLE_QUOTED_KEY_RE.test(text)) { // allow:regex-no-length-cap — text bounded by maxBytes above
|
|
344
|
+
issues.push({
|
|
345
|
+
kind: "single-quoted-key", severity: "high", ruleId: "json.json5-syntax",
|
|
346
|
+
snippet: "single-quoted key (JSON5 only; not RFC 8259)",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (HEX_LITERAL_RE.test(text)) { // allow:regex-no-length-cap — text bounded by maxBytes above
|
|
350
|
+
issues.push({
|
|
351
|
+
kind: "hex-literal", severity: "high", ruleId: "json.json5-syntax",
|
|
352
|
+
snippet: "hex numeric literal (JSON5 only; not RFC 8259)",
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (opts.numericPrecisionPolicy !== "allow" && BIG_INTEGER_RE.test(text)) { // allow:regex-no-length-cap — text bounded by maxBytes above
|
|
357
|
+
issues.push({
|
|
358
|
+
kind: "numeric-precision-loss", severity: "warn",
|
|
359
|
+
ruleId: "json.numeric-precision",
|
|
360
|
+
snippet: "integer above Number.MAX_SAFE_INTEGER (precision loss)",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
// Prototype-pollution source scan — catches __proto__/constructor/
|
|
364
|
+
// prototype keys before any downstream parser sees them. Critical
|
|
365
|
+
// when the operator's downstream code uses raw JSON.parse without
|
|
366
|
+
// safeJson's reviver.
|
|
367
|
+
if (opts.pollutionPolicy !== "allow") {
|
|
368
|
+
var protoIter = text.matchAll(/"(__proto__|constructor|prototype)"\s*:/g);
|
|
369
|
+
var protoMatch;
|
|
370
|
+
for (protoMatch of protoIter) {
|
|
371
|
+
issues.push({
|
|
372
|
+
kind: "prototype-pollution-key",
|
|
373
|
+
severity: opts.pollutionPolicy === "reject" ? "critical" : "high",
|
|
374
|
+
ruleId: "json.prototype-pollution",
|
|
375
|
+
location: protoMatch.index,
|
|
376
|
+
snippet: "prototype-pollution key " + JSON.stringify(protoMatch[1]) +
|
|
377
|
+
" at byte " + protoMatch.index +
|
|
378
|
+
" (CVE-2025-55182 / CVE-2025-57820 class)",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Bidi / null / control via shared codepoint class.
|
|
383
|
+
issues.push.apply(issues, codepointClass.detectCharThreats(text, opts, "json"));
|
|
384
|
+
if (opts.zeroWidthPolicy !== "allow" && opts.zeroWidthPolicy !== "strip" &&
|
|
385
|
+
ZW_RE.test(text)) { // allow:regex-no-length-cap — text bounded by maxBytes above
|
|
386
|
+
issues.push({
|
|
387
|
+
kind: "zero-width", severity: "warn", ruleId: "json.zero-width",
|
|
388
|
+
snippet: "zero-width / invisible-formatting char in JSON source",
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return issues;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// _detectIssues — full validate path: raw-source pre-scan + parse +
|
|
395
|
+
// tree walk.
|
|
396
|
+
function _detectIssues(input, opts) {
|
|
397
|
+
var issues = [];
|
|
398
|
+
if (typeof input !== "string") {
|
|
399
|
+
return [{ kind: "bad-input", severity: "high",
|
|
400
|
+
snippet: "input is not a string" }];
|
|
401
|
+
}
|
|
402
|
+
if (input.length > opts.maxBytes) {
|
|
403
|
+
return [{ kind: "too-large", severity: "high", ruleId: "json.too-large",
|
|
404
|
+
snippet: "input " + input.length +
|
|
405
|
+
" bytes exceeds maxBytes " + opts.maxBytes }];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Raw-source pre-scan.
|
|
409
|
+
issues = issues.concat(_scanRawSource(input, opts));
|
|
410
|
+
|
|
411
|
+
// Try parsing — bail early on syntax errors so the operator gets a
|
|
412
|
+
// usable error rather than crashing the validator. safeJson.parse
|
|
413
|
+
// already enforces top-level depth/size caps and returns an Error
|
|
414
|
+
// on malformed input.
|
|
415
|
+
var parsed;
|
|
416
|
+
try {
|
|
417
|
+
parsed = safeJson.parse(input, {
|
|
418
|
+
maxBytes: opts.maxBytes,
|
|
419
|
+
maxDepth: opts.maxDepth,
|
|
420
|
+
});
|
|
421
|
+
} catch (e) {
|
|
422
|
+
issues.push({
|
|
423
|
+
kind: "parse-failed", severity: "critical", ruleId: "json.parse",
|
|
424
|
+
snippet: "JSON parse failed: " + (e && e.message),
|
|
425
|
+
});
|
|
426
|
+
return issues;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Top-level-key allowlist check.
|
|
430
|
+
if (opts.requireTopLevelKeyAllowlist || Array.isArray(opts.topLevelKeyAllowlist)) {
|
|
431
|
+
if (!Array.isArray(opts.topLevelKeyAllowlist)) {
|
|
432
|
+
issues.push({
|
|
433
|
+
kind: "missing-allowlist", severity: "high",
|
|
434
|
+
ruleId: "json.top-level-allowlist",
|
|
435
|
+
snippet: "requireTopLevelKeyAllowlist set but topLevelKeyAllowlist is null",
|
|
436
|
+
});
|
|
437
|
+
} else if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
438
|
+
var topKeys = Object.keys(parsed);
|
|
439
|
+
var allow = opts.topLevelKeyAllowlist;
|
|
440
|
+
for (var tki = 0; tki < topKeys.length; tki += 1) {
|
|
441
|
+
if (allow.indexOf(topKeys[tki]) === -1) {
|
|
442
|
+
issues.push({
|
|
443
|
+
kind: "top-level-key-not-allowlisted", severity: "high",
|
|
444
|
+
ruleId: "json.top-level-allowlist",
|
|
445
|
+
snippet: "top-level key " + JSON.stringify(topKeys[tki]) +
|
|
446
|
+
" not in topLevelKeyAllowlist",
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Tree walk for depth / breadth / string-length / array-length /
|
|
454
|
+
// node-count caps. Pollution-key detection happens at the source
|
|
455
|
+
// level (above) — after JSON.parse, __proto__ is invisible to
|
|
456
|
+
// Object.keys() (routes through the prototype setter), so a post-
|
|
457
|
+
// parse walk misses the pollution-source case.
|
|
458
|
+
var ctx = _scanTree(parsed, opts);
|
|
459
|
+
for (var bi = 0; bi < ctx.breadthCapHits.length; bi += 1) {
|
|
460
|
+
issues.push(Object.assign({ severity: "high",
|
|
461
|
+
ruleId: "json.breadth-cap" }, ctx.breadthCapHits[bi]));
|
|
462
|
+
}
|
|
463
|
+
for (var ai = 0; ai < ctx.arrayLenCapHits.length; ai += 1) {
|
|
464
|
+
issues.push(Object.assign({ severity: "high",
|
|
465
|
+
ruleId: "json.array-length-cap" }, ctx.arrayLenCapHits[ai]));
|
|
466
|
+
}
|
|
467
|
+
for (var di = 0; di < ctx.depthCapHits.length; di += 1) {
|
|
468
|
+
issues.push(Object.assign({ severity: "high",
|
|
469
|
+
ruleId: "json.depth-cap" }, ctx.depthCapHits[di]));
|
|
470
|
+
}
|
|
471
|
+
for (var si = 0; si < ctx.stringTooLongHits.length; si += 1) {
|
|
472
|
+
issues.push(Object.assign({ severity: "high",
|
|
473
|
+
ruleId: "json.string-too-long" }, ctx.stringTooLongHits[si]));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Duplicate-key detection — JSON.parse silently last-wins. We re-
|
|
477
|
+
// scan the source for duplicate keys at the same nesting depth.
|
|
478
|
+
if (opts.duplicateKeyPolicy !== "allow") {
|
|
479
|
+
var dups = _detectDuplicateKeys(input);
|
|
480
|
+
for (var dki = 0; dki < dups.length; dki += 1) {
|
|
481
|
+
issues.push({
|
|
482
|
+
kind: "duplicate-key",
|
|
483
|
+
severity: opts.duplicateKeyPolicy === "reject" ? "critical" : "warn",
|
|
484
|
+
ruleId: "json.duplicate-key",
|
|
485
|
+
snippet: "duplicate key " + JSON.stringify(dups[dki]) +
|
|
486
|
+
" (RFC 8259 SHOULD-unique; last-wins silently)",
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return issues;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// _detectDuplicateKeys — minimal source scan that counts identical
|
|
495
|
+
// quoted keys at the same `{ ... }` nesting level. Not a full parser;
|
|
496
|
+
// catches the common `{"a":1,"a":2}` shape and similar.
|
|
497
|
+
function _detectDuplicateKeys(text) {
|
|
498
|
+
var seen = [Object.create(null)]; // stack of scopes, top = current
|
|
499
|
+
var dups = Object.create(null);
|
|
500
|
+
var len = text.length;
|
|
501
|
+
var i = 0;
|
|
502
|
+
while (i < len) {
|
|
503
|
+
var c = text.charAt(i);
|
|
504
|
+
if (c === "{") { seen.push(Object.create(null)); i += 1; continue; }
|
|
505
|
+
if (c === "}") { if (seen.length > 1) seen.pop(); i += 1; continue; }
|
|
506
|
+
if (c === '"') {
|
|
507
|
+
// Read the string up to its closing quote.
|
|
508
|
+
var start = i + 1;
|
|
509
|
+
var p = start;
|
|
510
|
+
while (p < len) {
|
|
511
|
+
var cp = text.charAt(p);
|
|
512
|
+
if (cp === "\\") { p += 2; continue; }
|
|
513
|
+
if (cp === '"') break;
|
|
514
|
+
p += 1;
|
|
515
|
+
}
|
|
516
|
+
var keyText = text.slice(start, p);
|
|
517
|
+
i = p + 1;
|
|
518
|
+
// Skip whitespace; if next non-whitespace is `:`, this string is
|
|
519
|
+
// an object key.
|
|
520
|
+
while (i < len && /\s/.test(text.charAt(i))) i += 1;
|
|
521
|
+
if (i < len && text.charAt(i) === ":") {
|
|
522
|
+
var scope = seen[seen.length - 1];
|
|
523
|
+
if (scope[keyText] === true) dups[keyText] = true;
|
|
524
|
+
else scope[keyText] = true;
|
|
525
|
+
i += 1;
|
|
526
|
+
}
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
i += 1;
|
|
530
|
+
}
|
|
531
|
+
return Object.keys(dups);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// _stripPollutionTree — recursively delete __proto__/constructor/
|
|
535
|
+
// prototype keys from the parsed tree. Used by sanitize when policy is
|
|
536
|
+
// "strip". Walks Object.create(null)-shaped clones so the cleaned tree
|
|
537
|
+
// has no prototype pollution.
|
|
538
|
+
function _stripPollutionTree(value, opts, depth) {
|
|
539
|
+
depth = depth || 0;
|
|
540
|
+
if (depth > opts.maxDepth) return value;
|
|
541
|
+
if (value === null || typeof value !== "object") return value;
|
|
542
|
+
if (Array.isArray(value)) {
|
|
543
|
+
var out = [];
|
|
544
|
+
for (var i = 0; i < value.length; i += 1) {
|
|
545
|
+
out.push(_stripPollutionTree(value[i], opts, depth + 1));
|
|
546
|
+
}
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
var keys = Object.keys(value);
|
|
550
|
+
var clean = Object.create(null);
|
|
551
|
+
for (var ki = 0; ki < keys.length; ki += 1) {
|
|
552
|
+
var k = keys[ki];
|
|
553
|
+
if (_isPollutionKey(k)) continue;
|
|
554
|
+
clean[k] = _stripPollutionTree(value[k], opts, depth + 1);
|
|
555
|
+
}
|
|
556
|
+
return clean;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---- Public surface ----
|
|
560
|
+
|
|
561
|
+
function validate(input, opts) {
|
|
562
|
+
opts = _resolveOpts(opts);
|
|
563
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
564
|
+
["maxBytes", "maxDepth", "maxKeysPerObject", "maxArrayLength",
|
|
565
|
+
"maxStringLength", "maxTotalNodes"],
|
|
566
|
+
"guardJson.validate", GuardJsonError, "json.bad-opt");
|
|
567
|
+
if (typeof input !== "string") {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
issues: [{ kind: "bad-input", severity: "high",
|
|
571
|
+
snippet: "input is not a string" }],
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return gateContract.aggregateIssues(_detectIssues(input, opts));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function parse(input, opts) {
|
|
578
|
+
opts = _resolveOpts(opts);
|
|
579
|
+
if (typeof input !== "string") {
|
|
580
|
+
throw _err("json.bad-input", "parse requires string input");
|
|
581
|
+
}
|
|
582
|
+
// Strip BOM if policy says strip.
|
|
583
|
+
if (opts.bomPolicy === "strip" && input.indexOf(BOM_CHAR) === 0) {
|
|
584
|
+
input = input.slice(1);
|
|
585
|
+
}
|
|
586
|
+
// Strip control chars from the source (rare in practice; refused
|
|
587
|
+
// under strict; balanced/permissive allow strip).
|
|
588
|
+
if (opts.controlPolicy === "strip") {
|
|
589
|
+
input = input.replace(codepointClass.C0_CTRL_RE_G, "");
|
|
590
|
+
}
|
|
591
|
+
if (opts.zeroWidthPolicy === "strip") {
|
|
592
|
+
input = input.replace(codepointClass.ZW_RE_G, "");
|
|
593
|
+
}
|
|
594
|
+
// Source-level pollution check — refuse early when policy is reject.
|
|
595
|
+
if (opts.pollutionPolicy === "reject" && POLLUTION_KEY_SOURCE_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxBytes above
|
|
596
|
+
throw _err("json.prototype-pollution",
|
|
597
|
+
"guardJson.parse: source contains prototype-pollution key " +
|
|
598
|
+
"(__proto__ / constructor / prototype)");
|
|
599
|
+
}
|
|
600
|
+
// Refuse on other critical pre-parse threats per policy.
|
|
601
|
+
var preIssues = _scanRawSource(input, opts);
|
|
602
|
+
for (var pi = 0; pi < preIssues.length; pi += 1) {
|
|
603
|
+
var issue = preIssues[pi];
|
|
604
|
+
if (issue.kind === "prototype-pollution-key") continue; // handled above
|
|
605
|
+
if (issue.severity === "critical" ||
|
|
606
|
+
(issue.severity === "high" &&
|
|
607
|
+
opts[_policyKeyForRuleId(issue.ruleId)] === "reject")) {
|
|
608
|
+
throw _err(issue.ruleId, "guardJson.parse: " + issue.snippet);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// safeJson.parse strips POISONED_KEYS via the reviver pass; this is
|
|
612
|
+
// the canonical strip path. allowProto=true preserves them for the
|
|
613
|
+
// permissive/audit path.
|
|
614
|
+
var allowProto = opts.pollutionPolicy === "allow" ||
|
|
615
|
+
opts.pollutionPolicy === "audit";
|
|
616
|
+
var parsed;
|
|
617
|
+
try {
|
|
618
|
+
parsed = safeJson.parse(input, {
|
|
619
|
+
maxBytes: opts.maxBytes,
|
|
620
|
+
maxDepth: opts.maxDepth,
|
|
621
|
+
allowProto: allowProto,
|
|
622
|
+
});
|
|
623
|
+
} catch (e) {
|
|
624
|
+
throw _err("json.parse", "guardJson.parse: " + (e && e.message));
|
|
625
|
+
}
|
|
626
|
+
return parsed;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function _policyKeyForRuleId(ruleId) {
|
|
630
|
+
// Map issue ruleId → opts policy key for the reject-decision lookup.
|
|
631
|
+
var map = {
|
|
632
|
+
"json.bom": "bomPolicy",
|
|
633
|
+
"json.comment": "commentPolicy",
|
|
634
|
+
"json.nan-infinity": "nanInfinityPolicy",
|
|
635
|
+
"json.trailing-comma": "trailingCommaPolicy",
|
|
636
|
+
"json.json5-syntax": "json5SyntaxPolicy",
|
|
637
|
+
"json.numeric-precision": "numericPrecisionPolicy",
|
|
638
|
+
"json.bidi": "bidiPolicy",
|
|
639
|
+
"json.control": "controlPolicy",
|
|
640
|
+
"json.null-byte": "nullBytePolicy",
|
|
641
|
+
"json.zero-width": "zeroWidthPolicy",
|
|
642
|
+
};
|
|
643
|
+
return map[ruleId] || null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function gate(opts) {
|
|
647
|
+
opts = _resolveOpts(opts);
|
|
648
|
+
return gateContract.buildGuardGate(
|
|
649
|
+
opts.name || "guardJson:" + (opts.profile || "default"),
|
|
650
|
+
opts,
|
|
651
|
+
async function (ctx) {
|
|
652
|
+
var text = gateContract.extractBytesAsText(ctx);
|
|
653
|
+
if (!text) return { ok: true, action: "serve" };
|
|
654
|
+
var rv = validate(text, opts);
|
|
655
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
656
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
657
|
+
return i.severity === "critical" || i.severity === "high";
|
|
658
|
+
});
|
|
659
|
+
if (!hasCritical) return { ok: true, action: "audit-only", issues: rv.issues };
|
|
660
|
+
|
|
661
|
+
// Sanitize-eligibility: every reject-policy must be off.
|
|
662
|
+
var canSanitize = opts.pollutionPolicy !== "reject" &&
|
|
663
|
+
opts.duplicateKeyPolicy !== "reject" &&
|
|
664
|
+
opts.nanInfinityPolicy !== "reject" &&
|
|
665
|
+
opts.commentPolicy !== "reject" &&
|
|
666
|
+
opts.trailingCommaPolicy !== "reject" &&
|
|
667
|
+
opts.json5SyntaxPolicy !== "reject" &&
|
|
668
|
+
opts.bomPolicy !== "reject" &&
|
|
669
|
+
opts.bidiPolicy !== "reject" &&
|
|
670
|
+
opts.controlPolicy !== "reject" &&
|
|
671
|
+
opts.nullBytePolicy !== "reject";
|
|
672
|
+
if (canSanitize) {
|
|
673
|
+
try {
|
|
674
|
+
var clean = parse(text, opts);
|
|
675
|
+
var emitted = JSON.stringify(clean);
|
|
676
|
+
return {
|
|
677
|
+
ok: true, action: "sanitize",
|
|
678
|
+
sanitized: Buffer.from(emitted, "utf8"),
|
|
679
|
+
issues: rv.issues,
|
|
680
|
+
};
|
|
681
|
+
} catch (_e) { /* fall through */ }
|
|
682
|
+
}
|
|
683
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
688
|
+
|
|
689
|
+
function compliancePosture(name) {
|
|
690
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES, _err, "json");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
var _jsonRulePacks = gateContract.makeRulePackLoader(GuardJsonError, "json");
|
|
694
|
+
var loadRulePack = _jsonRulePacks.load;
|
|
695
|
+
|
|
696
|
+
module.exports = {
|
|
697
|
+
// ---- guard-* family registry exports ----
|
|
698
|
+
NAME: "json",
|
|
699
|
+
KIND: "content",
|
|
700
|
+
MIME_TYPES: Object.freeze([
|
|
701
|
+
"application/json", "application/ld+json", "application/vnd.api+json",
|
|
702
|
+
]),
|
|
703
|
+
EXTENSIONS: Object.freeze([".json", ".jsonld"]),
|
|
704
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
705
|
+
kind: "content",
|
|
706
|
+
contentType: "application/json",
|
|
707
|
+
extension: ".json",
|
|
708
|
+
benignBytes: Buffer.from('{"name":"alice","age":30}', "utf8"),
|
|
709
|
+
// Hostile: prototype-pollution payload (CVE-2025-55182 React Server
|
|
710
|
+
// Functions class; CVE-2025-57820 Svelte devalue class).
|
|
711
|
+
hostileBytes: Buffer.from('{"__proto__":{"polluted":true}}', "utf8"),
|
|
712
|
+
}),
|
|
713
|
+
// ---- primitive surface ----
|
|
714
|
+
validate: validate,
|
|
715
|
+
parse: parse,
|
|
716
|
+
gate: gate,
|
|
717
|
+
buildProfile: buildProfile,
|
|
718
|
+
compliancePosture: compliancePosture,
|
|
719
|
+
loadRulePack: loadRulePack,
|
|
720
|
+
PROFILES: PROFILES,
|
|
721
|
+
DEFAULTS: DEFAULTS,
|
|
722
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
723
|
+
POLLUTION_KEYS: POLLUTION_KEYS,
|
|
724
|
+
GuardJsonError: GuardJsonError,
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
void BIDI_RE; // referenced via codepointClass.detectCharThreats; binding kept for clarity
|
|
728
|
+
void C0_CTRL_RE;
|
|
729
|
+
void NULL_BYTE;
|