@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.
@@ -583,12 +583,46 @@ function needsRehash(stored, opts) {
583
583
  }
584
584
  }
585
585
 
586
+ // OWASP 2026 Argon2id minimum floor — operator audit visibility.
587
+ // Any deploy MUST satisfy m >= 19 MiB, t >= 2, p >= 1. params() exposes
588
+ // the active defaults so an operator audit (or compliance scan) can
589
+ // verify the floor without parsing PHC strings out of the database.
590
+ //
591
+ // Argon2 expresses memoryCost in KiB. C.BYTES.kib(19) returns 19456,
592
+ // which argon2 reads as 19456 KiB = 19 MiB — the same shape as the
593
+ // active DEFAULT_PARAMS.memoryCost (C.BYTES.kib(64) = 65536 KiB = 64
594
+ // MiB). timeCost + parallelism are unitless argon2 parameters.
595
+ var OWASP_FLOOR_2026 = Object.freeze({
596
+ memoryCostKib: C.BYTES.kib(19),
597
+ timeCost: 2,
598
+ parallelism: 1,
599
+ });
600
+
601
+ function params() {
602
+ // Active framework defaults plus the OWASP 2026 floor for comparison.
603
+ var active = {
604
+ memoryCostKib: DEFAULT_PARAMS.memoryCost,
605
+ timeCost: DEFAULT_PARAMS.timeCost,
606
+ parallelism: DEFAULT_PARAMS.parallelism,
607
+ };
608
+ return {
609
+ algorithm: "argon2id",
610
+ active: active,
611
+ owaspFloor: OWASP_FLOOR_2026,
612
+ meetsFloor: active.memoryCostKib >= OWASP_FLOOR_2026.memoryCostKib &&
613
+ active.timeCost >= OWASP_FLOOR_2026.timeCost &&
614
+ active.parallelism >= OWASP_FLOOR_2026.parallelism,
615
+ };
616
+ }
617
+
586
618
  module.exports = {
587
619
  hash: hash,
588
620
  verify: verify,
589
621
  needsRehash: needsRehash,
590
622
  policy: policy,
623
+ params: params,
591
624
  DEFAULT_PARAMS: DEFAULT_PARAMS,
592
625
  DEFAULT_POLICY: DEFAULT_POLICY,
593
626
  POLICY_PROFILES: POLICY_PROFILES,
627
+ OWASP_FLOOR_2026: OWASP_FLOOR_2026,
594
628
  };
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ /**
3
+ * codepoint-class — shared codepoint-table threat catalog and regex
4
+ * compiler for the guard-* family.
5
+ *
6
+ * Threat detectors that need to match Unicode bidi overrides, C0
7
+ * control characters, zero-width / invisible chars, etc. compose
8
+ * regex character classes from numeric codepoint range tables here
9
+ * instead of embedding the attack characters directly in their
10
+ * source files. Centralizing the tables means:
11
+ *
12
+ * - Source files in lib/guard-* stay pure ASCII (zero
13
+ * irregular-whitespace lint findings, no eslint-disable comments
14
+ * for this category).
15
+ * - Adding / removing a codepoint from the catalog is a single
16
+ * edit; every guard picks up the change.
17
+ * - The detector composes the way an attacker would compose the
18
+ * payload (programmatic codepoint emission, not literal typing).
19
+ *
20
+ * Surface:
21
+ *
22
+ * hex4(cp) -> "\\uXXXX" escape for a single codepoint
23
+ * charClass(ranges) -> regex character class body for a range
24
+ * table (e.g. [0x200E, [0x202A,0x202E]])
25
+ * fromCp(cp) -> String.fromCharCode shorthand
26
+ * ranges() -> { BIDI_RANGES, C0_CTRL_RANGES,
27
+ * ZERO_WIDTH_RANGES }
28
+ * compiled() -> { BIDI_RE, BIDI_RE_G, C0_CTRL_RE,
29
+ * C0_CTRL_RE_G, ZERO_WIDTH_RE, ZW_RE_G,
30
+ * NULL_RE_G, NULL_BYTE, BOM_CHAR }
31
+ *
32
+ * The compiled() exports are RegExp instances built from the
33
+ * codepoint tables at module load. Consumers grab them once at boot.
34
+ *
35
+ * Codepoint tables:
36
+ *
37
+ * BIDI_RANGES — Unicode bidi-override family (CVE-2021-42574
38
+ * Trojan Source). LRM U+200E / RLM U+200F / ALM U+061C / LRE
39
+ * U+202A / RLE U+202B / PDF U+202C / LRO U+202D / RLO U+202E /
40
+ * LRI U+2066 / RLI U+2067 / FSI U+2068 / PDI U+2069.
41
+ *
42
+ * C0_CTRL_RANGES — C0 control characters minus tab (U+09) / lf
43
+ * (U+0A) / cr (U+0D) — those are dialect-shaped chars that
44
+ * parsers handle separately. Everything else (U+00, U+01-U+08,
45
+ * U+0B-U+0C, U+0E-U+1F) flagged as control-byte injection.
46
+ *
47
+ * ZERO_WIDTH_RANGES — invisible-formatting / zero-width chars
48
+ * attackers use to hide payloads:
49
+ * SHY U+00AD ZWSP U+200B ZWNJ U+200C ZWJ U+200D
50
+ * WJ U+2060 BOM U+FEFF
51
+ */
52
+
53
+ var HEX_RADIX = 16; // allow:raw-byte-literal — base-16 radix, not byte size
54
+
55
+ function hex4(cp) {
56
+ var s = cp.toString(HEX_RADIX).toUpperCase();
57
+ while (s.length < 4) s = "0" + s;
58
+ return "\\u" + s;
59
+ }
60
+ function charClass(rangeList) {
61
+ return rangeList.map(function (r) {
62
+ return Array.isArray(r) ? hex4(r[0]) + "-" + hex4(r[1]) : hex4(r);
63
+ }).join("");
64
+ }
65
+ function fromCp(cp) { return String.fromCharCode(cp); }
66
+
67
+ var BIDI_RANGES = [0x200E, 0x200F, 0x061C, [0x202A, 0x202E], [0x2066, 0x2069]];
68
+ var C0_CTRL_RANGES = [[0x0000, 0x0008], 0x000B, 0x000C, [0x000E, 0x001F]];
69
+ var ZERO_WIDTH_RANGES = [0x00AD, [0x200B, 0x200D], 0x2060, 0xFEFF];
70
+
71
+ // allow:dynamic-regex — codepoints from BIDI_RANGES literal table
72
+ var BIDI_RE = new RegExp("[" + charClass(BIDI_RANGES) + "]");
73
+ // allow:dynamic-regex — codepoints from BIDI_RANGES literal table
74
+ var BIDI_RE_G = new RegExp("[" + charClass(BIDI_RANGES) + "]", "g");
75
+ // allow:dynamic-regex — codepoints from C0_CTRL_RANGES literal table
76
+ var C0_CTRL_RE = new RegExp("[" + charClass(C0_CTRL_RANGES) + "]");
77
+ // allow:dynamic-regex — codepoints from C0_CTRL_RANGES literal table
78
+ var C0_CTRL_RE_G = new RegExp("[" + charClass(C0_CTRL_RANGES) + "]", "g");
79
+ // allow:dynamic-regex — codepoints from ZERO_WIDTH_RANGES literal table
80
+ var ZERO_WIDTH_RE = new RegExp("[" + charClass(ZERO_WIDTH_RANGES) + "]");
81
+ // allow:dynamic-regex — codepoints from ZERO_WIDTH_RANGES literal table
82
+ var ZW_RE_G = new RegExp("[" + charClass(ZERO_WIDTH_RANGES) + "]", "g");
83
+ // allow:dynamic-regex — single literal codepoint U+0000
84
+ var NULL_RE_G = new RegExp(hex4(0x0000), "g");
85
+
86
+ var NULL_BYTE = fromCp(0x0000);
87
+ var BOM_CHAR = fromCp(0xFEFF);
88
+
89
+ // detectCharThreats — returns an array of issue objects for character-
90
+ // class threats (bidi / null / C0-control) per the opts policy. Emits
91
+ // at most one issue per class. Used by guard-* primitives' detection
92
+ // pass instead of repeating the per-class match-and-push block.
93
+ //
94
+ // Issue shape mirrors guard-* convention:
95
+ // { kind, severity, ruleId, location, snippet }
96
+ //
97
+ // issues.push.apply(issues,
98
+ // codepointClass.detectCharThreats(text, opts, "html"));
99
+ function detectCharThreats(text, opts, codePrefix) {
100
+ var issues = [];
101
+ if (typeof text !== "string") return issues;
102
+ if (opts && opts.bidiPolicy !== "allow") {
103
+ var bidiMatch = text.match(BIDI_RE);
104
+ if (bidiMatch) {
105
+ issues.push({
106
+ kind: "bidi-override", severity: "critical",
107
+ ruleId: codePrefix + ".bidi",
108
+ location: bidiMatch.index,
109
+ snippet: "Unicode bidi override (CVE-2021-42574 Trojan Source)",
110
+ });
111
+ }
112
+ }
113
+ if (opts && opts.nullBytePolicy !== "allow") {
114
+ var nullIdx = text.indexOf(NULL_BYTE);
115
+ if (nullIdx >= 0) {
116
+ issues.push({
117
+ kind: "null-byte", severity: "critical",
118
+ ruleId: codePrefix + ".null-byte",
119
+ location: nullIdx,
120
+ snippet: "null byte at byte " + nullIdx,
121
+ });
122
+ }
123
+ }
124
+ if (opts && opts.controlPolicy !== "allow") {
125
+ var ctrlMatch = text.match(C0_CTRL_RE);
126
+ if (ctrlMatch) {
127
+ issues.push({
128
+ kind: "control-char", severity: "high",
129
+ ruleId: codePrefix + ".control",
130
+ location: ctrlMatch.index,
131
+ snippet: "C0 control char U+" + ctrlMatch[0].charCodeAt(0).toString(HEX_RADIX),
132
+ });
133
+ }
134
+ }
135
+ return issues;
136
+ }
137
+
138
+ // assertNoCharThreats — throws an instance of errorFactory(code, msg)
139
+ // when the text contains a class that's set to "reject" in opts.
140
+ // Opt-name vocabulary: bidiPolicy / nullBytePolicy / controlPolicy
141
+ // (the standard guard-* family naming; older guard-csv uses different
142
+ // names and keeps its inline checks).
143
+ function assertNoCharThreats(text, opts, errorFactory, codePrefix) {
144
+ if (typeof text !== "string") return;
145
+ if (opts && opts.bidiPolicy === "reject" && BIDI_RE.test(text)) { // allow:regex-no-length-cap — caller bounds length before invoking
146
+ throw errorFactory(codePrefix + ".bidi",
147
+ "input contains Unicode bidi override (CVE-2021-42574)");
148
+ }
149
+ if (opts && opts.nullBytePolicy === "reject" && text.indexOf(NULL_BYTE) !== -1) {
150
+ throw errorFactory(codePrefix + ".null-byte",
151
+ "input contains null byte");
152
+ }
153
+ if (opts && opts.controlPolicy === "reject" && C0_CTRL_RE.test(text)) { // allow:regex-no-length-cap — caller bounds length before invoking
154
+ throw errorFactory(codePrefix + ".control",
155
+ "input contains C0 control character");
156
+ }
157
+ }
158
+
159
+ // applyCharStripPolicies — given a text and a policy object, apply
160
+ // strip-mode replacements for each character-class threat. Reads:
161
+ // opts.bidiPolicy === "strip" -> strip BIDI overrides
162
+ // opts.controlPolicy === "strip" -> strip C0 controls
163
+ // opts.nullBytePolicy === "strip" -> strip null bytes
164
+ // opts.zeroWidthPolicy === "strip" -> strip zero-widths
165
+ // Returns the cleaned string. Used by every guard's sanitize path so
166
+ // each one doesn't reinvent the same sequence of replace() calls.
167
+ function applyCharStripPolicies(text, opts) {
168
+ if (typeof text !== "string") return text;
169
+ var out = text;
170
+ if (opts && opts.bidiPolicy === "strip") out = out.replace(BIDI_RE_G, "");
171
+ if (opts && opts.controlPolicy === "strip") out = out.replace(C0_CTRL_RE_G, "");
172
+ if (opts && opts.nullBytePolicy === "strip") out = out.replace(NULL_RE_G, "");
173
+ if (opts && opts.zeroWidthPolicy === "strip") out = out.replace(ZW_RE_G, "");
174
+ return out;
175
+ }
176
+
177
+ module.exports = {
178
+ hex4: hex4,
179
+ charClass: charClass,
180
+ fromCp: fromCp,
181
+ BIDI_RANGES: BIDI_RANGES,
182
+ C0_CTRL_RANGES: C0_CTRL_RANGES,
183
+ ZERO_WIDTH_RANGES: ZERO_WIDTH_RANGES,
184
+ BIDI_RE: BIDI_RE,
185
+ BIDI_RE_G: BIDI_RE_G,
186
+ C0_CTRL_RE: C0_CTRL_RE,
187
+ C0_CTRL_RE_G: C0_CTRL_RE_G,
188
+ ZERO_WIDTH_RE: ZERO_WIDTH_RE,
189
+ ZW_RE_G: ZW_RE_G,
190
+ NULL_RE_G: NULL_RE_G,
191
+ NULL_BYTE: NULL_BYTE,
192
+ BOM_CHAR: BOM_CHAR,
193
+ applyCharStripPolicies: applyCharStripPolicies,
194
+ assertNoCharThreats: assertNoCharThreats,
195
+ detectCharThreats: detectCharThreats,
196
+ };
package/lib/csv.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * // returns array of arrays
11
11
  *
