@blamejs/blamejs-shop 0.4.91 → 0.4.92
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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/vendor/MANIFEST.json +49 -41
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/SECURITY.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +208 -2
- package/lib/vendor/blamejs/examples/wiki/test/e2e.js +7 -4
- package/lib/vendor/blamejs/examples/wiki/test/integration.js +15 -12
- package/lib/vendor/blamejs/index.js +2 -0
- package/lib/vendor/blamejs/lib/audit-sign.js +34 -1
- package/lib/vendor/blamejs/lib/backup/manifest.js +191 -44
- package/lib/vendor/blamejs/lib/codepoint-class.js +284 -77
- package/lib/vendor/blamejs/lib/framework-error.js +14 -0
- package/lib/vendor/blamejs/lib/fsm.js +80 -24
- package/lib/vendor/blamejs/lib/log.js +32 -0
- package/lib/vendor/blamejs/lib/middleware/rate-limit.js +18 -2
- package/lib/vendor/blamejs/lib/middleware/request-id.js +24 -4
- package/lib/vendor/blamejs/lib/request-helpers.js +50 -0
- package/lib/vendor/blamejs/lib/safe-path.js +24 -10
- package/lib/vendor/blamejs/lib/sql.js +133 -0
- package/lib/vendor/blamejs/lib/totp.js +98 -33
- package/lib/vendor/blamejs/lib/ws-client.js +39 -28
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.21.json +51 -0
- package/lib/vendor/blamejs/release-notes/v0.15.22.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.15.23.json +22 -0
- package/lib/vendor/blamejs/test/00-primitives.js +80 -0
- package/lib/vendor/blamejs/test/_smoke-worker.js +81 -0
- package/lib/vendor/blamejs/test/integration/federation-auth.test.js +7 -4
- package/lib/vendor/blamejs/test/integration/mail-crypto-smime.test.js +7 -4
- package/lib/vendor/blamejs/test/layer-0-primitives/backup-manifest-signature.test.js +91 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +65 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codepoint-class.test.js +58 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/defineguard-default-gate-posture-caps.test.js +5 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/dpop-middleware-replaystore-required.test.js +9 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/fsm.test.js +99 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/money.test.js +30 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/otlp-attr-redaction.test.js +9 -6
- package/lib/vendor/blamejs/test/layer-0-primitives/rate-limit-xff-spoofing.test.js +36 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/request-id-async-context.test.js +117 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-path.test.js +64 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +96 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +55 -0
- package/lib/vendor/blamejs/test/smoke.js +93 -10
- package/package.json +1 -1
|
@@ -5170,6 +5170,48 @@ var KNOWN_ANTIPATTERNS = [
|
|
|
5170
5170
|
allowlist: ["lib/x509-chain.js"],
|
|
5171
5171
|
reason: "basicConstraints cA:TRUE enforcement is owned by x509Chain.issuerValidlyIssued / x509Chain.isCaCert; tsa/mail-bimi/mail-crypto-smime route through it. Any lib file calling X.checkIssued(Y) (Y!=X) directly bypasses the cA check and must use x509Chain instead. lib/x509-chain.js is the home of the primitive.",
|
|
5172
5172
|
},
|
|
5173
|
+
{
|
|
5174
|
+
id: "fingerprint-pin-against-claimed-field-not-recomputed",
|
|
5175
|
+
primitive: "b.auditSign.fingerprintOf",
|
|
5176
|
+
scanScope: "lib",
|
|
5177
|
+
skipCommentLines: true,
|
|
5178
|
+
// A fingerprint PIN (expectedFingerprint, supplied out-of-band by the
|
|
5179
|
+
// verifier) must be checked against a fingerprint RECOMPUTED from the
|
|
5180
|
+
// signature block's own publicKey — fingerprintOf(block.publicKey) — NOT
|
|
5181
|
+
// the block's self-asserted `.fingerprint` field. An attacker controls that
|
|
5182
|
+
// field: sign arbitrary bytes with their OWN key, set `fingerprint` to the
|
|
5183
|
+
// trusted value, and a `block.fingerprint === expectedFingerprint` check
|
|
5184
|
+
// passes while the signature still verifies under the attacker's key (the
|
|
5185
|
+
// backup-manifest verifyBytes/verifySignature P1 substitution bug). Binding
|
|
5186
|
+
// the pin to fingerprintOf(publicKey) — the key the signature is actually
|
|
5187
|
+
// verified under — closes the substitution. Fires on either argument order.
|
|
5188
|
+
regex: /(?:[\w.]+\.fingerprint\s*[!=]==\s*[\w.]*\bexpectedFingerprint\b|\bexpectedFingerprint\b\s*[!=]==\s*[\w.]+\.fingerprint\b)/,
|
|
5189
|
+
allowlist: [],
|
|
5190
|
+
reason: "A fingerprint pin must be compared against auditSign.fingerprintOf(<the block's publicKey>) — the key the signature verifies under — never the block's untrusted self-asserted `.fingerprint` field (an attacker sets that to the trusted value while signing with their own key). backup/manifest.js _verifyPayloadAgainstBlock recomputes via derivedFingerprint = fingerprintOf(sig.publicKey). Any `X.fingerprint === expectedFingerprint` (either order) re-introduces the substitution bypass and must recompute instead.",
|
|
5191
|
+
},
|
|
5192
|
+
{
|
|
5193
|
+
id: "platform-parameterized-containment-resolves-with-runtime-path-module",
|
|
5194
|
+
primitive: "b.safePath (target-platform lexical resolve)",
|
|
5195
|
+
scanScope: "lib",
|
|
5196
|
+
skipCommentLines: true,
|
|
5197
|
+
// A path validator parameterized by opts.platform must resolve AND bound its
|
|
5198
|
+
// LEXICAL containment with the TARGET platform's path module
|
|
5199
|
+
// (`pathMod = isWin ? nodePath.win32 : nodePath.posix`), not the runtime
|
|
5200
|
+
// node:path. The per-segment walk already splits on the target separator
|
|
5201
|
+
// (`sep = isWin ? /[\\/]/ : /\//`); if the lexical resolve uses runtime
|
|
5202
|
+
// semantics they disagree. On a POSIX host validating opts.platform:
|
|
5203
|
+
// "windows", runtime node:path treats `\` as an ordinary filename char, so
|
|
5204
|
+
// `ok\..\..\outside` was NOT collapsed and slipped past the boundary slice —
|
|
5205
|
+
// resolving to `<base>/ok\..\..\outside`, a path that escapes the base once a
|
|
5206
|
+
// Windows consumer reads the backslashes (Codex P1, PR #372). The realpath
|
|
5207
|
+
// check hits the live filesystem and legitimately keeps a SEPARATE runtime
|
|
5208
|
+
// resolve under distinctly-named vars (rtBaseResolved / rtJoined / rtSep), so
|
|
5209
|
+
// those don't match this anchor. Fires if the lexical `joined` reverts to
|
|
5210
|
+
// `nodePath.resolve(baseResolved, ...)` or `sepChar` to `nodePath.sep`.
|
|
5211
|
+
regex: /\bjoined\s*=\s*nodePath\.resolve\s*\(\s*baseResolved|\bvar\s+sepChar\s*=\s*nodePath\.sep\b/,
|
|
5212
|
+
allowlist: [],
|
|
5213
|
+
reason: "b.safePath.resolve refuses traversal lexically by resolving rel under base and slicing on the containment boundary. When validating for a non-runtime platform (opts.platform), the lexical resolve + boundary separator MUST come from the target module (pathMod = isWin ? nodePath.win32 : nodePath.posix) so they share the per-segment walk's platform semantics — otherwise a separator the target treats as a delimiter (the Windows backslash) is treated as a filename char by the runtime resolver and a cross-platform `ok\\..\\..\\outside` traversal escapes the base. The realpath check keeps a separate runtime resolve (rtBaseResolved/rtJoined/rtSep) because it touches the live FS. Reverting the lexical `joined`/`sepChar` to runtime nodePath re-opens the cross-platform traversal hole.",
|
|
5214
|
+
},
|
|
5173
5215
|
{
|
|
5174
5216
|
id: "compose-pipeline-settle-on-response-ended-not-return",
|
|
5175
5217
|
primitive: "b.middleware.composePipeline",
|
|
@@ -9360,6 +9402,29 @@ var KNOWN_ANTIPATTERNS = [
|
|
|
9360
9402
|
allowlist: [],
|
|
9361
9403
|
reason: "#123 macOS codebase-patterns watchdog hang. _scanShardInWorker rejected on worker error/exit without w.terminate(), so an errored worker thread stayed alive holding open handles; the parent then could not exit and the smoke run ran to the 25-min watchdog on memory-starved macOS-arm64 runners (it hung this very release's CI). Every settle path must reap the worker via w.terminate() first; the fix funnels message/error/exit through a settle() guard that terminates before resolve/reject. Fires on the bare `w.once(\"error\", reject)` shape; silent once error/exit route through settle().",
|
|
9362
9404
|
},
|
|
9405
|
+
{
|
|
9406
|
+
// A test file must invoke its run()/IIFE ONLY under
|
|
9407
|
+
// `if (require.main === module)`. The smoke worker REQUIRES each test
|
|
9408
|
+
// module and then awaits its exported run(); a module-level `run()` (or
|
|
9409
|
+
// `run().then(...process.exit...)`) at column 0 fires a SECOND, unawaited
|
|
9410
|
+
// run() at require-time that races the worker's result print — and if it
|
|
9411
|
+
// calls process.exit() it exits the worker BEFORE the result line is
|
|
9412
|
+
// written, which the parent reports as the unattributable "no result line"
|
|
9413
|
+
// / "fork failed". Found via the worker's late-error + leaked-handle audit
|
|
9414
|
+
// sweep (defineguard-default-gate-posture-caps / dpop-middleware-
|
|
9415
|
+
// replaystore-required / otlp-attr-redaction + two integration files), the
|
|
9416
|
+
// same slow-runner-flake root as #123. Export run and guard the
|
|
9417
|
+
// self-execution. Structural test-harness-contract drift a behavioral test
|
|
9418
|
+
// can't assert (the race only surfaces under require, intermittently on a
|
|
9419
|
+
// slow runner) — the detector is the guard.
|
|
9420
|
+
id: "test-unguarded-module-level-run",
|
|
9421
|
+
primitive: "a test file's run()/self-execution must sit under `if (require.main === module)` (export run for the smoke worker to await) — a bare module-level run() at column 0 re-runs at require-time and races / exits the worker",
|
|
9422
|
+
scanScope: "test",
|
|
9423
|
+
regex: /^run\s*\(/m,
|
|
9424
|
+
skipCommentLines: true,
|
|
9425
|
+
allowlist: [],
|
|
9426
|
+
reason: "The smoke worker requires each test module and awaits its exported run(); a column-0 `run()` / `run().then(...process.exit...)` fires a second unawaited run() at require-time that races the worker's result print (and process.exit() exits before the result line, read as 'no result line' / 'fork failed' on a slow runner). Export `run` and wrap the invocation in `if (require.main === module)`. Fires on any `run(` at the start of a line; `function run()`, `module.exports = { run }`, and an indented `run()` inside the require-main guard stay silent.",
|
|
9427
|
+
},
|
|
9363
9428
|
{
|
|
9364
9429
|
// `Promise + setTimeout` direct sleep in tests is forbidden;
|
|
9365
9430
|
// tests waiting on an asynchronous condition MUST use
|
|
@@ -65,9 +65,67 @@ function testFirstControlCharOffset() {
|
|
|
65
65
|
check("firstControlCharOffset: CR found when only allowLf", g("a\rb", { allowLf: true }) === 1);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// #332 — the catalog is now exported on the public b. surface so a consumer
|
|
69
|
+
// can build a custom free-text screen without reaching into the internal
|
|
70
|
+
// module path or re-rolling the bidi / control / zero-width regexes.
|
|
71
|
+
function testPublicSurface() {
|
|
72
|
+
var b = helpers.b;
|
|
73
|
+
check("b.codepointClass is on the public surface", typeof b.codepointClass === "object");
|
|
74
|
+
// The detectors / classifier the issue names are reachable + functional.
|
|
75
|
+
check("b.codepointClass.detectCharThreats is a function", typeof b.codepointClass.detectCharThreats === "function");
|
|
76
|
+
check("b.codepointClass.assertNoCharThreats is a function", typeof b.codepointClass.assertNoCharThreats === "function");
|
|
77
|
+
check("b.codepointClass.applyCharStripPolicies is a function", typeof b.codepointClass.applyCharStripPolicies === "function");
|
|
78
|
+
check("b.codepointClass.scriptFor is a function", typeof b.codepointClass.scriptFor === "function");
|
|
79
|
+
check("b.codepointClass.detectMixedScripts is a function", typeof b.codepointClass.detectMixedScripts === "function");
|
|
80
|
+
// The compiled regexes / constants are reachable.
|
|
81
|
+
check("b.codepointClass.BIDI_RE is a RegExp", b.codepointClass.BIDI_RE instanceof RegExp);
|
|
82
|
+
check("b.codepointClass.C0_CTRL_RE is a RegExp", b.codepointClass.C0_CTRL_RE instanceof RegExp);
|
|
83
|
+
check("b.codepointClass.ZERO_WIDTH_RE is a RegExp", b.codepointClass.ZERO_WIDTH_RE instanceof RegExp);
|
|
84
|
+
check("b.codepointClass.NULL_BYTE is the NUL char", b.codepointClass.NULL_BYTE === "\x00");
|
|
85
|
+
|
|
86
|
+
// Functional smoke: a bidi-override Trojan-source payload is detected; a
|
|
87
|
+
// Cyrillic confusable mixed into a Latin label is flagged.
|
|
88
|
+
var bidi = "abc" + b.codepointClass.fromCp(0x202E) + "def";
|
|
89
|
+
var issues = b.codepointClass.detectCharThreats(bidi, { bidiPolicy: "reject" }, "free-text");
|
|
90
|
+
check("public detectCharThreats flags a bidi override", issues.length >= 1 && issues[0].kind === "bidi-override");
|
|
91
|
+
|
|
92
|
+
var spoof = "pa" + b.codepointClass.fromCp(0x0443) + "pal"; // Cyrillic u (U+0443)
|
|
93
|
+
var scripts = b.codepointClass.detectMixedScripts(spoof);
|
|
94
|
+
check("public detectMixedScripts flags a Latin/Cyrillic confusable",
|
|
95
|
+
Array.isArray(scripts) && scripts.indexOf("latin") !== -1 && scripts.indexOf("cyrillic") !== -1);
|
|
96
|
+
|
|
97
|
+
// strip policy removes the override (sanitize path).
|
|
98
|
+
var cleaned = b.codepointClass.applyCharStripPolicies(bidi, { bidiPolicy: "strip" });
|
|
99
|
+
check("public applyCharStripPolicies strips the override", cleaned === "abcdef");
|
|
100
|
+
|
|
101
|
+
// The composition helpers are reachable + correct on the public surface too,
|
|
102
|
+
// so a consumer building its own screen doesn't reach into the internal path.
|
|
103
|
+
check("b.codepointClass.hex4", b.codepointClass.hex4(0x202E) === "\\u202E");
|
|
104
|
+
check("b.codepointClass.charClass",
|
|
105
|
+
b.codepointClass.charClass([0x200E, [0x202A, 0x202E]]) === "\\u200E\\u202A-\\u202E");
|
|
106
|
+
check("b.codepointClass.fromCp", b.codepointClass.fromCp(0x41) === "A");
|
|
107
|
+
check("b.codepointClass.escapeRegExp",
|
|
108
|
+
b.codepointClass.escapeRegExp("a.b*c") === "a\\.b\\*c");
|
|
109
|
+
check("b.codepointClass.isAsciiAlnum",
|
|
110
|
+
b.codepointClass.isAsciiAlnum(0x5a) === true && b.codepointClass.isAsciiAlnum(0x2d) === false);
|
|
111
|
+
check("b.codepointClass.isUnreserved",
|
|
112
|
+
b.codepointClass.isUnreserved(0x7e) === true && b.codepointClass.isUnreserved(0x2f) === false);
|
|
113
|
+
check("b.codepointClass.isForbiddenControlChar",
|
|
114
|
+
b.codepointClass.isForbiddenControlChar(0x00) === true && b.codepointClass.isForbiddenControlChar(0x41) === false);
|
|
115
|
+
check("b.codepointClass.firstControlCharOffset",
|
|
116
|
+
b.codepointClass.firstControlCharOffset("ok\x00bad") === 2 && b.codepointClass.firstControlCharOffset("clean") === -1);
|
|
117
|
+
check("b.codepointClass.decodeNumericEntities",
|
|
118
|
+
b.codepointClass.decodeNumericEntities("javascript:") === "javascript:" &&
|
|
119
|
+
b.codepointClass.decodeNumericEntities("javascript:") === "javascript:");
|
|
120
|
+
// The remaining catalog constants the issue lists are reachable.
|
|
121
|
+
check("b.codepointClass.BOM_CHAR",
|
|
122
|
+
typeof b.codepointClass.BOM_CHAR === "string" && b.codepointClass.BOM_CHAR.charCodeAt(0) === 0xFEFF);
|
|
123
|
+
}
|
|
124
|
+
|
|
68
125
|
async function run() {
|
|
69
126
|
testIsForbiddenControlChar();
|
|
70
127
|
testFirstControlCharOffset();
|
|
128
|
+
testPublicSurface();
|
|
71
129
|
}
|
|
72
130
|
|
|
73
131
|
module.exports = { run: run };
|
package/lib/vendor/blamejs/test/layer-0-primitives/defineguard-default-gate-posture-caps.test.js
CHANGED
|
@@ -26,5 +26,8 @@ async function run() {
|
|
|
26
26
|
process.stdout.write("OK — defineGuard default-gate posture-cap tests\n");
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
module.exports = { run: run };
|
|
30
|
+
if (require.main === module) {
|
|
31
|
+
run().then(function () { process.exit(0); })
|
|
32
|
+
.catch(function (e) { process.stderr.write((e && e.stack ? e.stack : String(e)) + "\n"); process.exit(1); });
|
|
33
|
+
}
|
package/lib/vendor/blamejs/test/layer-0-primitives/dpop-middleware-replaystore-required.test.js
CHANGED
|
@@ -43,4 +43,12 @@ function run() {
|
|
|
43
43
|
process.stdout.write("OK — dpop middleware replayStore-required tests\n");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
run
|
|
46
|
+
module.exports = { run: run };
|
|
47
|
+
if (require.main === module) {
|
|
48
|
+
// run() is synchronous here — wrap in Promise.resolve().then so the standalone
|
|
49
|
+
// CLI path works whether run is sync or async (a bare run().catch throws
|
|
50
|
+
// "Cannot read properties of undefined" on a sync run that returns undefined).
|
|
51
|
+
Promise.resolve().then(run).catch(function (e) {
|
|
52
|
+
process.stderr.write((e && e.stack ? e.stack : String(e)) + "\n"); process.exit(1);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -255,6 +255,102 @@ async function testAuditEmission() {
|
|
|
255
255
|
check("transition succeeded under best-effort audit", i.state === "paid");
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
// Tap b.audit.safeEmit (the sink namespaced(...) routes through) for the
|
|
259
|
+
// duration of `fn`, returning the captured records. Restores in a finally so
|
|
260
|
+
// a thrown assertion never leaves the global sink monkeypatched.
|
|
261
|
+
async function _withAuditCapture(fn) {
|
|
262
|
+
var events = [];
|
|
263
|
+
var orig = b.audit.safeEmit;
|
|
264
|
+
b.audit.safeEmit = function (rec) { events.push(rec); return orig.apply(this, arguments); };
|
|
265
|
+
try {
|
|
266
|
+
await fn(events);
|
|
267
|
+
// namespaced emit fires on setImmediate via safeEmit's wrapper path — let
|
|
268
|
+
// the microtask/immediate queue flush before reading.
|
|
269
|
+
await new Promise(function (r) { setImmediate(r); });
|
|
270
|
+
} finally {
|
|
271
|
+
b.audit.safeEmit = orig;
|
|
272
|
+
}
|
|
273
|
+
return events;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// #345 Gap 1 — side-effect-free destination resolver. Mirrors can() but
|
|
277
|
+
// returns the to-state so a consumer can build an external compare-and-swap's
|
|
278
|
+
// `SET status = <to>` without calling transition() (which mutates + audits).
|
|
279
|
+
async function testTargetResolver() {
|
|
280
|
+
var f = _orderFactory();
|
|
281
|
+
var i = f.create({ initialContext: {} }); // no address → ship guard refuses
|
|
282
|
+
check("target(pay) → paid", i.target("pay") === "paid");
|
|
283
|
+
check("target(cancel) → canceled", i.target("cancel") === "canceled");
|
|
284
|
+
check("target(ship) illegal from placed → null", i.target("ship") === null);
|
|
285
|
+
check("target(unknown) → null", i.target("teleport") === null);
|
|
286
|
+
check("target(non-string) → null", i.target(42) === null);
|
|
287
|
+
check("target did not mutate state", i.state === "placed");
|
|
288
|
+
check("target did not push history", i.history.length === 0);
|
|
289
|
+
|
|
290
|
+
await i.transition("pay");
|
|
291
|
+
// From paid: ship is guard-refused (no address) → target null, matching can().
|
|
292
|
+
check("target(ship) guard-refused → null", i.target("ship") === null);
|
|
293
|
+
check("can(ship) guard-refused → false", i.can("ship") === false);
|
|
294
|
+
i.context.address = "addr";
|
|
295
|
+
check("target(ship) guard-pass → shipped", i.target("ship") === "shipped");
|
|
296
|
+
check("can/target agree after guard flip", i.can("ship") === (i.target("ship") !== null));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// #345 Gap 2 — { audit: false } suppresses the built-in emit so a composition
|
|
300
|
+
// driving an external claim can emit its own enriched record after the claim.
|
|
301
|
+
async function testAuditSuppression() {
|
|
302
|
+
var f = _orderFactory();
|
|
303
|
+
var events = await _withAuditCapture(async function () {
|
|
304
|
+
var i = f.create({ initialContext: { address: "addr" } });
|
|
305
|
+
await i.transition("pay", { audit: false });
|
|
306
|
+
check("transition still committed under audit:false", i.state === "paid");
|
|
307
|
+
});
|
|
308
|
+
var fsmEvents = events.filter(function (e) {
|
|
309
|
+
return e && /\.transition$/.test(String(e.action || e.event || ""));
|
|
310
|
+
});
|
|
311
|
+
check("audit:false suppressed the fsm transition emit", fsmEvents.length === 0);
|
|
312
|
+
|
|
313
|
+
// Default (audit on) DOES emit.
|
|
314
|
+
var events2 = await _withAuditCapture(async function () {
|
|
315
|
+
var i2 = f.create({ initialContext: { address: "addr" } });
|
|
316
|
+
await i2.transition("pay");
|
|
317
|
+
});
|
|
318
|
+
check("default transition emits an audit record", events2.length >= 1);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Hunt finding (HIGH) — a throwing onEnter commits the state change but used
|
|
322
|
+
// to skip the audit emit entirely, leaving an unaudited state transition (a
|
|
323
|
+
// compliance hole). The emit must still fire, stamped failure + the error.
|
|
324
|
+
async function testOnEnterThrowStillAudits() {
|
|
325
|
+
var f = b.fsm.define({
|
|
326
|
+
name: "hooky", initial: "a",
|
|
327
|
+
states: { a: {}, b: { onEnter: function () { throw new Error("hook boom"); } } },
|
|
328
|
+
transitions: [ { from: "a", to: "b", on: "go" } ],
|
|
329
|
+
});
|
|
330
|
+
var events = await _withAuditCapture(async function () {
|
|
331
|
+
var i = f.create();
|
|
332
|
+
var threw = null;
|
|
333
|
+
try { await i.transition("go"); } catch (e) { threw = e; }
|
|
334
|
+
check("onEnter throw still propagates to caller", !!threw && threw.message === "hook boom");
|
|
335
|
+
check("state committed despite onEnter throw", i.state === "b");
|
|
336
|
+
check("history recorded the transition", i.history.length === 1);
|
|
337
|
+
});
|
|
338
|
+
var ev = events[events.length - 1] || {};
|
|
339
|
+
check("onEnter-throw now emits an audit record", events.length >= 1);
|
|
340
|
+
check("onEnter-throw audit outcome = failure", ev.outcome === "failure");
|
|
341
|
+
check("onEnter-throw audit carries the error",
|
|
342
|
+
ev.metadata && /hook boom/.test(JSON.stringify(ev.metadata)));
|
|
343
|
+
|
|
344
|
+
// Happy path keeps outcome success.
|
|
345
|
+
var f2 = _orderFactory();
|
|
346
|
+
var okEvents = await _withAuditCapture(async function () {
|
|
347
|
+
var i = f2.create({ initialContext: { address: "addr" } });
|
|
348
|
+
await i.transition("pay");
|
|
349
|
+
});
|
|
350
|
+
check("happy-path audit outcome = success",
|
|
351
|
+
(okEvents[okEvents.length - 1] || {}).outcome === "success");
|
|
352
|
+
}
|
|
353
|
+
|
|
258
354
|
function testDefineBadStateName() {
|
|
259
355
|
var threw = null;
|
|
260
356
|
try {
|
|
@@ -358,6 +454,9 @@ async function run() {
|
|
|
358
454
|
await testToJsonRestoreRoundtrip();
|
|
359
455
|
await testConcurrentTransitionsSerialize();
|
|
360
456
|
await testAuditEmission();
|
|
457
|
+
await testTargetResolver();
|
|
458
|
+
await testAuditSuppression();
|
|
459
|
+
await testOnEnterThrowStillAudits();
|
|
361
460
|
testDefineBadStateName();
|
|
362
461
|
testDefineBadTransitionName();
|
|
363
462
|
testDefineMissingInitial();
|
|
@@ -38,6 +38,36 @@ function run() {
|
|
|
38
38
|
check("roundMinor: non-integer minor throws money/bad-minor-units",
|
|
39
39
|
(function () { try { b.money.roundMinor(1.5, 5n); return null; } catch (e) { return e.code; } })()
|
|
40
40
|
=== "money/bad-minor-units");
|
|
41
|
+
|
|
42
|
+
// ---- Money.roundToIncrement: cash rounding on a Money value (#336) ----
|
|
43
|
+
// The issue's primary ask: snap a Money total to a coarser cash increment
|
|
44
|
+
// (Swiss Rappenrundung to 0.05, Swedish öre to 0.10, psychological steps)
|
|
45
|
+
// and get a NEW Money in the SAME currency — integer-only, negative-safe.
|
|
46
|
+
check("roundToIncrement is a Money method",
|
|
47
|
+
typeof b.money.of("12.32", "CHF").roundToIncrement === "function");
|
|
48
|
+
var chf = b.money.of("12.32", "CHF").roundToIncrement(5, { mode: "half-up" });
|
|
49
|
+
check("roundToIncrement: CHF 12.32 → nearest 0.05 half-up = 12.30",
|
|
50
|
+
chf instanceof b.money.Money && chf.toMinorUnits() === 1230n && chf.currency === "CHF");
|
|
51
|
+
check("roundToIncrement: returns same currency tag",
|
|
52
|
+
b.money.of("19.97", "SEK").roundToIncrement(10).currency === "SEK");
|
|
53
|
+
check("roundToIncrement: SEK 19.97 → nearest 0.10 (default half-even) = 20.00",
|
|
54
|
+
b.money.of("19.97", "SEK").roundToIncrement(10).toMinorUnits() === 2000n);
|
|
55
|
+
check("roundToIncrement: JPY psychological 100-step",
|
|
56
|
+
b.money.fromMinorUnits(1234n, "JPY").roundToIncrement(100, { mode: "half-up" }).toMinorUnits() === 1200n);
|
|
57
|
+
// Negative amount (refund preview) — remainder sign handled correctly.
|
|
58
|
+
check("roundToIncrement: negative CHF -12.32 half-up → -12.30",
|
|
59
|
+
b.money.of("-12.32", "CHF").roundToIncrement(5, { mode: "half-up" }).toMinorUnits() === -1230n);
|
|
60
|
+
// Modes carry through to the Money method.
|
|
61
|
+
check("roundToIncrement: floor toward -inf",
|
|
62
|
+
b.money.fromMinorUnits(27n, "USD").roundToIncrement(10, { mode: "floor" }).toMinorUnits() === 20n);
|
|
63
|
+
check("roundToIncrement: ceiling toward +inf",
|
|
64
|
+
b.money.fromMinorUnits(21n, "USD").roundToIncrement(10, { mode: "ceiling" }).toMinorUnits() === 30n);
|
|
65
|
+
check("roundToIncrement: immutable — original unchanged",
|
|
66
|
+
(function () { var m = b.money.of("12.32", "CHF"); m.roundToIncrement(5, { mode: "half-up" }); return m.toMinorUnits(); })() === 1232n);
|
|
67
|
+
check("roundToIncrement: bad mode throws money/bad-rounding-mode",
|
|
68
|
+
(function () { try { b.money.of("1.00", "CHF").roundToIncrement(5, { mode: "nope" }); return null; } catch (e) { return e.code; } })()
|
|
69
|
+
=== "money/bad-rounding-mode");
|
|
70
|
+
|
|
41
71
|
check("b.money.Money is the prototype", b.money.of("1.00", "USD") instanceof b.money.Money);
|
|
42
72
|
check("CURRENCIES is frozen", Object.isFrozen(b.money.CURRENCIES));
|
|
43
73
|
check("CURRENCIES.USD exponent", b.money.CURRENCIES.USD === 2);
|
|
@@ -167,9 +167,12 @@ async function run() {
|
|
|
167
167
|
process.stdout.write("OK — otlp attribute redaction tests\n");
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
170
|
+
module.exports = { run: run };
|
|
171
|
+
if (require.main === module) {
|
|
172
|
+
run().then(function () {
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}).catch(function (e) {
|
|
175
|
+
process.stderr.write((e && e.stack ? e.stack : String(e)) + "\n");
|
|
176
|
+
process.exit(1);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -66,6 +66,42 @@ async function run() {
|
|
|
66
66
|
var c2again = await _runMw(proxMw, _req("10.0.0.9", "203.0.113.1"));
|
|
67
67
|
check("trusted proxy: client A second request blocked", c2again.next === false);
|
|
68
68
|
|
|
69
|
+
// #352 — ipKeyMode "prefix64": an IPv6 end-site owns a whole /64 and can
|
|
70
|
+
// rotate the low 64 bits freely. Under the default exact key each rotated
|
|
71
|
+
// address is a fresh bucket (the bypass); under prefix64 they share the
|
|
72
|
+
// /64 bucket, so the per-IP limit actually binds. IPv4 stays exact.
|
|
73
|
+
var v6a = "2001:db8:1:2:dead:beef:0:1";
|
|
74
|
+
var v6b = "2001:db8:1:2:ffff:ffff:ffff:ffff"; // same /64, rotated low bits
|
|
75
|
+
var v6c = "2001:db8:1:3::1"; // different /64
|
|
76
|
+
|
|
77
|
+
// Baseline (exact): rotating the low 64 bits evades the limit.
|
|
78
|
+
var exactMw = b.middleware.rateLimit({ backend: "memory", burst: 1, refillPerSecond: 0.001 });
|
|
79
|
+
var e1 = await _runMw(exactMw, _req(v6a));
|
|
80
|
+
var e2 = await _runMw(exactMw, _req(v6b));
|
|
81
|
+
check("exact key: low-64 rotation mints a fresh bucket (the bypass)",
|
|
82
|
+
e1.next === true && e2.next === true);
|
|
83
|
+
|
|
84
|
+
// prefix64: the rotated address shares the /64 bucket → second blocked.
|
|
85
|
+
var p64Mw = b.middleware.rateLimit({ backend: "memory", burst: 1, refillPerSecond: 0.001, ipKeyMode: "prefix64" });
|
|
86
|
+
var f1 = await _runMw(p64Mw, _req(v6a));
|
|
87
|
+
var f2 = await _runMw(p64Mw, _req(v6b));
|
|
88
|
+
var f3 = await _runMw(p64Mw, _req(v6c));
|
|
89
|
+
check("prefix64: first /64 request allowed", f1.next === true);
|
|
90
|
+
check("prefix64: low-64 rotation hits the SAME /64 bucket (blocked)", f2.next === false);
|
|
91
|
+
check("prefix64: a different /64 gets its own bucket (allowed)", f3.next === true);
|
|
92
|
+
|
|
93
|
+
// prefix64 keeps IPv4 exact — two distinct IPv4 hosts are distinct buckets.
|
|
94
|
+
var p64v4 = b.middleware.rateLimit({ backend: "memory", burst: 1, refillPerSecond: 0.001, ipKeyMode: "prefix64" });
|
|
95
|
+
var g1 = await _runMw(p64v4, _req("198.51.100.10"));
|
|
96
|
+
var g2 = await _runMw(p64v4, _req("198.51.100.11"));
|
|
97
|
+
check("prefix64: distinct IPv4 hosts stay distinct (v4 exact)", g1.next === true && g2.next === true);
|
|
98
|
+
|
|
99
|
+
// Bad ipKeyMode refused at construction.
|
|
100
|
+
var threwMode = null;
|
|
101
|
+
try { b.middleware.rateLimit({ backend: "memory", burst: 1, refillPerSecond: 0.001, ipKeyMode: "bogus" }); }
|
|
102
|
+
catch (e) { threwMode = e; }
|
|
103
|
+
check("create: bad ipKeyMode refused", threwMode !== null && /ipKeyMode/.test(threwMode.message));
|
|
104
|
+
|
|
69
105
|
console.log("[rate-limit-xff-spoofing] OK — " + helpers.getChecks() + " checks passed");
|
|
70
106
|
}
|
|
71
107
|
|
|
@@ -15,6 +15,7 @@ function testSurface() {
|
|
|
15
15
|
check("b.requestHelpers exposed", typeof b.requestHelpers === "object");
|
|
16
16
|
check("resolveRoute is a function", typeof b.requestHelpers.resolveRoute === "function");
|
|
17
17
|
check("captureResponseStatus is a function", typeof b.requestHelpers.captureResponseStatus === "function");
|
|
18
|
+
check("ipKey is a function", typeof b.requestHelpers.ipKey === "function");
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function testResolveRoutePrefersRoutePattern() {
|
|
@@ -460,10 +461,42 @@ function testIpPrefixMasking() {
|
|
|
460
461
|
check("ipPrefix rejects an out-of-range v4 octet", ip("999.0.0.1") === "");
|
|
461
462
|
}
|
|
462
463
|
|
|
464
|
+
// #352 — ipKey: IPv4 verbatim (exact host), IPv6 collapsed to /64 so one
|
|
465
|
+
// end-site can't rotate the low 64 bits to mint unlimited rate-limit keys.
|
|
466
|
+
function testIpKeyForRateLimit() {
|
|
467
|
+
var k = b.requestHelpers.ipKey;
|
|
468
|
+
check("ipKey is a function", typeof k === "function");
|
|
469
|
+
// IPv4 kept exact (no /24 masking, no /32 suffix) — one IPv4 is one host.
|
|
470
|
+
check("ipKey v4 exact verbatim", k("203.0.113.47") === "203.0.113.47");
|
|
471
|
+
check("ipKey v4 distinct hosts → distinct keys", k("203.0.113.47") !== k("203.0.113.48"));
|
|
472
|
+
// IPv6 → /64; rotating the low 64 bits yields the SAME key (the defense).
|
|
473
|
+
var k64 = k("2001:db8:1:2:dead:beef:0:1");
|
|
474
|
+
check("ipKey v6 → /64 suffix, low bits zeroed",
|
|
475
|
+
/\/64$/.test(k64) && k64.indexOf("dead") === -1);
|
|
476
|
+
check("ipKey v6 low-64 rotation → same key (rotation defeated)",
|
|
477
|
+
k("2001:db8:1:2:dead:beef:0:1") === k("2001:db8:1:2:ffff:ffff:ffff:ffff"));
|
|
478
|
+
check("ipKey v6 different /64 → different key",
|
|
479
|
+
k("2001:db8:1:2::1") !== k("2001:db8:1:3::1"));
|
|
480
|
+
// Configurable width.
|
|
481
|
+
check("ipKey v6 ipv6Bits=48 widens the bucket", /\/48$/.test(k("2001:db8:1:2::1", { ipv6Bits: 48 })));
|
|
482
|
+
// IPv4-mapped IPv6 folds to its exact dotted host.
|
|
483
|
+
check("ipKey folds ::ffff: mapped v4 to the exact v4 host",
|
|
484
|
+
k("::ffff:203.0.113.47") === "203.0.113.47");
|
|
485
|
+
// Defensive defaults — never throws, "" on bad input (caller falls back).
|
|
486
|
+
check("ipKey '' for non-string", k(null) === "" && k(12345) === "");
|
|
487
|
+
check("ipKey '' for empty", k("") === "");
|
|
488
|
+
check("ipKey '' for unparseable", k("not-an-ip") === "");
|
|
489
|
+
check("ipKey '' for out-of-range v4 octet", k("999.0.0.1") === "");
|
|
490
|
+
// Distinct from ipPrefix: ipKey keeps v4 exact where ipPrefix masks to /24.
|
|
491
|
+
check("ipKey v4 != ipPrefix v4 (exact vs /24)",
|
|
492
|
+
k("203.0.113.47") !== b.requestHelpers.ipPrefix("203.0.113.47"));
|
|
493
|
+
}
|
|
494
|
+
|
|
463
495
|
async function run() {
|
|
464
496
|
testSurface();
|
|
465
497
|
testSafeHeadersDistinct();
|
|
466
498
|
testIpPrefixMasking();
|
|
499
|
+
testIpKeyForRateLimit();
|
|
467
500
|
testClientIpDefaultIgnoresXff();
|
|
468
501
|
testClientIpPeerGatedTrustedPeer();
|
|
469
502
|
testClientIpPeerGatedUntrustedPeerIgnoresXff();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.middleware.requestId({ asyncContext: true }) — #353.
|
|
4
|
+
*
|
|
5
|
+
* The b.router dispatch model is boolean-`next`: the router awaits each
|
|
6
|
+
* middleware, then runs the route handler in its own loop AFTER the middleware
|
|
7
|
+
* returns. A middleware that wraps `next()` in an AsyncLocalStorage callback
|
|
8
|
+
* (`als.run(store, () => next())`) therefore loses the binding the instant
|
|
9
|
+
* `next()` returns — before any awaited handler code runs — so
|
|
10
|
+
* `b.log.getRequestId()` reads null inside the handler. asyncContext:true binds
|
|
11
|
+
* the id with `AsyncLocalStorage.enterWith` (via b.log.enterRequestId) so it
|
|
12
|
+
* persists forward across the awaited route chain, while each request stays
|
|
13
|
+
* isolated because the HTTP server runs each request in its own async context.
|
|
14
|
+
*
|
|
15
|
+
* Every request below goes through a real http.Server so the enterWith binding
|
|
16
|
+
* is scoped to the request's own async context (driving app.handle() directly
|
|
17
|
+
* from the test would leak the binding into the test runner's context — the
|
|
18
|
+
* exact non-isolated manual-dispatch case enterWith is NOT meant for).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
var helpers = require("../helpers");
|
|
22
|
+
var http = require("node:http");
|
|
23
|
+
var b = helpers.b;
|
|
24
|
+
var check = helpers.check;
|
|
25
|
+
|
|
26
|
+
// Spin up a one-route app on an ephemeral port, fire a single GET with the
|
|
27
|
+
// given inbound id, resolve { body, hdr }, then close the server.
|
|
28
|
+
function _serveOnce(mwOpts, id) {
|
|
29
|
+
var app = b.router.create();
|
|
30
|
+
app.use(b.middleware.requestId(mwOpts));
|
|
31
|
+
app.get("/x", async function (req, res) {
|
|
32
|
+
// Cross an event-loop turn (a real awaited gap) before reading the ALS, so
|
|
33
|
+
// this exercises the boolean-next-dispatch survival the issue is about.
|
|
34
|
+
await new Promise(function (r) { setImmediate(r); });
|
|
35
|
+
res.end(JSON.stringify({ reqField: req.requestId, als: b.log.getRequestId() }));
|
|
36
|
+
});
|
|
37
|
+
var srv = http.createServer(function (req, res) { app.handle(req, res); });
|
|
38
|
+
return new Promise(function (resolve, reject) {
|
|
39
|
+
srv.listen(0, function () {
|
|
40
|
+
var port = srv.address().port;
|
|
41
|
+
http.get({ port: port, path: "/x", agent: false, headers: id ? { "x-request-id": id } : {} }, function (r) {
|
|
42
|
+
var d = ""; r.on("data", function (c) { d += c; });
|
|
43
|
+
// Await the server close so the listening handle is released before the
|
|
44
|
+
// test resolves — a fire-and-forget srv.close() leaves a TCPServerWrap
|
|
45
|
+
// lingering (the leak the harness audit flags).
|
|
46
|
+
r.on("end", function () { srv.close(function () { resolve({ body: JSON.parse(d), hdr: r.headers["x-request-id"] }); }); });
|
|
47
|
+
}).on("error", function (e) { srv.close(function () { reject(e); }); });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function testSurface() {
|
|
53
|
+
check("b.log.enterRequestId is a function", typeof b.log.enterRequestId === "function");
|
|
54
|
+
check("requestId factory is a function", typeof b.middleware.requestId === "function");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// asyncContext:true binds the id into the log ALS so an awaited handler reads
|
|
58
|
+
// it via b.log.getRequestId().
|
|
59
|
+
async function testAsyncContextBindsAls() {
|
|
60
|
+
var r = await _serveOnce({ asyncContext: true }, "abcdefgh-async-1");
|
|
61
|
+
check("asyncContext: req.requestId set", r.body.reqField === "abcdefgh-async-1");
|
|
62
|
+
check("asyncContext: getRequestId() works in awaited handler", r.body.als === "abcdefgh-async-1");
|
|
63
|
+
check("asyncContext: response header reflected", r.hdr === "abcdefgh-async-1");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Default mode binds nothing — req.requestId is set but the ALS stays null.
|
|
67
|
+
async function testDefaultModeDoesNotBindAls() {
|
|
68
|
+
var r = await _serveOnce({}, "plain-no-async-2");
|
|
69
|
+
check("default: req.requestId still set", r.body.reqField === "plain-no-async-2");
|
|
70
|
+
check("default: ALS NOT bound (null)", r.body.als === null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The correctness crux: concurrent requests through a real server each see
|
|
74
|
+
// their OWN id via the ALS, with no cross-request bleed.
|
|
75
|
+
async function testConcurrentRequestsIsolated() {
|
|
76
|
+
var app = b.router.create();
|
|
77
|
+
app.use(b.middleware.requestId({ asyncContext: true }));
|
|
78
|
+
app.get("/x", async function (req, res) {
|
|
79
|
+
// A real awaited gap; the concurrency comes from the concurrent http.get
|
|
80
|
+
// calls below (each request its own async context on a real server).
|
|
81
|
+
await new Promise(function (r) { setImmediate(r); });
|
|
82
|
+
res.end(JSON.stringify({ als: b.log.getRequestId() }));
|
|
83
|
+
});
|
|
84
|
+
var srv = http.createServer(function (req, res) { app.handle(req, res); });
|
|
85
|
+
await new Promise(function (r) { srv.listen(0, r); });
|
|
86
|
+
var port = srv.address().port;
|
|
87
|
+
function get(id) {
|
|
88
|
+
return new Promise(function (resolve) {
|
|
89
|
+
http.get({ port: port, path: "/x", agent: false, headers: { "x-request-id": id } }, function (r) {
|
|
90
|
+
var d = ""; r.on("data", function (c) { d += c; });
|
|
91
|
+
r.on("end", function () { resolve({ id: id, body: JSON.parse(d), hdr: r.headers["x-request-id"] }); });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
var results = await Promise.all([
|
|
96
|
+
get("req-AAAAAA01"), get("req-BBBBBB02"), get("req-CCCCCC03"),
|
|
97
|
+
get("req-DDDDDD04"), get("req-EEEEEE05"),
|
|
98
|
+
]);
|
|
99
|
+
await new Promise(function (r) { srv.close(r); }); // release the listening handle
|
|
100
|
+
var allIsolated = results.every(function (r) { return r.body.als === r.id; });
|
|
101
|
+
check("concurrent: every request's ALS id is its own (no bleed)", allIsolated);
|
|
102
|
+
var hdrsMatch = results.every(function (r) { return r.hdr === r.id; });
|
|
103
|
+
check("concurrent: every response header matches its request", hdrsMatch);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function run() {
|
|
107
|
+
testSurface();
|
|
108
|
+
await testAsyncContextBindsAls();
|
|
109
|
+
await testDefaultModeDoesNotBindAls();
|
|
110
|
+
await testConcurrentRequestsIsolated();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { run: run };
|
|
114
|
+
if (require.main === module) {
|
|
115
|
+
run().then(function () { console.log("OK"); })
|
|
116
|
+
.catch(function (e) { console.error(e); process.exit(1); });
|
|
117
|
+
}
|
|
@@ -65,6 +65,68 @@ function testErrorClassExported() {
|
|
|
65
65
|
check("SafePathError exported", typeof b.safePath.SafePathError === "function");
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// #371 — opts.platform gates the per-segment naming rules AND the lexical
|
|
69
|
+
// containment resolution. The lexical resolve + boundary use the TARGET
|
|
70
|
+
// platform's path module (nodePath.win32 / nodePath.posix) so the resolved
|
|
71
|
+
// output's separator matches the boundary slice. Validating against the
|
|
72
|
+
// OPPOSITE platform's rules (the recommended cross-platform pattern) used to
|
|
73
|
+
// refuse every in-base path with safe-path/escapes-base because the boundary
|
|
74
|
+
// slice compared the runtime-separated nodePath.resolve output against the
|
|
75
|
+
// opts.platform separator. With target-platform resolution the in-base path is
|
|
76
|
+
// accepted AND a genuine traversal is still refused under any override.
|
|
77
|
+
function testCrossPlatformContainment() {
|
|
78
|
+
var nodePath = require("node:path");
|
|
79
|
+
var other = process.platform === "win32" ? "linux" : "windows";
|
|
80
|
+
var pathMod = other === "windows" ? nodePath.win32 : nodePath.posix;
|
|
81
|
+
// Drive-prefixed base on Windows, posix base elsewhere.
|
|
82
|
+
var base = process.platform === "win32" ? "C:/srv/uploads" : "/srv/uploads";
|
|
83
|
+
|
|
84
|
+
var inBase = b.safePath.resolveOrNull(base, "file.txt", { platform: other });
|
|
85
|
+
check("opposite-platform override resolves an in-base file (not null)",
|
|
86
|
+
typeof inBase === "string" && inBase.indexOf("file.txt") !== -1);
|
|
87
|
+
var nested = b.safePath.resolveOrNull(base, "a/b/file.txt", { platform: other });
|
|
88
|
+
check("opposite-platform override resolves a nested in-base path",
|
|
89
|
+
typeof nested === "string" && nested.indexOf("file.txt") !== -1);
|
|
90
|
+
var v = b.safePath.validate(base, "data/x.json", { platform: other });
|
|
91
|
+
check("opposite-platform override validate() ok=true", v.ok === true && typeof v.resolved === "string");
|
|
92
|
+
// Containment is still ENFORCED under the override — a real traversal refused.
|
|
93
|
+
check("opposite-platform override still refuses a forward-slash traversal",
|
|
94
|
+
b.safePath.resolveOrNull(base, "../../etc/passwd", { platform: other }) === null);
|
|
95
|
+
// The containment boundary uses the TARGET platform's separator, so the
|
|
96
|
+
// resolved path begins with the target-resolved base.
|
|
97
|
+
check("resolved path begins with the target-resolved base",
|
|
98
|
+
typeof inBase === "string" && inBase.indexOf(pathMod.resolve(base)) === 0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// #371 P1 — cross-platform backslash traversal. A POSIX host validating with
|
|
102
|
+
// opts.platform: "windows" must collapse Windows separators (\) and `..` the
|
|
103
|
+
// SAME way the per-segment walk does. The lexical resolve previously used the
|
|
104
|
+
// runtime path module: on POSIX, node:path treats \ as an ordinary filename
|
|
105
|
+
// character, so `ok\..\..\outside` slipped past containment and resolved to
|
|
106
|
+
// `<base>/ok\..\..\outside` — a path that escapes the base once a Windows
|
|
107
|
+
// consumer interprets the backslashes. Validating FOR windows now resolves with
|
|
108
|
+
// nodePath.win32 on every host, so the traversal is refused. (On a Windows host
|
|
109
|
+
// win32 IS the runtime, so this also guards the same case there.)
|
|
110
|
+
function testCrossPlatformBackslashTraversalRefused() {
|
|
111
|
+
var BS = String.fromCharCode(92); // backslash without source-escaping ambiguity
|
|
112
|
+
var base = "/srv/uploads";
|
|
113
|
+
var trav = "ok" + BS + ".." + BS + ".." + BS + "outside"; // ok\..\..\outside
|
|
114
|
+
check("windows-target backslash traversal refused (resolveOrNull → null)",
|
|
115
|
+
b.safePath.resolveOrNull(base, trav, { platform: "windows" }) === null);
|
|
116
|
+
var v = b.safePath.validate(base, trav, { platform: "windows" });
|
|
117
|
+
check("windows-target backslash traversal refused (validate ok=false)",
|
|
118
|
+
v.ok === false && v.code === "safe-path/escapes-base");
|
|
119
|
+
var threw = false, code = null;
|
|
120
|
+
try { b.safePath.resolve(base, trav, { platform: "windows" }); }
|
|
121
|
+
catch (e) { threw = true; code = e && e.code; }
|
|
122
|
+
check("windows-target backslash traversal refused (resolve throws escapes-base)",
|
|
123
|
+
threw === true && code === "safe-path/escapes-base");
|
|
124
|
+
// A nested-but-in-base Windows path with backslashes still resolves.
|
|
125
|
+
var ok = b.safePath.resolveOrNull(base, "a" + BS + "b" + BS + "c.txt", { platform: "windows" });
|
|
126
|
+
check("windows-target in-base backslash path resolves",
|
|
127
|
+
typeof ok === "string" && ok.indexOf("c.txt") !== -1);
|
|
128
|
+
}
|
|
129
|
+
|
|
68
130
|
function run() {
|
|
69
131
|
testHappyPath();
|
|
70
132
|
testRefusalClasses();
|
|
@@ -72,6 +134,8 @@ function run() {
|
|
|
72
134
|
testResolveOrNullReturnsNull();
|
|
73
135
|
testValidateReturnsVerdict();
|
|
74
136
|
testErrorClassExported();
|
|
137
|
+
testCrossPlatformContainment();
|
|
138
|
+
testCrossPlatformBackslashTraversalRefused();
|
|
75
139
|
}
|
|
76
140
|
|
|
77
141
|
if (require.main === module) run();
|