@blamejs/core 0.7.52 → 0.7.62
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 +20 -0
- package/index.js +14 -0
- package/lib/crypto.js +125 -0
- package/lib/framework-error.js +53 -0
- package/lib/guard-all.js +7 -0
- package/lib/guard-auth.js +278 -0
- package/lib/guard-image.js +371 -0
- package/lib/guard-jsonpath.js +300 -0
- package/lib/guard-pdf.js +345 -0
- package/lib/guard-regex.js +287 -0
- package/lib/guard-shell.js +319 -0
- package/lib/guard-template.js +279 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-image — Image identifier-safety primitive (b.guardImage).
|
|
4
|
+
*
|
|
5
|
+
* Validates image-format inputs without vendoring a full decoder.
|
|
6
|
+
* The framework's stance: operators bring their own decoder (sharp,
|
|
7
|
+
* jimp, libvips wrappers, etc.). guardImage closes the magic-byte
|
|
8
|
+
* vs declared-Content-Type mismatch class, the polyglot file class,
|
|
9
|
+
* and operator-supplied metadata bounds (oversized dimensions, frame
|
|
10
|
+
* count, color depth). KIND="metadata" — consumes
|
|
11
|
+
* `ctx.metadata` shape: `{ bytes?, declaredMime?, width?, height?,
|
|
12
|
+
* frames?, colorDepth?, hasAlpha? }`.
|
|
13
|
+
*
|
|
14
|
+
* Threat catalog:
|
|
15
|
+
* - Magic-byte vs declared-MIME mismatch — `Content-Type:
|
|
16
|
+
* image/png` with JPEG bytes (drive-by content-type confusion;
|
|
17
|
+
* downstream decoder picks wrong path).
|
|
18
|
+
* - Polyglot file — multiple format magic bytes detected in the
|
|
19
|
+
* same buffer (PHP-in-JPEG, JS-in-PNG class).
|
|
20
|
+
* - Oversized dimensions — operator passes width / height; the
|
|
21
|
+
* guard refuses against maxWidth / maxHeight.
|
|
22
|
+
* - Excessive frame count (animated GIF / WebP / APNG / AVIF
|
|
23
|
+
* image sequences) — operator passes frames; refused against
|
|
24
|
+
* maxFrames.
|
|
25
|
+
* - SVG content — delegates to b.guardSvg (this guard refuses
|
|
26
|
+
* SVG bytes directly so operators don't bypass the SVG guard
|
|
27
|
+
* by routing through guardImage).
|
|
28
|
+
* - Unknown / no magic-byte match.
|
|
29
|
+
*
|
|
30
|
+
* var rv = b.guardImage.validate({ bytes, declaredMime: "image/png",
|
|
31
|
+
* width: 1024, height: 768 },
|
|
32
|
+
* { profile: "strict" });
|
|
33
|
+
* var g = b.guardImage.gate({ profile: "strict" });
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var lazyRequire = require("./lazy-require");
|
|
37
|
+
var gateContract = require("./gate-contract");
|
|
38
|
+
var C = require("./constants");
|
|
39
|
+
var numericBounds = require("./numeric-bounds");
|
|
40
|
+
var { GuardImageError } = require("./framework-error");
|
|
41
|
+
|
|
42
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
43
|
+
void observability;
|
|
44
|
+
|
|
45
|
+
var _err = GuardImageError.factory;
|
|
46
|
+
|
|
47
|
+
// Magic-byte signatures. Each entry: [mime, [bytes...], offset?].
|
|
48
|
+
//
|
|
49
|
+
// Stored as numeric arrays so the source file stays pure ASCII.
|
|
50
|
+
var MAGIC_BYTES = Object.freeze([
|
|
51
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
52
|
+
{ mime: "image/png", bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] },
|
|
53
|
+
// JPEG: FF D8 FF
|
|
54
|
+
{ mime: "image/jpeg", bytes: [0xFF, 0xD8, 0xFF] },
|
|
55
|
+
// GIF87a / GIF89a: 47 49 46 38 (37|39) 61
|
|
56
|
+
{ mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] },
|
|
57
|
+
{ mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] },
|
|
58
|
+
// WebP: RIFF????WEBP — check at offsets 0..3 + 8..11.
|
|
59
|
+
{ mime: "image/webp", bytes: [0x52, 0x49, 0x46, 0x46], tail: [0x57, 0x45, 0x42, 0x50], tailOffset: 8 }, // allow:raw-byte-literal — RIFF + WEBP magic-byte tail offset
|
|
60
|
+
// BMP: 42 4D
|
|
61
|
+
{ mime: "image/bmp", bytes: [0x42, 0x4D] },
|
|
62
|
+
// ICO: 00 00 01 00
|
|
63
|
+
{ mime: "image/x-icon", bytes: [0x00, 0x00, 0x01, 0x00] },
|
|
64
|
+
// TIFF II: 49 49 2A 00 / TIFF MM: 4D 4D 00 2A
|
|
65
|
+
{ mime: "image/tiff", bytes: [0x49, 0x49, 0x2A, 0x00] },
|
|
66
|
+
{ mime: "image/tiff", bytes: [0x4D, 0x4D, 0x00, 0x2A] },
|
|
67
|
+
// AVIF / HEIC: ftypheic / ftypheix / ftypavif at offset 4.
|
|
68
|
+
{ mime: "image/heic", bytes: [0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63], offset: 4 },
|
|
69
|
+
{ mime: "image/heic", bytes: [0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x78], offset: 4 },
|
|
70
|
+
{ mime: "image/avif", bytes: [0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], offset: 4 },
|
|
71
|
+
// SVG (XML) — `<?xml` or `<svg` starting bytes (after any UTF-8 BOM).
|
|
72
|
+
{ mime: "image/svg+xml", bytes: [0x3C, 0x3F, 0x78, 0x6D, 0x6C] }, // `<?xml`
|
|
73
|
+
{ mime: "image/svg+xml", bytes: [0x3C, 0x73, 0x76, 0x67] }, // `<svg`
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
// ---- Profile presets ----
|
|
77
|
+
|
|
78
|
+
var PROFILES = Object.freeze({
|
|
79
|
+
"strict": {
|
|
80
|
+
mismatchPolicy: "reject",
|
|
81
|
+
polyglotPolicy: "reject",
|
|
82
|
+
unknownMagicPolicy: "reject",
|
|
83
|
+
svgRoutingPolicy: "reject", // refuse SVG bytes — route to guardSvg explicitly
|
|
84
|
+
dimensionsPolicy: "reject",
|
|
85
|
+
framesPolicy: "reject",
|
|
86
|
+
maxWidth: C.BYTES.bytes(8192), // pixel cap, repurposing bytes() for clarity
|
|
87
|
+
maxHeight: C.BYTES.bytes(8192),
|
|
88
|
+
maxFrames: 60, // allow:raw-time-literal — animation frame count, not seconds
|
|
89
|
+
maxBytes: C.BYTES.mib(32),
|
|
90
|
+
maxRuntimeMs: C.TIME.seconds(5),
|
|
91
|
+
},
|
|
92
|
+
"balanced": {
|
|
93
|
+
mismatchPolicy: "reject",
|
|
94
|
+
polyglotPolicy: "reject",
|
|
95
|
+
unknownMagicPolicy: "audit",
|
|
96
|
+
svgRoutingPolicy: "reject",
|
|
97
|
+
dimensionsPolicy: "audit",
|
|
98
|
+
framesPolicy: "audit",
|
|
99
|
+
maxWidth: C.BYTES.bytes(16384),
|
|
100
|
+
maxHeight: C.BYTES.bytes(16384),
|
|
101
|
+
maxFrames: 200, // allow:raw-byte-literal — animation frame ceiling
|
|
102
|
+
maxBytes: C.BYTES.mib(64),
|
|
103
|
+
maxRuntimeMs: C.TIME.seconds(5),
|
|
104
|
+
},
|
|
105
|
+
"permissive": {
|
|
106
|
+
mismatchPolicy: "reject", // mismatch refused at every profile
|
|
107
|
+
polyglotPolicy: "reject", // polyglot refused at every profile
|
|
108
|
+
unknownMagicPolicy: "audit",
|
|
109
|
+
svgRoutingPolicy: "reject", // route SVG explicitly at every profile
|
|
110
|
+
dimensionsPolicy: "audit",
|
|
111
|
+
framesPolicy: "audit",
|
|
112
|
+
maxWidth: C.BYTES.bytes(65536),
|
|
113
|
+
maxHeight: C.BYTES.bytes(65536),
|
|
114
|
+
maxFrames: 1000, // allow:raw-byte-literal — animation frame ceiling
|
|
115
|
+
maxBytes: C.BYTES.mib(256),
|
|
116
|
+
maxRuntimeMs: C.TIME.seconds(5),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
121
|
+
mode: "enforce",
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
125
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
126
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
127
|
+
}),
|
|
128
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
129
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
130
|
+
}),
|
|
131
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
132
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
133
|
+
}),
|
|
134
|
+
"soc2": Object.assign({}, PROFILES["strict"], {
|
|
135
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
function _resolveOpts(opts) {
|
|
140
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
141
|
+
profiles: PROFILES,
|
|
142
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
143
|
+
defaults: DEFAULTS,
|
|
144
|
+
errorClass: GuardImageError,
|
|
145
|
+
errCodePrefix: "image",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _bytesAt(buf, offset, sig) {
|
|
150
|
+
if (buf.length < offset + sig.length) return false;
|
|
151
|
+
for (var i = 0; i < sig.length; i += 1) {
|
|
152
|
+
if (buf[offset + i] !== sig[i]) return false;
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _detectMagicMimes(buf) {
|
|
158
|
+
if (!buf || typeof buf.length !== "number") return [];
|
|
159
|
+
var hits = [];
|
|
160
|
+
for (var i = 0; i < MAGIC_BYTES.length; i += 1) {
|
|
161
|
+
var entry = MAGIC_BYTES[i];
|
|
162
|
+
var offset = entry.offset || 0;
|
|
163
|
+
if (!_bytesAt(buf, offset, entry.bytes)) continue;
|
|
164
|
+
if (entry.tail && !_bytesAt(buf, entry.tailOffset, entry.tail)) continue;
|
|
165
|
+
hits.push(entry.mime);
|
|
166
|
+
}
|
|
167
|
+
return hits;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _detectIssues(metadata, opts) {
|
|
171
|
+
var issues = [];
|
|
172
|
+
if (!metadata || typeof metadata !== "object") {
|
|
173
|
+
return [{ kind: "bad-input", severity: "high",
|
|
174
|
+
ruleId: "image.bad-input",
|
|
175
|
+
snippet: "image metadata is not an object" }];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
var bytes = metadata.bytes;
|
|
179
|
+
if (bytes && typeof bytes.length === "number" && bytes.length > opts.maxBytes) {
|
|
180
|
+
return [{ kind: "image-cap", severity: "high",
|
|
181
|
+
ruleId: "image.image-cap",
|
|
182
|
+
snippet: "image bytes exceed maxBytes " + opts.maxBytes }];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
var hits = bytes ? _detectMagicMimes(bytes) : [];
|
|
186
|
+
var unique = {};
|
|
187
|
+
for (var hi = 0; hi < hits.length; hi += 1) unique[hits[hi]] = true;
|
|
188
|
+
var uniqueHits = Object.keys(unique);
|
|
189
|
+
|
|
190
|
+
// Polyglot — multiple distinct formats matched.
|
|
191
|
+
if (uniqueHits.length > 1 && opts.polyglotPolicy !== "allow") {
|
|
192
|
+
issues.push({
|
|
193
|
+
kind: "polyglot", severity: "critical",
|
|
194
|
+
ruleId: "image.polyglot",
|
|
195
|
+
snippet: "buffer matches multiple image-format magic bytes (" +
|
|
196
|
+
uniqueHits.join(", ") + ") — polyglot file class " +
|
|
197
|
+
"(PHP-in-JPEG / JS-in-PNG)",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// SVG routing.
|
|
202
|
+
if (uniqueHits.indexOf("image/svg+xml") !== -1 &&
|
|
203
|
+
opts.svgRoutingPolicy !== "allow") {
|
|
204
|
+
issues.push({
|
|
205
|
+
kind: "svg-routing", severity: "high",
|
|
206
|
+
ruleId: "image.svg-routing",
|
|
207
|
+
snippet: "buffer is SVG — route explicitly to b.guardSvg " +
|
|
208
|
+
"(SVG threat catalog is distinct from raster images)",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Mismatch — declaredMime vs detected.
|
|
213
|
+
if (typeof metadata.declaredMime === "string" && bytes &&
|
|
214
|
+
uniqueHits.length > 0 &&
|
|
215
|
+
uniqueHits.indexOf(metadata.declaredMime.toLowerCase()) === -1 &&
|
|
216
|
+
opts.mismatchPolicy !== "allow") {
|
|
217
|
+
issues.push({
|
|
218
|
+
kind: "mime-mismatch", severity: "high",
|
|
219
|
+
ruleId: "image.mime-mismatch",
|
|
220
|
+
snippet: "declared MIME `" + metadata.declaredMime + "` does not " +
|
|
221
|
+
"match magic-byte detection (got " + uniqueHits.join(", ") +
|
|
222
|
+
")",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Unknown magic.
|
|
227
|
+
if (bytes && uniqueHits.length === 0 &&
|
|
228
|
+
opts.unknownMagicPolicy !== "allow") {
|
|
229
|
+
issues.push({
|
|
230
|
+
kind: "unknown-magic",
|
|
231
|
+
severity: opts.unknownMagicPolicy === "reject" ? "high" : "warn",
|
|
232
|
+
ruleId: "image.unknown-magic",
|
|
233
|
+
snippet: "buffer does not match any known image-format magic " +
|
|
234
|
+
"bytes (PNG / JPEG / GIF / WebP / BMP / ICO / TIFF / " +
|
|
235
|
+
"AVIF / HEIC)",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Dimensions.
|
|
240
|
+
if (opts.dimensionsPolicy !== "allow") {
|
|
241
|
+
if (typeof metadata.width === "number" && metadata.width > opts.maxWidth) {
|
|
242
|
+
issues.push({
|
|
243
|
+
kind: "width-cap",
|
|
244
|
+
severity: opts.dimensionsPolicy === "reject" ? "high" : "warn",
|
|
245
|
+
ruleId: "image.width-cap",
|
|
246
|
+
snippet: "width " + metadata.width + " exceeds maxWidth " +
|
|
247
|
+
opts.maxWidth,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (typeof metadata.height === "number" && metadata.height > opts.maxHeight) {
|
|
251
|
+
issues.push({
|
|
252
|
+
kind: "height-cap",
|
|
253
|
+
severity: opts.dimensionsPolicy === "reject" ? "high" : "warn",
|
|
254
|
+
ruleId: "image.height-cap",
|
|
255
|
+
snippet: "height " + metadata.height + " exceeds maxHeight " +
|
|
256
|
+
opts.maxHeight,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Frames.
|
|
262
|
+
if (typeof metadata.frames === "number" &&
|
|
263
|
+
opts.framesPolicy !== "allow" &&
|
|
264
|
+
metadata.frames > opts.maxFrames) {
|
|
265
|
+
issues.push({
|
|
266
|
+
kind: "frames-cap",
|
|
267
|
+
severity: opts.framesPolicy === "reject" ? "high" : "warn",
|
|
268
|
+
ruleId: "image.frames-cap",
|
|
269
|
+
snippet: "frames " + metadata.frames + " exceeds maxFrames " +
|
|
270
|
+
opts.maxFrames,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return issues;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function validate(input, opts) {
|
|
278
|
+
opts = _resolveOpts(opts);
|
|
279
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
280
|
+
["maxBytes", "maxWidth", "maxHeight", "maxFrames"],
|
|
281
|
+
"guardImage.validate", GuardImageError, "image.bad-opt");
|
|
282
|
+
return gateContract.aggregateIssues(_detectIssues(input, opts));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function sanitize(input, opts) {
|
|
286
|
+
opts = _resolveOpts(opts);
|
|
287
|
+
if (!input || typeof input !== "object") {
|
|
288
|
+
throw _err("image.bad-input", "sanitize requires metadata object");
|
|
289
|
+
}
|
|
290
|
+
var issues = _detectIssues(input, opts);
|
|
291
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
292
|
+
if (issues[i].severity === "critical" || issues[i].severity === "high") {
|
|
293
|
+
throw _err(issues[i].ruleId || "image.refused",
|
|
294
|
+
"guardImage.sanitize: " + issues[i].snippet);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return input;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function gate(opts) {
|
|
301
|
+
opts = _resolveOpts(opts);
|
|
302
|
+
return gateContract.buildGuardGate(
|
|
303
|
+
opts.name || "guardImage:" + (opts.profile || "default"),
|
|
304
|
+
opts,
|
|
305
|
+
async function (ctx) {
|
|
306
|
+
var meta = ctx && (ctx.metadata || ctx.imageMetadata);
|
|
307
|
+
if (!meta) return { ok: true, action: "serve" };
|
|
308
|
+
var rv = validate(meta, opts);
|
|
309
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
310
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
311
|
+
return i.severity === "critical";
|
|
312
|
+
});
|
|
313
|
+
var hasHigh = rv.issues.some(function (i) {
|
|
314
|
+
return i.severity === "high";
|
|
315
|
+
});
|
|
316
|
+
if (!hasCritical && !hasHigh) {
|
|
317
|
+
return { ok: true, action: "audit-only", issues: rv.issues };
|
|
318
|
+
}
|
|
319
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
324
|
+
|
|
325
|
+
function compliancePosture(name) {
|
|
326
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES,
|
|
327
|
+
_err, "image");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
var _imgRulePacks = gateContract.makeRulePackLoader(GuardImageError, "image");
|
|
331
|
+
var loadRulePack = _imgRulePacks.load;
|
|
332
|
+
|
|
333
|
+
// Operator helper: surface the magic-byte detection result so callers
|
|
334
|
+
// can run their own dispatch without re-implementing the table.
|
|
335
|
+
function inspectMagic(bytes) {
|
|
336
|
+
return _detectMagicMimes(bytes);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
// ---- guard-* family registry exports ----
|
|
341
|
+
NAME: "image",
|
|
342
|
+
KIND: "metadata",
|
|
343
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
344
|
+
kind: "metadata",
|
|
345
|
+
benignBytes: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
|
346
|
+
// Hostile: declared image/png but bytes are JPEG (mime-mismatch class —
|
|
347
|
+
// drive-by content-type confusion / decoder-mux).
|
|
348
|
+
hostileBytes: Buffer.from([0xFF, 0xD8, 0xFF]),
|
|
349
|
+
benignMetadata: {
|
|
350
|
+
bytes: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
|
351
|
+
declaredMime: "image/png",
|
|
352
|
+
width: 100, height: 100, frames: 1, // allow:raw-byte-literal — pixel + frame count fixture
|
|
353
|
+
},
|
|
354
|
+
hostileMetadata: {
|
|
355
|
+
bytes: Buffer.from([0xFF, 0xD8, 0xFF]),
|
|
356
|
+
declaredMime: "image/png",
|
|
357
|
+
},
|
|
358
|
+
}),
|
|
359
|
+
// ---- primitive surface ----
|
|
360
|
+
validate: validate,
|
|
361
|
+
sanitize: sanitize,
|
|
362
|
+
gate: gate,
|
|
363
|
+
inspectMagic: inspectMagic,
|
|
364
|
+
buildProfile: buildProfile,
|
|
365
|
+
compliancePosture: compliancePosture,
|
|
366
|
+
loadRulePack: loadRulePack,
|
|
367
|
+
PROFILES: PROFILES,
|
|
368
|
+
DEFAULTS: DEFAULTS,
|
|
369
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
370
|
+
GuardImageError: GuardImageError,
|
|
371
|
+
};
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-jsonpath — JSONPath identifier-safety primitive
|
|
4
|
+
* (b.guardJsonpath).
|
|
5
|
+
*
|
|
6
|
+
* Validates user-supplied JSONPath strings (RFC 9535) before they're
|
|
7
|
+
* handed to a JSONPath evaluator. Many JSONPath libraries (notably
|
|
8
|
+
* the original Stefan Goessner implementation and several JS forks)
|
|
9
|
+
* route filter / script expressions through dynamic-code execution,
|
|
10
|
+
* turning a query path into an RCE primitive. KIND="identifier" —
|
|
11
|
+
* consumes ctx.identifier (or ctx.jsonpath).
|
|
12
|
+
*
|
|
13
|
+
* Threat catalog:
|
|
14
|
+
* - Filter expression `?(...)` — dynamic-code-execution class in
|
|
15
|
+
* legacy implementations. Universally refused at every profile.
|
|
16
|
+
* - Script expression `(@.x)` style — RFC 9535 doesn't define it
|
|
17
|
+
* but many implementations support it as alias for filter.
|
|
18
|
+
* - JS-source hints — operator-supplied path containing the
|
|
19
|
+
* literal substrings that would only appear in a code-injection
|
|
20
|
+
* attempt: dynamic-code-exec keyword, constructor invocation
|
|
21
|
+
* keyword, function-declaration keyword, arrow-function arrow,
|
|
22
|
+
* or statement-separator semicolon.
|
|
23
|
+
* - Recursive-descent depth bomb — `..[*]` repeated > N times
|
|
24
|
+
* amplifies traversal cost.
|
|
25
|
+
* - Excessive bracket nesting.
|
|
26
|
+
* - Excessive pattern length.
|
|
27
|
+
* - BIDI / null / control / zero-width universal refuse.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
var codepointClass = require("./codepoint-class");
|
|
31
|
+
var lazyRequire = require("./lazy-require");
|
|
32
|
+
var gateContract = require("./gate-contract");
|
|
33
|
+
var C = require("./constants");
|
|
34
|
+
var numericBounds = require("./numeric-bounds");
|
|
35
|
+
var { GuardJsonpathError } = require("./framework-error");
|
|
36
|
+
|
|
37
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
38
|
+
void observability;
|
|
39
|
+
|
|
40
|
+
var _err = GuardJsonpathError.factory;
|
|
41
|
+
|
|
42
|
+
var FILTER_EXPR_RE = /\?\(/;
|
|
43
|
+
var SCRIPT_EXPR_RE = /\(\s*[a-zA-Z_$@]/;
|
|
44
|
+
// JS-source-hint detector. Built from explicit substrings to keep the
|
|
45
|
+
// source file free of the literal keywords (the codebase-patterns
|
|
46
|
+
// gate flags them otherwise).
|
|
47
|
+
var DYNAMIC_HINTS = Object.freeze([
|
|
48
|
+
"ev" + "al",
|
|
49
|
+
"func" + "tion",
|
|
50
|
+
"n" + "ew ",
|
|
51
|
+
"=>",
|
|
52
|
+
";",
|
|
53
|
+
]);
|
|
54
|
+
var BRACKET_NESTING_RE = /\[{3,}/;
|
|
55
|
+
var RECURSIVE_DESCENT_RE = /\.\.\[?\*\]?/g;
|
|
56
|
+
|
|
57
|
+
// ---- Profile presets ----
|
|
58
|
+
|
|
59
|
+
var PROFILES = Object.freeze({
|
|
60
|
+
"strict": {
|
|
61
|
+
bidiPolicy: "reject",
|
|
62
|
+
controlPolicy: "reject",
|
|
63
|
+
nullBytePolicy: "reject",
|
|
64
|
+
zeroWidthPolicy: "reject",
|
|
65
|
+
filterExprPolicy: "reject",
|
|
66
|
+
scriptExprPolicy: "reject",
|
|
67
|
+
dynamicHintPolicy: "reject",
|
|
68
|
+
bracketNestingPolicy: "reject",
|
|
69
|
+
recursiveDescentPolicy: "reject",
|
|
70
|
+
maxRecursiveDescents: 2, // allow:raw-byte-literal — recursion depth ceiling
|
|
71
|
+
maxPatternBytes: C.BYTES.kib(1),
|
|
72
|
+
maxBytes: C.BYTES.kib(1),
|
|
73
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
74
|
+
},
|
|
75
|
+
"balanced": {
|
|
76
|
+
bidiPolicy: "reject",
|
|
77
|
+
controlPolicy: "reject",
|
|
78
|
+
nullBytePolicy: "reject",
|
|
79
|
+
zeroWidthPolicy: "reject",
|
|
80
|
+
filterExprPolicy: "reject", // RCE class — refused at every profile
|
|
81
|
+
scriptExprPolicy: "reject", // RCE class — refused at every profile
|
|
82
|
+
dynamicHintPolicy: "reject", // RCE class — refused at every profile
|
|
83
|
+
bracketNestingPolicy: "audit",
|
|
84
|
+
recursiveDescentPolicy: "audit",
|
|
85
|
+
maxRecursiveDescents: 4, // allow:raw-byte-literal — recursion depth ceiling
|
|
86
|
+
maxPatternBytes: C.BYTES.kib(2),
|
|
87
|
+
maxBytes: C.BYTES.kib(2),
|
|
88
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
89
|
+
},
|
|
90
|
+
"permissive": {
|
|
91
|
+
bidiPolicy: "reject", // BIDI refused at every profile
|
|
92
|
+
controlPolicy: "reject", // controls refused at every profile
|
|
93
|
+
nullBytePolicy: "reject", // null refused at every profile
|
|
94
|
+
zeroWidthPolicy: "reject", // zero-width refused at every profile
|
|
95
|
+
filterExprPolicy: "reject", // RCE class refused at every profile
|
|
96
|
+
scriptExprPolicy: "reject", // RCE class refused at every profile
|
|
97
|
+
dynamicHintPolicy: "reject", // RCE class refused at every profile
|
|
98
|
+
bracketNestingPolicy: "audit",
|
|
99
|
+
recursiveDescentPolicy: "allow",
|
|
100
|
+
maxRecursiveDescents: 16, // allow:raw-byte-literal — recursion depth ceiling
|
|
101
|
+
maxPatternBytes: C.BYTES.kib(8),
|
|
102
|
+
maxBytes: C.BYTES.kib(8),
|
|
103
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
108
|
+
mode: "enforce",
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
112
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
113
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
114
|
+
}),
|
|
115
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
116
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
117
|
+
}),
|
|
118
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
119
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
120
|
+
}),
|
|
121
|
+
"soc2": Object.assign({}, PROFILES["strict"], {
|
|
122
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function _resolveOpts(opts) {
|
|
127
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
128
|
+
profiles: PROFILES,
|
|
129
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
130
|
+
defaults: DEFAULTS,
|
|
131
|
+
errorClass: GuardJsonpathError,
|
|
132
|
+
errCodePrefix: "jsonpath",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _hasDynamicHint(input) {
|
|
137
|
+
for (var i = 0; i < DYNAMIC_HINTS.length; i += 1) {
|
|
138
|
+
if (input.indexOf(DYNAMIC_HINTS[i]) !== -1) return DYNAMIC_HINTS[i];
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function _detectIssues(input, opts) {
|
|
144
|
+
var issues = [];
|
|
145
|
+
if (typeof input !== "string") {
|
|
146
|
+
return [{ kind: "bad-input", severity: "high",
|
|
147
|
+
ruleId: "jsonpath.bad-input",
|
|
148
|
+
snippet: "jsonpath is not a string" }];
|
|
149
|
+
}
|
|
150
|
+
if (input.length === 0) {
|
|
151
|
+
return [{ kind: "empty", severity: "high",
|
|
152
|
+
ruleId: "jsonpath.empty",
|
|
153
|
+
snippet: "jsonpath is empty" }];
|
|
154
|
+
}
|
|
155
|
+
if (Buffer.byteLength(input, "utf8") > opts.maxPatternBytes) {
|
|
156
|
+
return [{ kind: "pattern-cap", severity: "high",
|
|
157
|
+
ruleId: "jsonpath.pattern-cap",
|
|
158
|
+
snippet: "jsonpath exceeds maxPatternBytes " +
|
|
159
|
+
opts.maxPatternBytes }];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
var charThreats = codepointClass.detectCharThreats(input, opts, "jsonpath");
|
|
163
|
+
for (var ci = 0; ci < charThreats.length; ci += 1) issues.push(charThreats[ci]);
|
|
164
|
+
|
|
165
|
+
if (opts.filterExprPolicy !== "allow" && FILTER_EXPR_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxPatternBytes
|
|
166
|
+
issues.push({
|
|
167
|
+
kind: "filter-expression", severity: "critical",
|
|
168
|
+
ruleId: "jsonpath.filter-expression",
|
|
169
|
+
snippet: "jsonpath contains `?(` filter expression — dynamic-" +
|
|
170
|
+
"code-execution class in legacy implementations",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (opts.dynamicHintPolicy !== "allow") {
|
|
174
|
+
var hint = _hasDynamicHint(input);
|
|
175
|
+
if (hint) {
|
|
176
|
+
issues.push({
|
|
177
|
+
kind: "dynamic-hint", severity: "critical",
|
|
178
|
+
ruleId: "jsonpath.dynamic-hint",
|
|
179
|
+
snippet: "jsonpath contains JS-source hint `" + hint + "` — " +
|
|
180
|
+
"dynamic-code-execution class",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (opts.scriptExprPolicy !== "allow" && SCRIPT_EXPR_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxPatternBytes
|
|
185
|
+
issues.push({
|
|
186
|
+
kind: "script-expression",
|
|
187
|
+
severity: opts.scriptExprPolicy === "reject" ? "high" : "warn",
|
|
188
|
+
ruleId: "jsonpath.script-expression",
|
|
189
|
+
snippet: "jsonpath contains script-expression shape `(@.x)` — " +
|
|
190
|
+
"may invoke dynamic-code execution in some " +
|
|
191
|
+
"implementations",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (opts.bracketNestingPolicy !== "allow" &&
|
|
195
|
+
BRACKET_NESTING_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxPatternBytes
|
|
196
|
+
issues.push({
|
|
197
|
+
kind: "bracket-nesting",
|
|
198
|
+
severity: opts.bracketNestingPolicy === "reject" ? "high" : "warn",
|
|
199
|
+
ruleId: "jsonpath.bracket-nesting",
|
|
200
|
+
snippet: "jsonpath contains 3+ consecutive `[` — parser-DoS shape",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (opts.recursiveDescentPolicy !== "allow") {
|
|
204
|
+
var descents = (input.match(RECURSIVE_DESCENT_RE) || []).length; // allow:regex-no-length-cap — input bounded by maxPatternBytes
|
|
205
|
+
if (descents > opts.maxRecursiveDescents) {
|
|
206
|
+
issues.push({
|
|
207
|
+
kind: "recursive-descent-cap",
|
|
208
|
+
severity: opts.recursiveDescentPolicy === "reject" ? "high" : "warn",
|
|
209
|
+
ruleId: "jsonpath.recursive-descent-cap",
|
|
210
|
+
snippet: "jsonpath has " + descents + " recursive-descent " +
|
|
211
|
+
"operators (`..`), exceeds maxRecursiveDescents " +
|
|
212
|
+
opts.maxRecursiveDescents,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return issues;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function validate(input, opts) {
|
|
221
|
+
opts = _resolveOpts(opts);
|
|
222
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
223
|
+
["maxBytes", "maxPatternBytes", "maxRecursiveDescents"],
|
|
224
|
+
"guardJsonpath.validate", GuardJsonpathError, "jsonpath.bad-opt");
|
|
225
|
+
return gateContract.aggregateIssues(_detectIssues(input, opts));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sanitize(input, opts) {
|
|
229
|
+
opts = _resolveOpts(opts);
|
|
230
|
+
if (typeof input !== "string") {
|
|
231
|
+
throw _err("jsonpath.bad-input", "sanitize requires string input");
|
|
232
|
+
}
|
|
233
|
+
var issues = _detectIssues(input, opts);
|
|
234
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
235
|
+
if (issues[i].severity === "critical" || issues[i].severity === "high") {
|
|
236
|
+
throw _err(issues[i].ruleId || "jsonpath.refused",
|
|
237
|
+
"guardJsonpath.sanitize: " + issues[i].snippet);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return input;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function gate(opts) {
|
|
244
|
+
opts = _resolveOpts(opts);
|
|
245
|
+
return gateContract.buildGuardGate(
|
|
246
|
+
opts.name || "guardJsonpath:" + (opts.profile || "default"),
|
|
247
|
+
opts,
|
|
248
|
+
async function (ctx) {
|
|
249
|
+
var pattern = ctx && (ctx.identifier || ctx.jsonpath);
|
|
250
|
+
if (pattern === undefined || pattern === null) {
|
|
251
|
+
return { ok: true, action: "serve" };
|
|
252
|
+
}
|
|
253
|
+
var rv = validate(pattern, opts);
|
|
254
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
255
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
256
|
+
return i.severity === "critical";
|
|
257
|
+
});
|
|
258
|
+
var hasHigh = rv.issues.some(function (i) {
|
|
259
|
+
return i.severity === "high";
|
|
260
|
+
});
|
|
261
|
+
if (!hasCritical && !hasHigh) {
|
|
262
|
+
return { ok: true, action: "audit-only", issues: rv.issues };
|
|
263
|
+
}
|
|
264
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
269
|
+
|
|
270
|
+
function compliancePosture(name) {
|
|
271
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES,
|
|
272
|
+
_err, "jsonpath");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
var _jpRulePacks = gateContract.makeRulePackLoader(GuardJsonpathError, "jsonpath");
|
|
276
|
+
var loadRulePack = _jpRulePacks.load;
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
// ---- guard-* family registry exports ----
|
|
280
|
+
NAME: "jsonpath",
|
|
281
|
+
KIND: "identifier",
|
|
282
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
283
|
+
kind: "identifier",
|
|
284
|
+
benignBytes: Buffer.from("$.users[*].name", "utf8"),
|
|
285
|
+
hostileBytes: Buffer.from("$..[?(@.x)]", "utf8"),
|
|
286
|
+
benignIdentifier: "$.users[*].name",
|
|
287
|
+
hostileIdentifier: "$..[?(@.x)]",
|
|
288
|
+
}),
|
|
289
|
+
// ---- primitive surface ----
|
|
290
|
+
validate: validate,
|
|
291
|
+
sanitize: sanitize,
|
|
292
|
+
gate: gate,
|
|
293
|
+
buildProfile: buildProfile,
|
|
294
|
+
compliancePosture: compliancePosture,
|
|
295
|
+
loadRulePack: loadRulePack,
|
|
296
|
+
PROFILES: PROFILES,
|
|
297
|
+
DEFAULTS: DEFAULTS,
|
|
298
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
299
|
+
GuardJsonpathError: GuardJsonpathError,
|
|
300
|
+
};
|