@blamejs/core 0.10.8 → 0.10.11

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.
@@ -0,0 +1,254 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.safePath
4
+ * @nav Filesystem
5
+ * @title Safe Path
6
+ *
7
+ * @intro
8
+ * Path-traversal-safe multi-segment resolve. Operators consuming
9
+ * operator-OR-user-supplied path segments (uploaded filenames,
10
+ * tarball entries, archive extraction, dynamic include paths) pass
11
+ * `base + rel` to `b.safePath.resolve` and get back the absolute
12
+ * canonicalized path — guaranteed to lie strictly within `base` —
13
+ * or a typed `SafePathError` with a stable `code` on refusal.
14
+ *
15
+ * Refusal classes (each a documented code, never best-effort):
16
+ *
17
+ * - `safe-path/absolute-rel` — rel is absolute, UNC, or carries a drive letter
18
+ * - `safe-path/escapes-base` — `..` segments escape base after lexical resolve
19
+ * - `safe-path/null-byte` — NUL anywhere (closes Node poison-NUL class)
20
+ * - `safe-path/control-char` — C0 control char other than NUL
21
+ * - `safe-path/bidi` — bidi-override codepoint (CVE-2021-42574 Trojan Source)
22
+ * - `safe-path/win-reserved` — Windows reserved name (CON/PRN/AUX/NUL/COM0-9/LPT0-9)
23
+ * on EVERY platform — closes CVE-2025-27210 cross-mount class
24
+ * - `safe-path/win-trailing` — segment ends with `.` or ` ` under windows-mode resolve
25
+ * - `safe-path/separator-in-segment` — encoded path-separator in a segment (URL / fullwidth /
26
+ * overlong UTF-8 / division-slash)
27
+ * - `safe-path/ads-marker` — NTFS Alternate Data Stream `foo:bar` marker
28
+ * - `safe-path/realpath-escapes-base` — symlink resolution escapes base (opt-in via opts.realpath)
29
+ *
30
+ * Per-segment filename validation composes `b.guardFilename`'s
31
+ * reserved-name + overlong UTF-8 + bidi tables; the multi-segment
32
+ * resolve + base-escape check is the new code.
33
+ *
34
+ * @card
35
+ * Traversal-safe multi-segment path resolve. Every documented failure mode → coded refusal. Composes b.guardFilename.
36
+ */
37
+
38
+ var nodePath = require("node:path");
39
+ var nodeFs = require("node:fs");
40
+ var { defineClass } = require("./framework-error");
41
+
42
+ var SafePathError = defineClass("SafePathError", { alwaysPermanent: true });
43
+
44
+ // Windows reserved device names — CON, PRN, AUX, NUL, COM0–COM9,
45
+ // LPT0–LPT9, CONIN$, CONOUT$. Enforced on EVERY platform to defend
46
+ // the cross-mount case where a POSIX server writes a path that a
47
+ // Windows operator later mounts (closes CVE-2025-27210 class).
48
+ var WIN_RESERVED_RE = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)(?:\..*)?$/i;
49
+ // Path separators outside the platform-native set. Each entry MUST
50
+ // be rejected as a segment-internal character. Includes both raw +
51
+ // canonical-encoded forms.
52
+ var ENCODED_SEPARATOR_RE = /(%2[fF]|%5[cC]|%C0%AF|%C1%9C|[/\∕⧸⁄])/;
53
+ // Bidi-override codepoints (RTL/LTR markers + isolate enclosures).
54
+ var BIDI_RE = /[‪-‮⁦-⁩‎‏]/;
55
+ // C0 control byte range (excluding NUL which has its own dedicated
56
+ // refusal so the error code matches the historical poison-NUL class).
57
+ // eslint-disable-next-line no-control-regex
58
+ var C0_RE = /[\x01-\x1F\x7F]/;
59
+
60
+ function _refuse(code, message) {
61
+ throw new SafePathError(code, message);
62
+ }
63
+
64
+ /**
65
+ * @primitive b.safePath.resolve
66
+ * @signature b.safePath.resolve(base, rel, opts?)
67
+ * @since 0.10.9
68
+ * @status stable
69
+ * @related b.safePath.validate, b.guardFilename.validate, b.atomicFile.write
70
+ *
71
+ * Resolve `rel` against `base` and return the absolute canonicalized
72
+ * path — guaranteed to lie strictly within `base`. Throws
73
+ * `SafePathError` with a stable refusal code on any rejection.
74
+ *
75
+ * @opts
76
+ * realpath: boolean, // default false; true → fs.realpathSync check (symlink-escape)
77
+ * platform: string, // "windows" forces win-trailing / UNC refusal regardless of host
78
+ * allowAbsoluteRel: boolean, // default false; opt-in for absolute rel that still resolves inside base
79
+ *
80
+ * @example
81
+ * var p = b.safePath.resolve("/srv/uploads", req.body.path);
82
+ * // → "/srv/uploads/<safe-rel>" OR throws SafePathError on traversal
83
+ */
84
+ function resolve(base, rel, opts) {
85
+ return _resolveCore(base, rel, opts || {});
86
+ }
87
+
88
+ /**
89
+ * @primitive b.safePath.resolveOrNull
90
+ * @signature b.safePath.resolveOrNull(base, rel, opts?)
91
+ * @since 0.10.9
92
+ * @status stable
93
+ * @related b.safePath.resolve, b.safePath.validate
94
+ *
95
+ * Same contract as `resolve` but returns `null` on refusal instead of
96
+ * throwing. Useful for hot-path callers that want a boolean-ish gate
97
+ * without try/catch overhead.
98
+ *
99
+ * @opts
100
+ * realpath: boolean,
101
+ * platform: string,
102
+ * allowAbsoluteRel: boolean,
103
+ *
104
+ * @example
105
+ * var p = b.safePath.resolveOrNull("/srv/uploads", req.body.path);
106
+ * if (p === null) { res.statusCode = 400; res.end("bad path"); return; }
107
+ */
108
+ function resolveOrNull(base, rel, opts) {
109
+ try { return _resolveCore(base, rel, opts || {}); }
110
+ catch (_e) { return null; }
111
+ }
112
+
113
+ /**
114
+ * @primitive b.safePath.validate
115
+ * @signature b.safePath.validate(base, rel, opts?)
116
+ * @since 0.10.9
117
+ * @status stable
118
+ * @related b.safePath.resolve
119
+ *
120
+ * Same gate as `resolve` but returns a verdict object instead of
121
+ * throwing — `{ ok: true, resolved }` on success, `{ ok: false,
122
+ * code, message }` on refusal. Use when the caller wants to log the
123
+ * refusal class without throw/catch.
124
+ *
125
+ * @opts
126
+ * realpath: boolean,
127
+ * platform: string,
128
+ * allowAbsoluteRel: boolean,
129
+ *
130
+ * @example
131
+ * var v = b.safePath.validate("/srv/uploads", req.body.path);
132
+ * if (!v.ok) { res.end("rejected: " + v.code); return; }
133
+ */
134
+ function validate(base, rel, opts) {
135
+ try { return { ok: true, resolved: _resolveCore(base, rel, opts || {}) }; }
136
+ catch (e) { return { ok: false, code: e.code || "safe-path/unknown", message: e.message }; }
137
+ }
138
+
139
+ function _resolveCore(base, rel, opts) {
140
+ if (typeof base !== "string" || base.length === 0) {
141
+ _refuse("safe-path/bad-input", "b.safePath.resolve: base must be a non-empty string");
142
+ }
143
+ if (typeof rel !== "string") {
144
+ _refuse("safe-path/bad-input", "b.safePath.resolve: rel must be a string");
145
+ }
146
+ var platform = opts.platform || process.platform;
147
+ var isWin = platform === "win32" || platform === "windows";
148
+
149
+ // NUL byte ANYWHERE — its own refusal so the audit code matches
150
+ // the historical Node poison-NUL class.
151
+ if (rel.indexOf("\0") !== -1) {
152
+ _refuse("safe-path/null-byte", "b.safePath.resolve: NUL byte in rel");
153
+ }
154
+ // Other C0 + DEL.
155
+ if (C0_RE.test(rel)) { // allow:regex-no-length-cap — anchored C0/DEL set, length bounded by rel
156
+ _refuse("safe-path/control-char", "b.safePath.resolve: C0 control char in rel");
157
+ }
158
+ // Bidi override (Trojan Source).
159
+ if (BIDI_RE.test(rel)) { // allow:regex-no-length-cap — fixed bidi set, length bounded by rel
160
+ _refuse("safe-path/bidi",
161
+ "b.safePath.resolve: bidi-override codepoint in rel (CVE-2021-42574 class)");
162
+ }
163
+ // Encoded path separators inside what should be a single segment.
164
+ if (ENCODED_SEPARATOR_RE.test(rel)) { // allow:regex-no-length-cap — fixed separator-shape set
165
+ _refuse("safe-path/separator-in-segment",
166
+ "b.safePath.resolve: encoded path-separator codepoint in rel");
167
+ }
168
+ // Absolute rel (POSIX, Windows drive-letter, UNC) — refuse unless
169
+ // operator opted in.
170
+ var isAbsolute = nodePath.isAbsolute(rel) ||
171
+ /^[A-Za-z]:[\\/]/.test(rel) || // allow:regex-no-length-cap — anchored drive-letter shape
172
+ /^\\\\/.test(rel) || // allow:regex-no-length-cap — UNC `\\` prefix
173
+ /^\/\//.test(rel); // allow:regex-no-length-cap — POSIX `//` prefix
174
+ if (isAbsolute && !opts.allowAbsoluteRel) {
175
+ _refuse("safe-path/absolute-rel",
176
+ "b.safePath.resolve: rel is absolute/UNC/drive-letter (set opts.allowAbsoluteRel for opt-in)");
177
+ }
178
+
179
+ // Per-segment walk. Reserved-name + ADS + win-trailing + segment-
180
+ // shape checks happen here.
181
+ var sep = isWin ? /[\\/]/ : /\//;
182
+ var segments = rel.split(sep); // allow:regex-no-length-cap — fixed separator
183
+ for (var si = 0; si < segments.length; si += 1) {
184
+ var seg = segments[si];
185
+ if (seg.length === 0) continue; // empty (leading/trailing/double-sep)
186
+ if (seg === "." || seg === "..") continue; // resolution handled below
187
+ var segLc = seg.toLowerCase();
188
+ var baseName = segLc.indexOf(".") === -1 ? segLc : segLc.slice(0, segLc.indexOf("."));
189
+ if (WIN_RESERVED_RE.test(seg) || WIN_RESERVED_RE.test(baseName)) { // allow:regex-no-length-cap — anchored reserved-name set
190
+ _refuse("safe-path/win-reserved",
191
+ "b.safePath.resolve: segment '" + seg + "' is a Windows reserved name (CVE-2025-27210 class)");
192
+ }
193
+ if (isWin) {
194
+ var last = seg.charAt(seg.length - 1);
195
+ if (last === "." || last === " ") {
196
+ _refuse("safe-path/win-trailing",
197
+ "b.safePath.resolve: segment '" + seg + "' ends with '.' or ' ' (Windows silently strips)");
198
+ }
199
+ }
200
+ // NTFS Alternate Data Stream marker — refuse `foo:bar` ANYWHERE
201
+ // except where the colon is part of a Windows drive prefix (the
202
+ // absolute-rel branch above already refused those).
203
+ if (seg.indexOf(":") !== -1) {
204
+ _refuse("safe-path/ads-marker",
205
+ "b.safePath.resolve: segment '" + seg + "' contains ':' (NTFS Alternate Data Stream marker; CVE-2024-12217 class)");
206
+ }
207
+ }
208
+
209
+ // Lexical resolve.
210
+ var baseResolved = nodePath.resolve(base);
211
+ var joined = nodePath.resolve(baseResolved, rel);
212
+ // Cross-check via posix.normalize so a Windows host with mixed
213
+ // separators still surfaces escapes consistently.
214
+ var sepChar = isWin ? "\\" : "/";
215
+ if (joined !== baseResolved && joined.slice(0, baseResolved.length + 1) !== baseResolved + sepChar) {
216
+ _refuse("safe-path/escapes-base",
217
+ "b.safePath.resolve: rel resolves outside base ('" + joined + "' not inside '" + baseResolved + "')");
218
+ }
219
+ if (opts.realpath === true) {
220
+ var baseRealpath;
221
+ try { baseRealpath = nodeFs.realpathSync.native(baseResolved); }
222
+ catch (e) {
223
+ _refuse("safe-path/realpath-base-unresolvable",
224
+ "b.safePath.resolve: opts.realpath set but base realpath failed: " + (e && e.message));
225
+ }
226
+ // Walk up the joined path from the leaf, finding the longest
227
+ // ancestor that exists, and check its realpath. Operators want
228
+ // refusal when ANY ancestor symlink escapes — nodeFs.realpathSync on a
229
+ // non-existent path would throw.
230
+ var ancestor = joined;
231
+ while (ancestor.length > baseResolved.length) {
232
+ try {
233
+ var ancRealpath = nodeFs.realpathSync.native(ancestor);
234
+ if (ancRealpath !== baseRealpath &&
235
+ ancRealpath.slice(0, baseRealpath.length + 1) !== baseRealpath + sepChar) {
236
+ _refuse("safe-path/realpath-escapes-base",
237
+ "b.safePath.resolve: symlink resolution at '" + ancestor +
238
+ "' escapes base realpath '" + baseRealpath + "'");
239
+ }
240
+ break;
241
+ } catch (_ie) {
242
+ ancestor = nodePath.dirname(ancestor);
243
+ }
244
+ }
245
+ }
246
+ return joined;
247
+ }
248
+
249
+ module.exports = {
250
+ resolve: resolve,
251
+ resolveOrNull: resolveOrNull,
252
+ validate: validate,
253
+ SafePathError: SafePathError,
254
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.8",
3
+ "version": "0.10.11",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:2db3bd59-b835-4672-aac5-7c874f2f9276",
5
+ "serialNumber": "urn:uuid:2bcbf942-6319-4d65-a08c-1e385ac35198",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-18T00:17:19.942Z",
8
+ "timestamp": "2026-05-18T04:40:34.689Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.10.8",
22
+ "bom-ref": "@blamejs/core@0.10.11",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.8",
25
+ "version": "0.10.11",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.10.8",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.11",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.10.8",
57
+ "ref": "@blamejs/core@0.10.11",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]