12
12
  * var out = b.csv.stringify(rows);
13
- * // RFC 4180 + Excel-formula-injection prevention by default
13
+ * // RFC 4180 quoting + anti-DoS bounds. NO threat-catalog handling.
14
14
  *
15
15
  * Defaults:
16
16
  * parse:
@@ -24,20 +24,22 @@
24
24
  * onBadRow: "throw" "skip" tolerates short/long rows
25
25
  *
26
26
  * stringify:
27
- * header: true (or array of explicit columns)
28
- * delimiter: ","
29
- * quote: '"'
30
- * eol: "\r\n" RFC 4180; "\n" via opt
31
- * alwaysQuote: false only quote when needed
32
- * preventFormulaInjection: true prefix '=' / '+' / '-' / '@' / TAB / CR
33
- * cells with "'" so Excel doesn't
34
- * execute them when the CSV is opened
35
- * formulaPrefixChars: ["=","+","-","@","\t","\r"]
27
+ * header: true (or array of explicit columns)
28
+ * delimiter: ","
29
+ * quote: '"'
30
+ * eol: "\r\n" RFC 4180; "\n" via opt
31
+ * alwaysQuote: false only quote when needed
36
32
  *
37
- * SECURITY: stringify defends against CSV-injection (Excel/Sheets execute
38
- * cells starting with "=" / "+" / "-" / "@" / TAB / CR as formulas).
39
- * Operator-controlled cells stay safe; user-supplied cells get the leading
40
- * single-quote so the formula engine sees a literal string.
33
+ * SCOPE: this module is for trusted-source-only emission. It performs RFC
34
+ * 4180 quote/delimiter escaping but does NOT defend against the broader
35
+ * CSV-injection threat catalog (Excel/Sheets formula triggers, Unicode
36
+ * bidi overrides, dangerous-function denylist, homoglyphs, control-byte
37
+ * injection, BOM mid-stream, dialect ambiguity, CSV-bombs). Any path
38
+ * that emits or accepts user-supplied cells MUST route through
39
+ * `b.guardCsv` — its `serialize` / `validate` / `sanitize` / `gate`
40
+ * surface handles every documented threat with a single profile choice
41
+ * (strict / balanced / permissive / email-attachment) or compliance
42
+ * posture (hipaa / pci-dss / gdpr / soc2-cc7).
41
43
  *
