@blamejs/core 0.7.4 → 0.7.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -0
- package/index.js +27 -1
- package/lib/api-key.js +2 -5
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +27 -1
- package/lib/auth/password.js +34 -0
- package/lib/codepoint-class.js +196 -0
- package/lib/csv.js +25 -36
- package/lib/db-declare-view.js +3 -4
- package/lib/file-upload.js +213 -10
- package/lib/framework-error.js +78 -0
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/mail-dkim.js +13 -6
- package/lib/mail.js +19 -0
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +79 -0
- package/lib/middleware/index.js +3 -0
- package/lib/numeric-bounds.js +20 -0
- package/lib/session.js +61 -4
- package/lib/static.js +184 -4
- package/lib/validate-opts.js +21 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/auth/password.js
CHANGED
|
@@ -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 +
|
|
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:
|
|
28
|
-
* delimiter:
|
|
29
|
-
* quote:
|
|
30
|
-
* eol:
|
|
31
|
-
* alwaysQuote:
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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:
|
|
63
|
-
delimiter:
|
|
64
|
-
quote:
|
|
65
|
-
eol:
|
|
66
|
-
alwaysQuote:
|
|
67
|
-
|
|
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.
|
|
92
|
-
"
|
|
93
|
-
|
|
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 ||
|
package/lib/db-declare-view.js
CHANGED
|
@@ -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);
|
package/lib/file-upload.js
CHANGED
|
@@ -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.
|
|
217
|
-
"
|
|
218
|
-
|
|
219
|
-
"fileUpload.create
|
|
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 {
|