42
44
  * Throws CsvError (FrameworkError, permanent) on shape violations.
43
45
  */
@@ -59,14 +61,12 @@ var DEFAULTS_PARSE = {
59
61
  };
60
62
 
61
63
  var DEFAULTS_STRINGIFY = {
62
- header: true,
63
- delimiter: ",",
64
- quote: "\"",
65
- eol: "\r\n",
66
- alwaysQuote: false,
67
- preventFormulaInjection: true,
68
- formulaPrefixChars: ["=", "+", "-", "@", "\t", "\r"],
69
- columns: null,
64
+ header: true,
65
+ delimiter: ",",
66
+ quote: "\"",
67
+ eol: "\r\n",
68
+ alwaysQuote: false,
69
+ columns: null,
70
70
  };
71
71
 
72
72
  function _validateDelim(name, value) {
@@ -88,12 +88,9 @@ function parse(input, opts) {
88
88
  // Infinity / NaN bypass the corresponding caps and let a hostile
89
89
  // multi-megabyte CSV (or a single hostile row / single hostile field)
90
90
  // through unbounded.
91
- numericBounds.requirePositiveFiniteIntIfPresent(opts.maxBytes,
92
- "csv.parse: maxBytes", CsvError, "csv/bad-opt");
93
- numericBounds.requirePositiveFiniteIntIfPresent(opts.maxRows,
94
- "csv.parse: maxRows", CsvError, "csv/bad-opt");
95
- numericBounds.requirePositiveFiniteIntIfPresent(opts.maxFieldBytes,
96
- "csv.parse: maxFieldBytes", CsvError, "csv/bad-opt");
91
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
92
+ ["maxBytes", "maxRows", "maxFieldBytes"],
93
+ "csv.parse", CsvError, "csv/bad-opt");
97
94
 
98
95
  var s;
99
96
  if (typeof input === "string") s = input;
@@ -239,16 +236,8 @@ function stringify(rows, opts) {
239
236
  "stringify: rows must be arrays or plain objects");
240
237
  }
241
238
 
242
- var prefixSet = Object.create(null);
243
- for (var pi = 0; pi < opts.formulaPrefixChars.length; pi++) {
244
- prefixSet[opts.formulaPrefixChars[pi]] = true;
245
- }
246
-
247
239
  function escapeCell(value) {
248
240
  var str = value == null ? "" : String(value);
249
- if (opts.preventFormulaInjection && str.length > 0 && prefixSet[str.charAt(0)]) {
250
- str = "'" + str;
251
- }
252
241
  var needsQuote = opts.alwaysQuote ||
253
242
  str.indexOf(opts.delimiter) !== -1 ||
254
243
  str.indexOf(opts.quote) !== -1 ||
@@ -158,11 +158,10 @@ function _validateOpts(opts) {
158
158
  }
159
159
 
160
160
  var hashColumns = {};
161
+ validateOpts.optionalPlainObject(opts.hashColumns, "hashColumns",
162
+ DeclareViewError, "declare-view/bad-type",
163
+ "must be an object { aliasOrHashCol: srcCol }");
161
164
  if (opts.hashColumns !== undefined && opts.hashColumns !== null) {
162
- if (typeof opts.hashColumns !== "object" || Array.isArray(opts.hashColumns)) {
163
- throw _err("declare-view/bad-type",
164
- "hashColumns must be an object { aliasOrHashCol: srcCol }");
165
- }
166
165
  for (var hc in opts.hashColumns) {
167
166
  if (!Object.prototype.hasOwnProperty.call(opts.hashColumns, hc)) continue;
168
167
  _validateIdent("hashColumns key '" + hc + "'", hc);
@@ -155,6 +155,8 @@ var stream = require("node:stream");
155
155
  var atomicFile = require("./atomic-file");
156
156
  var C = require("./constants");
157
157
  var crypto = require("./crypto");
158
+ var gateContract = require("./gate-contract");
159
+ var lazyRequire = require("./lazy-require");
158
160
  var numericBounds = require("./numeric-bounds");
159
161
  var requestHelpers = require("./request-helpers");
160
162
  var safeBuffer = require("./safe-buffer");
@@ -162,6 +164,12 @@ var safeJson = require("./safe-json");
162
164
  var validateOpts = require("./validate-opts");
163
165
  var { FileUploadError } = require("./framework-error");
164
166
 
167
+ // guard-* family is wired on by default; lazy-loaded to avoid eager
168
+ // import cycles (guards consume framework primitives that may not be
169
+ // resolved at file-upload load-time).
170
+ var guardAll = lazyRequire(function () { return require("./guard-all"); });
171
+ var guardFilename = lazyRequire(function () { return require("./guard-filename"); });
172
+
165
173
  var _err = FileUploadError.factory;
166
174
 
167
175
  var DEFAULTS = Object.freeze({
@@ -213,16 +221,10 @@ function _validateCreateOpts(opts) {
213
221
  }
214
222
  validateOpts.optionalFunction(opts.onFinalize, "fileUpload.create: onFinalize", FileUploadError);
215
223
  validateOpts.optionalFunction(opts.onChunk, "fileUpload.create: onChunk", FileUploadError);
216
- numericBounds.requirePositiveFiniteIntIfPresent(opts.maxFileBytes,
217
- "fileUpload.create: maxFileBytes", FileUploadError, "BAD_OPT");
218
- numericBounds.requirePositiveFiniteIntIfPresent(opts.maxChunkBytes,
219
- "fileUpload.create: maxChunkBytes", FileUploadError, "BAD_OPT");
220
- numericBounds.requirePositiveFiniteIntIfPresent(opts.maxStreamReassemblyBytes,
221
- "fileUpload.create: maxStreamReassemblyBytes", FileUploadError, "BAD_OPT");
222
- numericBounds.requirePositiveFiniteIntIfPresent(opts.maxStagingBytes,
223
- "fileUpload.create: maxStagingBytes", FileUploadError, "BAD_OPT");
224
- numericBounds.requirePositiveFiniteIntIfPresent(opts.maxActiveUploadsPerActor,
225
- "fileUpload.create: maxActiveUploadsPerActor", FileUploadError, "BAD_OPT");
224
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
225
+ ["maxFileBytes", "maxChunkBytes", "maxStreamReassemblyBytes",
226
+ "maxStagingBytes", "maxActiveUploadsPerActor"],
227
+ "fileUpload.create", FileUploadError, "BAD_OPT");
226
228
  numericBounds.requireNonNegativeFiniteIntIfPresent(opts.incompleteTtlMs,
227
229
  "fileUpload.create: incompleteTtlMs", FileUploadError, "BAD_OPT");
228
230
  numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxIdleMs,
@@ -247,6 +249,37 @@ function _validateCreateOpts(opts) {
247
249
  validateOpts.optionalObjectWithMethod(opts.permissions, "check",
248
250
  "fileUpload.create: permissions", FileUploadError, "BAD_OPT",
249
251
  "must be a b.permissions instance (check fn)");
252
+ // contentSafety — extension-keyed gate map for per-extension content
253
+ // validation. Default behaviour: when undefined, the framework wires
254
+ // b.guardAll.byExtension({ profile: "strict" }) automatically so every
255
+ // shipped guard is ON by default. Explicit opt-out: contentSafety:
256
+ // null (audited at create() time so a security review can reconstruct
257
+ // which deploys disabled the default-on protection).
258
+ // Example: contentSafety: { ".csv": b.guardCsv.gate({ profile: "strict" }) }
259
+ if (opts.contentSafety !== undefined && opts.contentSafety !== null) {
260
+ validateOpts.optionalPlainObject(opts.contentSafety,
261
+ "fileUpload.create: contentSafety", FileUploadError, "BAD_OPT",
262
+ "must be a plain { ext: gate } object, null to opt out, or " +
263
+ "undefined for the default-on b.guardAll wiring");
264
+ var safetyKeys = Object.keys(opts.contentSafety);
265
+ for (var sk = 0; sk < safetyKeys.length; sk++) {
266
+ var ext = safetyKeys[sk];
267
+ var g = opts.contentSafety[ext];
268
+ if (!g || typeof g.check !== "function") {
269
+ throw _err("BAD_OPT",
270
+ "fileUpload.create: contentSafety[" + JSON.stringify(ext) +
271
+ "] must be a gate (b.guardCsv.gate / b.guardHtml.gate / etc.)");
272
+ }
273
+ }
274
+ }
275
+ // filenameSafety — single gate for filename validation. Default: on.
276
+ // Operator opts out with filenameSafety: null (audited).
277
+ if (opts.filenameSafety !== undefined && opts.filenameSafety !== null) {
278
+ validateOpts.optionalObjectWithMethod(opts.filenameSafety, "check",
279
+ "fileUpload.create: filenameSafety", FileUploadError, "BAD_OPT",
280
+ "must be a gate (b.guardFilename.gate(...)), null to opt out, or " +
281
+ "undefined for the default-on wiring");
282
+ }
250
283
  }
251
284
 
252
285
  function create(opts) {
@@ -257,6 +290,74 @@ function create(opts) {
257
290
  var onChunk = opts.onChunk || null;
258
291
  var fileType = opts.fileType || null;
259
292
  var permissions = opts.permissions || null;
293
+ // ---- Default-on safety wiring ----
294
+ // contentSafety: undefined → wire b.guardAll.byExtension({ profile: "strict" })
295
+ // contentSafety: null → explicit opt-out, audit row emitted
296
+ // contentSafety: { ... } → use operator-supplied map
297
+ var contentSafety;
298
+ if (opts.contentSafety === undefined) {
299
+ // Strict profile is the security-correct default — every shipped
300
+ // guard's full threat catalog refused, including dangerous tags
301
+ // (script / style / iframe), event handlers, dangerous URL
302
+ // schemes, formula injection, DOCTYPE / SVGZ / animation-href
303
+ // hijack. Operators who need a broader content vocabulary opt up
304
+ // explicitly via contentSafety: b.guardAll.byExtension({
305
+ // profile: "balanced" | "permissive" }).
306
+ contentSafety = guardAll().byExtension({
307
+ profile: "strict",
308
+ audit: opts.audit,
309
+ observability: opts.observability,
310
+ });
311
+ } else if (opts.contentSafety === null) {
312
+ if (opts.audit && typeof opts.audit.safeEmit === "function") {
313
+ try {
314
+ opts.audit.safeEmit({
315
+ action: "fileUpload.contentSafety.disabled",
316
+ actor: {},
317
+ outcome: "success",
318
+ metadata: {
319
+ reason: opts.contentSafetyDisabledReason || "operator-explicit-opt-out",
320
+ },
321
+ });
322
+ } catch (_e) { /* audit best-effort */ }
323
+ }
324
+ contentSafety = null;
325
+ } else {
326
+ contentSafety = opts.contentSafety;
327
+ }
328
+ // filenameSafety: undefined → b.guardFilename.gate({ profile: "strict" })
329
+ // filenameSafety: null → explicit opt-out, audit row emitted
330
+ // filenameSafety: gate → use operator-supplied gate
331
+ var filenameSafety;
332
+ if (opts.filenameSafety === undefined) {
333
+ // Strict filename profile: ASCII-only, single-dot, 64-byte leaf
334
+ // cap, refuses every shell-exec extension (.exe / .bat / .vbs /
335
+ // .ps1 / .lnk / .scr / .dll / .so / .dmg / .msi / etc.). Operators
336
+ // accepting Unicode filenames or executable-extension artifacts
337
+ // opt up explicitly via filenameSafety: b.guardFilename.gate({
338
+ // profile: "balanced" | "permissive" }).
339
+ filenameSafety = guardFilename().gate({
340
+ profile: "strict",
341
+ audit: opts.audit,
342
+ observability: opts.observability,
343
+ });
344
+ } else if (opts.filenameSafety === null) {
345
+ if (opts.audit && typeof opts.audit.safeEmit === "function") {
346
+ try {
347
+ opts.audit.safeEmit({
348
+ action: "fileUpload.filenameSafety.disabled",
349
+ actor: {},
350
+ outcome: "success",
351
+ metadata: {
352
+ reason: opts.filenameSafetyDisabledReason || "operator-explicit-opt-out",
353
+ },
354
+ });
355
+ } catch (_e) { /* audit best-effort */ }
356
+ }
357
+ filenameSafety = null;
358
+ } else {
359
+ filenameSafety = opts.filenameSafety;
360
+ }
260
361
  var maxFileBytes = cfg.maxFileBytes;
261
362
  var maxChunkBytes = cfg.maxChunkBytes;
262
363
  var maxStreamReassemblyBytes = cfg.maxStreamReassemblyBytes;
@@ -756,6 +857,108 @@ function create(opts) {
756
857
  throw e;
757
858
  }
758
859
 
860
+ // Content-safety gate — operator-supplied per-extension gate
861
+ // (b.guardCsv.gate / b.guardHtml.gate / etc.). Routes the assembled
862
+ // body through the gate's check() before handing to onFinalize. The
863
+ // decision is honored:
864
+ // - serve → continue with the original buffer
865
+ // - sanitize → continue with decision.sanitized (operator's
866
+ // onFinalize sees the cleaned bytes)
867
+ // - refuse → throw FileUploadError; operator route surfaces the
868
+ // rejection to the client
869
+ // filenameSafety — single gate that validates the filename string
870
+ // (path traversal / null-byte / Windows reserved names / NTFS ADS /
871
+ // RTLO bidi / overlong UTF-8 / shell-exec / double-extension).
872
+ // Runs BEFORE contentSafety because a refused filename obviates
873
+ // the need to validate the body.
874
+ var filename = (meta.metadata && meta.metadata.filename) || uploadId;
875
+ if (filenameSafety && typeof filenameSafety.check === "function") {
876
+ var fnDecision;
877
+ try {
878
+ fnDecision = await filenameSafety.check({
879
+ filename: filename,
880
+ actor: actor,
881
+ direction: "inbound",
882
+ metadata: meta.metadata,
883
+ });
884
+ } catch (fnErr) {
885
+ _emitObs("fileUpload.filename_safety_threw", 1);
886
+ _emitAudit("fileUpload.finalize_failure", {
887
+ actor: requestHelpers.extractActorContext(actor),
888
+ outcome: "failure", reason: "filename-safety-threw",
889
+ metadata: { uploadId: uploadId, error: fnErr && fnErr.message },
890
+ });
891
+ throw _err("FILENAME_SAFETY_THREW",
892
+ "fileUpload.finalize: filenameSafety gate threw: " + (fnErr && fnErr.message));
893
+ }
894
+ if (!fnDecision.ok || fnDecision.action === "refuse") {
895
+ _emitObs("fileUpload.filename_safety_refused", 1);
896
+ _emitAudit("fileUpload.finalize_failure", {
897
+ actor: requestHelpers.extractActorContext(actor),
898
+ outcome: "failure", reason: "filename-safety-refused",
899
+ metadata: {
900
+ uploadId: uploadId, filename: filename,
901
+ issues: gateContract.summarizeIssues(fnDecision.issues),
902
+ },
903
+ });
904
+ throw _err("FILENAME_SAFETY_REFUSED",
905
+ "fileUpload.finalize: filenameSafety refused " + JSON.stringify(filename) +
906
+ ": " + gateContract.summarizeIssues(fnDecision.issues));
907
+ }
908
+ // sanitize: replace metadata.filename with the sanitized form so
909
+ // downstream code sees the cleaned name.
910
+ if (fnDecision.action === "sanitize" && fnDecision.sanitizedFilename) {
911
+ meta.metadata = Object.assign({}, meta.metadata || {},
912
+ { filename: fnDecision.sanitizedFilename });
913
+ filename = fnDecision.sanitizedFilename;
914
+ }
915
+ }
916
+ if (contentSafety) {
917
+ var safetyExt = path.extname(filename).toLowerCase();
918
+ var safetyGate = contentSafety[safetyExt];
919
+ if (safetyGate && typeof safetyGate.check === "function" && bodyBuffer) {
920
+ var safetyDecision;
921
+ try {
922
+ safetyDecision = await safetyGate.check({
923
+ bytes: bodyBuffer,
924
+ filename: filename,
925
+ actor: actor,
926
+ direction: "inbound",
927
+ metadata: meta.metadata,
928
+ });
929
+ } catch (gateErr) {
930
+ _emitObs("fileUpload.content_safety_threw", 1);
931
+ _emitAudit("fileUpload.finalize_failure", {
932
+ actor: requestHelpers.extractActorContext(actor),
933
+ outcome: "failure", reason: "content-safety-threw",
934
+ metadata: { uploadId: uploadId, error: gateErr && gateErr.message },
935
+ });
936
+ throw _err("CONTENT_SAFETY_THREW",
937
+ "fileUpload.finalize: contentSafety gate threw: " + (gateErr && gateErr.message));
938
+ }
939
+ if (!safetyDecision.ok || safetyDecision.action === "refuse") {
940
+ _emitObs("fileUpload.content_safety_refused", 1, { ext: safetyExt });
941
+ _emitAudit("fileUpload.finalize_failure", {
942
+ actor: requestHelpers.extractActorContext(actor),
943
+ outcome: "failure", reason: "content-safety-refused",
944
+ metadata: {
945
+ uploadId: uploadId, ext: safetyExt,
946
+ issues: gateContract.summarizeIssues(safetyDecision.issues),
947
+ },
948
+ });
949
+ throw _err("CONTENT_SAFETY_REFUSED",
950
+ "fileUpload.finalize: contentSafety gate refused upload (" +
951
+ (safetyDecision.issues || []).map(function (i) { return i.kind; }).join(", ") + ")");
952
+ }
953
+ if (safetyDecision.action === "sanitize" && safetyDecision.sanitized) {
954
+ // Replace the body buffer with the sanitized variant.
955
+ bodyBuffer = safetyDecision.sanitized;
956
+ // Clear the streaming alias if present — sanitized fits in memory.
957
+ bodyStream = null;
958
+ }
959
+ }
960
+ }
961
+
759
962
  // Hand to operator's onFinalize.
760
963
  var rv;
761
964
  try {