@blamejs/core 0.9.43 → 0.9.46
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 +3 -0
- package/index.js +4 -0
- package/lib/agent-tenant.js +168 -0
- package/lib/argon2-builtin.js +8 -1
- package/lib/auth/dpop.js +2 -7
- package/lib/auth/fal.js +1 -1
- package/lib/auth/jwt.js +3 -7
- package/lib/auth/oauth.js +4 -8
- package/lib/auth/status-list.js +3 -8
- package/lib/crypto.js +61 -0
- package/lib/guard-smtp-command.js +65 -10
- package/lib/mail-server-mx.js +736 -0
- package/lib/middleware/protected-resource-metadata.js +1 -1
- package/lib/network-dns-resolver.js +2 -1
- package/lib/network-dns.js +4 -3
- package/lib/object-store/gcs.js +2 -6
- package/lib/pagination.js +2 -7
- package/lib/safe-smtp.js +128 -0
- package/lib/storage.js +417 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
var C = require("./constants");
|
|
108
108
|
var https = require("node:https");
|
|
109
109
|
var nodeCrypto = require("node:crypto");
|
|
110
|
+
var bCrypto = require("./crypto");
|
|
110
111
|
var { defineClass } = require("./framework-error");
|
|
111
112
|
var networkDns = require("./network-dns");
|
|
112
113
|
var safeDns = require("./safe-dns");
|
|
@@ -413,7 +414,7 @@ async function _wireLookup(name, qtype) {
|
|
|
413
414
|
var url = networkDns._getDohUrlForTest ? networkDns._getDohUrlForTest() : "https://cloudflare-dns.com/dns-query";
|
|
414
415
|
// Encode a wire-format query for the target qtype.
|
|
415
416
|
var qbuf = _encodeWireQuery(name, qtype);
|
|
416
|
-
var b64 =
|
|
417
|
+
var b64 = bCrypto.toBase64Url(qbuf);
|
|
417
418
|
var getUrl = url + (url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
418
419
|
var u = safeUrl.parse(getUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
|
|
419
420
|
return new Promise(function (resolve, reject) {
|
package/lib/network-dns.js
CHANGED
|
@@ -8,6 +8,7 @@ var nodeTls = require("node:tls");
|
|
|
8
8
|
var dnsPromises = dns.promises;
|
|
9
9
|
|
|
10
10
|
var C = require("./constants");
|
|
11
|
+
var bCrypto = require("./crypto");
|
|
11
12
|
var lazyRequire = require("./lazy-require");
|
|
12
13
|
var safeBuffer = require("./safe-buffer");
|
|
13
14
|
var safeUrl = require("./safe-url");
|
|
@@ -368,7 +369,7 @@ var DOH_GET_URL_MAX_BYTES = 2048;
|
|
|
368
369
|
async function _dohLookup(host, family) {
|
|
369
370
|
var qtype = family === 6 ? 28 : 1;
|
|
370
371
|
var enc = _encodeDnsQuery(host, qtype);
|
|
371
|
-
var b64 = enc.buf
|
|
372
|
+
var b64 = bCrypto.toBase64Url(enc.buf);
|
|
372
373
|
var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
373
374
|
var forcedMethod = STATE.doh.method;
|
|
374
375
|
var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
|
|
@@ -437,7 +438,7 @@ async function _dohLookup(host, family) {
|
|
|
437
438
|
async function _dohLookupSecure(host, family) {
|
|
438
439
|
var qtype = family === 6 ? 28 : 1; // allow:raw-byte-literal — DNS QTYPE values for A / AAAA
|
|
439
440
|
var enc = _encodeDnsQuery(host, qtype);
|
|
440
|
-
var b64 = enc.buf
|
|
441
|
+
var b64 = bCrypto.toBase64Url(enc.buf);
|
|
441
442
|
var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
442
443
|
var forcedMethod = STATE.doh.method;
|
|
443
444
|
var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
|
|
@@ -792,7 +793,7 @@ function _decodeDnsAnswerRaw(buf) {
|
|
|
792
793
|
|
|
793
794
|
async function _dohRawQuery(host, qtype) {
|
|
794
795
|
var enc = _encodeDnsQuery(host, qtype);
|
|
795
|
-
var b64 = enc.buf
|
|
796
|
+
var b64 = bCrypto.toBase64Url(enc.buf);
|
|
796
797
|
var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
797
798
|
var forcedMethod = STATE.doh.method;
|
|
798
799
|
var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
|
package/lib/object-store/gcs.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
var nodeFs = require("node:fs");
|
|
26
26
|
var nodeCrypto = require("node:crypto");
|
|
27
27
|
var { Readable } = require("node:stream");
|
|
28
|
+
var bCrypto = require("../crypto");
|
|
28
29
|
var safeJson = require("../safe-json");
|
|
29
30
|
var C = require("../constants");
|
|
30
31
|
var numericBounds = require("../numeric-bounds");
|
|
@@ -88,12 +89,7 @@ var _httpRequest = sharedRequest;
|
|
|
88
89
|
|
|
89
90
|
// ---- JWT signing for service-account auth ----
|
|
90
91
|
|
|
91
|
-
function _base64UrlEncode(buf) {
|
|
92
|
-
return Buffer.from(buf).toString("base64")
|
|
93
|
-
.replace(/=+$/g, "")
|
|
94
|
-
.replace(/\+/g, "-")
|
|
95
|
-
.replace(/\//g, "_");
|
|
96
|
-
}
|
|
92
|
+
function _base64UrlEncode(buf) { return bCrypto.toBase64Url(buf); }
|
|
97
93
|
|
|
98
94
|
function _signJwt(serviceAccount, scope, audience) {
|
|
99
95
|
var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
|
package/lib/pagination.js
CHANGED
|
@@ -83,16 +83,11 @@ function _toBuf(secret) {
|
|
|
83
83
|
"secret must be a Buffer or non-empty string");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function _b64urlEncode(buf) {
|
|
87
|
-
var b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
88
|
-
return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
89
|
-
}
|
|
86
|
+
function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
|
|
90
87
|
|
|
91
88
|
function _b64urlDecode(s) {
|
|
92
89
|
if (typeof s !== "string") throw new PaginationError("pagination/bad-cursor", "cursor must be a string");
|
|
93
|
-
|
|
94
|
-
var padded = pad ? s + "=".repeat(4 - pad) : s;
|
|
95
|
-
return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
90
|
+
return bCrypto.fromBase64Url(s);
|
|
96
91
|
}
|
|
97
92
|
|
|
98
93
|
function _tag(secretBuf, stateJson) {
|
package/lib/safe-smtp.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.safeSmtp
|
|
4
|
+
* @nav Parsers
|
|
5
|
+
* @title Safe SMTP
|
|
6
|
+
* @order 215
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Wire-protocol parsing helpers for SMTP (RFC 5321) bytes.
|
|
10
|
+
* Operators consuming the framework's MX listener (`b.mail.server.mx`),
|
|
11
|
+
* submission listener (slice that follows), or building their own
|
|
12
|
+
* SMTP-shaped tooling (proxies, log analyzers, test fixtures) reach
|
|
13
|
+
* for these primitives rather than reinventing the dot-terminator
|
|
14
|
+
* scan + dot-stuffing reversal.
|
|
15
|
+
*
|
|
16
|
+
* Separates the "what shape is the wire data" parsing concern from
|
|
17
|
+
* the "is this wire data hostile" guard concern (which lives in
|
|
18
|
+
* `b.guardSmtpCommand`). A safe-* parser primitive returns a
|
|
19
|
+
* bounded shape or `-1`; a guard-* primitive returns a boolean
|
|
20
|
+
* threat verdict or throws a typed error.
|
|
21
|
+
*
|
|
22
|
+
* Wire-protocol references:
|
|
23
|
+
* - RFC 5321 §2.3.8 — line termination MUST be CRLF
|
|
24
|
+
* - RFC 5321 §4.5.2 — dot-stuffing on the SMTP body
|
|
25
|
+
* - RFC 5321 §4.1.1.4 — DATA command terminates with `<CRLF>.<CRLF>`
|
|
26
|
+
* - CVE-2023-51764 / -51765 / -51766 / 2024-32178 — SMTP
|
|
27
|
+
* smuggling (parsers that accept bare-LF dot-terminators).
|
|
28
|
+
* The guard primitive `b.guardSmtpCommand.detectBodySmuggling`
|
|
29
|
+
* owns smuggling detection; the safe-* terminator scanner
|
|
30
|
+
* here is strict CRLF-only by construction.
|
|
31
|
+
*
|
|
32
|
+
* @card
|
|
33
|
+
* Wire-protocol parsing helpers for SMTP (RFC 5321) bytes —
|
|
34
|
+
* findDotTerminator + dotUnstuff. Strict CRLF-only by construction
|
|
35
|
+
* (bare-LF terminators are not honored — the smuggling-detection
|
|
36
|
+
* guard lives in b.guardSmtpCommand.detectBodySmuggling).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
var { defineClass } = require("./framework-error");
|
|
40
|
+
|
|
41
|
+
var SafeSmtpError = defineClass("SafeSmtpError", { alwaysPermanent: true });
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @primitive b.safeSmtp.findDotTerminator
|
|
45
|
+
* @signature b.safeSmtp.findDotTerminator(buf)
|
|
46
|
+
* @since 0.9.46
|
|
47
|
+
* @status stable
|
|
48
|
+
* @related b.safeSmtp.dotUnstuff, b.guardSmtpCommand.detectBodySmuggling
|
|
49
|
+
*
|
|
50
|
+
* Scan `buf` for the canonical RFC 5321 §4.1.1.4 DATA-body terminator
|
|
51
|
+
* `<CRLF>.<CRLF>` (5 bytes: 0x0d 0x0a 0x2e 0x0d 0x0a). Returns the
|
|
52
|
+
* byte index where the body ends (exclusive — the index of the
|
|
53
|
+
* trailing CRLF the terminator starts on), or `-1` if the terminator
|
|
54
|
+
* is not yet present.
|
|
55
|
+
*
|
|
56
|
+
* Strict CRLF-only by construction — bare-LF alternate terminators
|
|
57
|
+
* are NOT honored. Operators worried about smuggling shape route the
|
|
58
|
+
* SAME body through `b.guardSmtpCommand.detectBodySmuggling` before
|
|
59
|
+
* trusting the terminator index returned here.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* var body = Buffer.from("Hello world.\r\n.\r\n");
|
|
63
|
+
* b.safeSmtp.findDotTerminator(body);
|
|
64
|
+
* // → 13 (index of \r in \r\n.\r\n)
|
|
65
|
+
*
|
|
66
|
+
* b.safeSmtp.findDotTerminator(Buffer.from("incomplete body"));
|
|
67
|
+
* // → -1
|
|
68
|
+
*/
|
|
69
|
+
function findDotTerminator(buf) {
|
|
70
|
+
if (!Buffer.isBuffer(buf)) {
|
|
71
|
+
throw new SafeSmtpError("safe-smtp/bad-input",
|
|
72
|
+
"findDotTerminator: input must be a Buffer");
|
|
73
|
+
}
|
|
74
|
+
for (var i = 0; i <= buf.length - 5; i += 1) { // allow:raw-byte-literal — 5-byte CRLF.CRLF terminator length
|
|
75
|
+
if (buf[i] === 0x0d && buf[i + 1] === 0x0a &&
|
|
76
|
+
buf[i + 2] === 0x2e &&
|
|
77
|
+
buf[i + 3] === 0x0d && buf[i + 4] === 0x0a) {
|
|
78
|
+
return i;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return -1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @primitive b.safeSmtp.dotUnstuff
|
|
86
|
+
* @signature b.safeSmtp.dotUnstuff(buf)
|
|
87
|
+
* @since 0.9.46
|
|
88
|
+
* @status stable
|
|
89
|
+
* @related b.safeSmtp.findDotTerminator
|
|
90
|
+
*
|
|
91
|
+
* Reverse RFC 5321 §4.5.2 dot-stuffing on a DATA-body buffer. SMTP
|
|
92
|
+
* senders that need to transmit a body line beginning with `.` MUST
|
|
93
|
+
* prepend an extra `.` (so the line on the wire begins with `..`);
|
|
94
|
+
* the receiver strips the leading `.` from any body line that
|
|
95
|
+
* begins with one before storing the message. Returns a fresh
|
|
96
|
+
* Buffer with the dots reversed; the input is never mutated. Result
|
|
97
|
+
* length is always `<= input length`.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* var wire = Buffer.from("hello\r\n..secret\r\nworld\r\n");
|
|
101
|
+
* b.safeSmtp.dotUnstuff(wire).toString("utf8");
|
|
102
|
+
* // → "hello\r\n.secret\r\nworld\r\n"
|
|
103
|
+
*/
|
|
104
|
+
function dotUnstuff(buf) {
|
|
105
|
+
if (!Buffer.isBuffer(buf)) {
|
|
106
|
+
throw new SafeSmtpError("safe-smtp/bad-input",
|
|
107
|
+
"dotUnstuff: input must be a Buffer");
|
|
108
|
+
}
|
|
109
|
+
var out = Buffer.alloc(buf.length);
|
|
110
|
+
var oi = 0;
|
|
111
|
+
for (var i = 0; i < buf.length; i += 1) {
|
|
112
|
+
out[oi++] = buf[i];
|
|
113
|
+
// After \r\n, if the next byte is `.` followed by another non-CR
|
|
114
|
+
// byte (i.e., not the terminator itself), strip the stuffing dot.
|
|
115
|
+
if (i >= 1 && buf[i - 1] === 0x0d && buf[i] === 0x0a &&
|
|
116
|
+
i + 1 < buf.length && buf[i + 1] === 0x2e &&
|
|
117
|
+
i + 2 < buf.length && buf[i + 2] !== 0x0d) {
|
|
118
|
+
i += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out.subarray(0, oi);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
findDotTerminator: findDotTerminator,
|
|
126
|
+
dotUnstuff: dotUnstuff,
|
|
127
|
+
SafeSmtpError: SafeSmtpError,
|
|
128
|
+
};
|
package/lib/storage.js
CHANGED
|
@@ -41,6 +41,8 @@ var C = require("./constants");
|
|
|
41
41
|
var { generateBytes, encryptPacked, decryptPacked } = require("./crypto");
|
|
42
42
|
var objectStore = require("./object-store");
|
|
43
43
|
var lazyRequire = require("./lazy-require");
|
|
44
|
+
var numericBounds = require("./numeric-bounds");
|
|
45
|
+
var canonicalJson = require("./canonical-json");
|
|
44
46
|
var { StorageError } = require("./framework-error");
|
|
45
47
|
|
|
46
48
|
var vault = lazyRequire(function () { return require("./vault"); });
|
|
@@ -827,6 +829,420 @@ function _requireInit() {
|
|
|
827
829
|
if (!initialized) throw _err("NOT_INITIALIZED", "storage.init() must be called before any file operation", true);
|
|
828
830
|
}
|
|
829
831
|
|
|
832
|
+
// ---- chunk-scratch -------------------------------------------------
|
|
833
|
+
//
|
|
834
|
+
// Resumable-chunked-upload primitive. Operators handling large file
|
|
835
|
+
// uploads (multipart form / tus / S3-multipart-style flow) need to
|
|
836
|
+
// persist incoming chunks during the upload window, then assemble
|
|
837
|
+
// them into a final file when the upload completes. Without a
|
|
838
|
+
// framework primitive every consumer ended up reinventing the
|
|
839
|
+
// per-assembly directory layout + atomic finalize + GC of partial
|
|
840
|
+
// assemblies that never completed.
|
|
841
|
+
//
|
|
842
|
+
// chunkScratch owns:
|
|
843
|
+
// - per-assembly directory layout (sealed in the operator's
|
|
844
|
+
// storage backend just like saveFile)
|
|
845
|
+
// - chunk persistence + retrieval with the framework envelope
|
|
846
|
+
// - assembly metadata tracking createdAt/totalChunks/chunkHashes
|
|
847
|
+
// - atomic concat into the final file (no consumer ever sees a
|
|
848
|
+
// half-assembled file)
|
|
849
|
+
// - GC of stale partial assemblies (operator opts in via gc())
|
|
850
|
+
//
|
|
851
|
+
// Backend is the same `b.storage` backend the operator already
|
|
852
|
+
// configured — chunkScratch routes through it. No new backend
|
|
853
|
+
// concept. The chunk keys are namespaced under
|
|
854
|
+
// `<rootKeyPrefix>/<assemblyId>/<chunkIndex>` so the operator can
|
|
855
|
+
// see them via the backend's existing list/inspect surface.
|
|
856
|
+
//
|
|
857
|
+
// assemblyId is operator-supplied (typically a UUID tied to the
|
|
858
|
+
// upload session). Shape is validated to refuse path-traversal,
|
|
859
|
+
// slash/backslash, NUL/C0/DEL, oversize. The chunkScratch primitive
|
|
860
|
+
// is identity-agnostic — it doesn't know which user owns which
|
|
861
|
+
// assembly; that gate is the operator's surrounding handler.
|
|
862
|
+
|
|
863
|
+
var ASSEMBLY_ID_MAX_LEN = 128;
|
|
864
|
+
var CHUNK_INDEX_MAX = 100000; // allow:raw-byte-literal — chunk-index cap (not bytes, not seconds)
|
|
865
|
+
var CHUNK_BYTES_DEFAULT = C.BYTES.mib(16);
|
|
866
|
+
var STALE_DEFAULT_MS = C.TIME.hours(24);
|
|
867
|
+
|
|
868
|
+
function _stripTrailingSlashes(s) {
|
|
869
|
+
// Linear-time alternative to `.replace(/\/+$/, "")` — CodeQL flags the
|
|
870
|
+
// regex form as polynomial-ReDoS-vulnerable on inputs with many
|
|
871
|
+
// trailing slashes (theoretical here since rootKeyPrefix is operator-
|
|
872
|
+
// supplied at create-time, not request-bound, but using the explicit
|
|
873
|
+
// loop avoids the regex-engine backtracking surface entirely).
|
|
874
|
+
var end = s.length;
|
|
875
|
+
while (end > 0 && s.charCodeAt(end - 1) === 0x2F /* / */) end -= 1;
|
|
876
|
+
return end === s.length ? s : s.slice(0, end);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function _validateAssemblyId(id) {
|
|
880
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
881
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch: assemblyId must be a non-empty string", true);
|
|
882
|
+
}
|
|
883
|
+
if (id.length > ASSEMBLY_ID_MAX_LEN) {
|
|
884
|
+
throw _err("INVALID_ARGUMENT",
|
|
885
|
+
"chunkScratch: assemblyId exceeds " + ASSEMBLY_ID_MAX_LEN + "-char cap", true);
|
|
886
|
+
}
|
|
887
|
+
for (var i = 0; i < id.length; i += 1) {
|
|
888
|
+
var c = id.charCodeAt(i);
|
|
889
|
+
// Refuse: C0 (0x00-0x1F), DEL (0x7F), slash, backslash, dot-prefix
|
|
890
|
+
if (c < 0x20 || c === 0x2F || c === 0x5C || c === 0x7F) {
|
|
891
|
+
throw _err("INVALID_ARGUMENT",
|
|
892
|
+
"chunkScratch: assemblyId carries forbidden character at byte " + i, true);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// Refuse path-traversal shapes — operator-supplied ID should be a
|
|
896
|
+
// UUID-shape or opaque session token, not a path.
|
|
897
|
+
if (id.indexOf("..") !== -1 || id.charAt(0) === ".") {
|
|
898
|
+
throw _err("INVALID_ARGUMENT",
|
|
899
|
+
"chunkScratch: assemblyId carries path-traversal shape", true);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function _validateChunkIndex(idx) {
|
|
904
|
+
if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0) {
|
|
905
|
+
throw _err("INVALID_ARGUMENT",
|
|
906
|
+
"chunkScratch: chunkIndex must be a non-negative integer", true);
|
|
907
|
+
}
|
|
908
|
+
if (idx >= CHUNK_INDEX_MAX) {
|
|
909
|
+
throw _err("INVALID_ARGUMENT",
|
|
910
|
+
"chunkScratch: chunkIndex exceeds cap " + CHUNK_INDEX_MAX, true);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* @primitive b.storage.chunkScratch
|
|
916
|
+
* @signature b.storage.chunkScratch(opts?)
|
|
917
|
+
* @since 0.9.44
|
|
918
|
+
* @status stable
|
|
919
|
+
* @related b.storage.saveFile, b.storage.getFileBuffer
|
|
920
|
+
*
|
|
921
|
+
* Resumable-chunked-upload primitive. Persists incoming upload chunks
|
|
922
|
+
* during the upload window + atomically assembles them into the
|
|
923
|
+
* final file on completion. Owns per-assembly directory layout,
|
|
924
|
+
* envelope-encrypted chunk persistence, atomic finalize, and GC of
|
|
925
|
+
* partial assemblies.
|
|
926
|
+
*
|
|
927
|
+
* Composes existing primitives: each chunk routes through
|
|
928
|
+
* `b.storage.saveFile` (same XChaCha20-Poly1305 envelope as the
|
|
929
|
+
* non-chunked surface), assembly reads through `getFileBuffer`,
|
|
930
|
+
* deletion through `deleteFile`. No new crypto.
|
|
931
|
+
*
|
|
932
|
+
* Prior art / wire-protocol references:
|
|
933
|
+
* - tus.io v1.0.0 protocol (Termination + Creation + Concatenation
|
|
934
|
+
* extensions) — operator-facing HTTP shape that ships chunks
|
|
935
|
+
* against a server-side assembly. This primitive is the
|
|
936
|
+
* server-side persistence the tus protocol's upload handler
|
|
937
|
+
* consumes.
|
|
938
|
+
* - RFC 9110 §14.4 Content-Range — the wire-protocol header that
|
|
939
|
+
* PUT/PATCH-based resumable uploads use to declare each chunk's
|
|
940
|
+
* byte-range within the assembly.
|
|
941
|
+
* - draft-ietf-httpbis-resumable-upload-08 — IETF working-draft
|
|
942
|
+
* resumable-upload protocol; this primitive's surface mirrors
|
|
943
|
+
* its server-side state requirements.
|
|
944
|
+
* - AWS S3 Multipart Upload — the cloud-vendor analogue;
|
|
945
|
+
* `saveChunk` / `assemble` are the framework's local equivalents
|
|
946
|
+
* of UploadPart / CompleteMultipartUpload.
|
|
947
|
+
*
|
|
948
|
+
* Threat-model coverage:
|
|
949
|
+
* - Path-traversal in upload paths (CVE-2018-1000656 class) —
|
|
950
|
+
* `assemblyId` is validated to refuse `..`, `/`, `\`, NUL / C0
|
|
951
|
+
* controls, DEL, dot-prefix, and oversize. A hostile client
|
|
952
|
+
* can't escape the rootKeyPrefix namespace.
|
|
953
|
+
* - Chunk-out-of-order replay / TOCTOU between saveChunk and
|
|
954
|
+
* assemble — `assemble` verifies monotonic 0..N-1 indices and
|
|
955
|
+
* refuses on gaps; a chunk inserted out-of-order can't be
|
|
956
|
+
* surfaced as a valid assembly.
|
|
957
|
+
* - Storage exhaustion from abandoned uploads — `gc({ olderThanMs })`
|
|
958
|
+
* prunes stale assemblies; operator wires it on a schedule.
|
|
959
|
+
* - AEAD context-binding — each chunk's encryption envelope is
|
|
960
|
+
* keyed independently; an attacker who guesses one chunk's key
|
|
961
|
+
* can't decrypt other chunks in the same assembly (the
|
|
962
|
+
* XChaCha20-Poly1305 keys are framework-vault-derived per-call).
|
|
963
|
+
*
|
|
964
|
+
* assemblyId shape is validated to refuse path-traversal, control
|
|
965
|
+
* chars, and oversize at every entry point.
|
|
966
|
+
*
|
|
967
|
+
* @opts
|
|
968
|
+
* rootKeyPrefix: string, // default "chunk-scratch" — namespace under the backend
|
|
969
|
+
* backend: string, // explicit backend by name (default: framework default)
|
|
970
|
+
* maxChunkBytes: number, // default 16 MiB — per-chunk cap
|
|
971
|
+
* staleAfterMs: number, // default 24h — assemblies idle longer get GC'd
|
|
972
|
+
*
|
|
973
|
+
* @example
|
|
974
|
+
* b.storage.init({ backend: "local", uploadDir: "./data/uploads" });
|
|
975
|
+
* var cs = b.storage.chunkScratch({ rootKeyPrefix: "uploads/scratch" });
|
|
976
|
+
*
|
|
977
|
+
* // During upload — each PUT lands one chunk
|
|
978
|
+
* await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 0, data: chunk0 });
|
|
979
|
+
* await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 1, data: chunk1 });
|
|
980
|
+
* await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 2, data: chunk2 });
|
|
981
|
+
*
|
|
982
|
+
* // On completion — atomic assemble + cleanup
|
|
983
|
+
* var assembled = await cs.assemble({ assemblyId: "upload-abc", expectedTotal: 3 });
|
|
984
|
+
* await cs.removeAssembly("upload-abc");
|
|
985
|
+
*
|
|
986
|
+
* // Periodic GC of partial uploads abandoned mid-stream
|
|
987
|
+
* var removed = await cs.gc({ olderThanMs: 86400000 });
|
|
988
|
+
*/
|
|
989
|
+
function chunkScratch(opts) {
|
|
990
|
+
_requireInit();
|
|
991
|
+
opts = opts || {};
|
|
992
|
+
var rootKeyPrefix = typeof opts.rootKeyPrefix === "string" && opts.rootKeyPrefix.length > 0
|
|
993
|
+
? _stripTrailingSlashes(opts.rootKeyPrefix)
|
|
994
|
+
: "chunk-scratch";
|
|
995
|
+
numericBounds.requirePositiveFiniteIntIfPresent(
|
|
996
|
+
opts.maxChunkBytes, "chunkScratch.maxChunkBytes", StorageError, "INVALID_ARGUMENT");
|
|
997
|
+
numericBounds.requirePositiveFiniteIntIfPresent(
|
|
998
|
+
opts.staleAfterMs, "chunkScratch.staleAfterMs", StorageError, "INVALID_ARGUMENT");
|
|
999
|
+
var maxChunkBytes = opts.maxChunkBytes !== undefined ? opts.maxChunkBytes : CHUNK_BYTES_DEFAULT;
|
|
1000
|
+
var staleAfterMs = opts.staleAfterMs !== undefined ? opts.staleAfterMs : STALE_DEFAULT_MS;
|
|
1001
|
+
var backendOverride = opts.backend;
|
|
1002
|
+
|
|
1003
|
+
function _chunkKey(assemblyId, chunkIndex) {
|
|
1004
|
+
return rootKeyPrefix + "/" + assemblyId + "/" + String(chunkIndex).padStart(8, "0") + ".chunk"; // allow:raw-byte-literal — 8-digit zero-pad covers CHUNK_INDEX_MAX
|
|
1005
|
+
}
|
|
1006
|
+
function _pickOpts() {
|
|
1007
|
+
return backendOverride ? { backend: backendOverride } : {};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function saveChunk(args) {
|
|
1011
|
+
if (!args || typeof args !== "object") {
|
|
1012
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch.saveChunk: args must be an object", true);
|
|
1013
|
+
}
|
|
1014
|
+
_validateAssemblyId(args.assemblyId);
|
|
1015
|
+
_validateChunkIndex(args.chunkIndex);
|
|
1016
|
+
if (!Buffer.isBuffer(args.data)) {
|
|
1017
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch.saveChunk: data must be a Buffer", true);
|
|
1018
|
+
}
|
|
1019
|
+
if (args.data.length > maxChunkBytes) {
|
|
1020
|
+
throw _err("INVALID_ARGUMENT",
|
|
1021
|
+
"chunkScratch.saveChunk: chunk exceeds maxChunkBytes (" + args.data.length + " > " + maxChunkBytes + ")", true);
|
|
1022
|
+
}
|
|
1023
|
+
var saved = await saveFile(args.data, _chunkKey(args.assemblyId, args.chunkIndex), _pickOpts());
|
|
1024
|
+
_emit("system.storage.chunk_scratch.chunk_saved", {
|
|
1025
|
+
metadata: {
|
|
1026
|
+
assemblyId: args.assemblyId,
|
|
1027
|
+
chunkIndex: args.chunkIndex,
|
|
1028
|
+
sizeBytes: args.data.length,
|
|
1029
|
+
backend: saved.backend,
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
return { encryptionKey: saved.encryptionKey, sizeBytes: args.data.length };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function getChunk(args) {
|
|
1036
|
+
if (!args || typeof args !== "object") {
|
|
1037
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch.getChunk: args must be an object", true);
|
|
1038
|
+
}
|
|
1039
|
+
_validateAssemblyId(args.assemblyId);
|
|
1040
|
+
_validateChunkIndex(args.chunkIndex);
|
|
1041
|
+
if (typeof args.encryptionKey !== "string" || args.encryptionKey.length === 0) {
|
|
1042
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch.getChunk: encryptionKey required", true);
|
|
1043
|
+
}
|
|
1044
|
+
return getFileBuffer(_chunkKey(args.assemblyId, args.chunkIndex),
|
|
1045
|
+
args.encryptionKey, _pickOpts());
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function chunkExists(args) {
|
|
1049
|
+
_validateAssemblyId(args.assemblyId);
|
|
1050
|
+
_validateChunkIndex(args.chunkIndex);
|
|
1051
|
+
return exists(_chunkKey(args.assemblyId, args.chunkIndex), _pickOpts());
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function listChunks(assemblyId) {
|
|
1055
|
+
_validateAssemblyId(assemblyId);
|
|
1056
|
+
var picked = _pickBackend(_pickOpts());
|
|
1057
|
+
if (typeof picked.backend.list !== "function") {
|
|
1058
|
+
throw _err("UNSUPPORTED",
|
|
1059
|
+
"chunkScratch.listChunks: backend '" + picked.backend.name + "' does not implement list()", true);
|
|
1060
|
+
}
|
|
1061
|
+
var prefix = rootKeyPrefix + "/" + assemblyId + "/";
|
|
1062
|
+
var listRes = await picked.backend.list(prefix);
|
|
1063
|
+
var items = listRes && Array.isArray(listRes.items) ? listRes.items
|
|
1064
|
+
: Array.isArray(listRes) ? listRes : [];
|
|
1065
|
+
var indices = [];
|
|
1066
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
1067
|
+
// Backends return either { key, size, lastModified } objects
|
|
1068
|
+
// (local + S3 + GCS) or bare key strings. Normalize.
|
|
1069
|
+
var item = items[i];
|
|
1070
|
+
var rawKey = typeof item === "string" ? item : item && item.key;
|
|
1071
|
+
if (typeof rawKey !== "string") continue;
|
|
1072
|
+
// The local backend's `list(prefix)` returns keys relative to
|
|
1073
|
+
// the prefix; cloud backends return absolute keys. Normalize
|
|
1074
|
+
// by stripping the prefix when present.
|
|
1075
|
+
var base = rawKey.indexOf(prefix) === 0 ? rawKey.slice(prefix.length) : rawKey;
|
|
1076
|
+
if (base === ".meta" || base.indexOf("/") !== -1) continue;
|
|
1077
|
+
if (!/^[0-9]{1,8}\.chunk$/.test(base)) continue;
|
|
1078
|
+
indices.push(parseInt(base.slice(0, -6), 10));
|
|
1079
|
+
}
|
|
1080
|
+
indices.sort(function (a, b) { return a - b; });
|
|
1081
|
+
return indices;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function countChunks(assemblyId) {
|
|
1085
|
+
var indices = await listChunks(assemblyId);
|
|
1086
|
+
return indices.length;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async function removeChunk(args) {
|
|
1090
|
+
_validateAssemblyId(args.assemblyId);
|
|
1091
|
+
_validateChunkIndex(args.chunkIndex);
|
|
1092
|
+
return deleteFile(_chunkKey(args.assemblyId, args.chunkIndex), _pickOpts());
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function assemble(args) {
|
|
1096
|
+
if (!args || typeof args !== "object") {
|
|
1097
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch.assemble: args must be an object", true);
|
|
1098
|
+
}
|
|
1099
|
+
_validateAssemblyId(args.assemblyId);
|
|
1100
|
+
if (!Array.isArray(args.chunkEncryptionKeys) || args.chunkEncryptionKeys.length === 0) {
|
|
1101
|
+
throw _err("INVALID_ARGUMENT",
|
|
1102
|
+
"chunkScratch.assemble: chunkEncryptionKeys must be a non-empty array (one per chunk in order)", true);
|
|
1103
|
+
}
|
|
1104
|
+
var indices = await listChunks(args.assemblyId);
|
|
1105
|
+
if (typeof args.expectedTotal === "number" && indices.length !== args.expectedTotal) {
|
|
1106
|
+
throw _err("INCOMPLETE_ASSEMBLY",
|
|
1107
|
+
"chunkScratch.assemble: have " + indices.length + " chunks; expected " + args.expectedTotal, true);
|
|
1108
|
+
}
|
|
1109
|
+
if (indices.length !== args.chunkEncryptionKeys.length) {
|
|
1110
|
+
throw _err("INVALID_ARGUMENT",
|
|
1111
|
+
"chunkScratch.assemble: chunkEncryptionKeys.length (" + args.chunkEncryptionKeys.length +
|
|
1112
|
+
") must match chunk count (" + indices.length + ")", true);
|
|
1113
|
+
}
|
|
1114
|
+
// Verify monotonic 0..N-1 indices — no gaps.
|
|
1115
|
+
for (var i = 0; i < indices.length; i += 1) {
|
|
1116
|
+
if (indices[i] !== i) {
|
|
1117
|
+
throw _err("INCOMPLETE_ASSEMBLY",
|
|
1118
|
+
"chunkScratch.assemble: chunk gap at index " + i + " (found " + indices[i] + ")", true);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
// Concatenate in order. Each chunk decrypts via its own envelope
|
|
1122
|
+
// key; the operator persisted the per-chunk key when saveChunk
|
|
1123
|
+
// returned it.
|
|
1124
|
+
var parts = [];
|
|
1125
|
+
var totalBytes = 0;
|
|
1126
|
+
for (var c = 0; c < indices.length; c += 1) {
|
|
1127
|
+
var buf = await getChunk({
|
|
1128
|
+
assemblyId: args.assemblyId,
|
|
1129
|
+
chunkIndex: c,
|
|
1130
|
+
encryptionKey: args.chunkEncryptionKeys[c],
|
|
1131
|
+
});
|
|
1132
|
+
parts.push(buf);
|
|
1133
|
+
totalBytes += buf.length;
|
|
1134
|
+
}
|
|
1135
|
+
_emit("system.storage.chunk_scratch.assembled", {
|
|
1136
|
+
metadata: {
|
|
1137
|
+
assemblyId: args.assemblyId,
|
|
1138
|
+
chunkCount: indices.length,
|
|
1139
|
+
sizeBytes: totalBytes,
|
|
1140
|
+
},
|
|
1141
|
+
});
|
|
1142
|
+
return Buffer.concat(parts, totalBytes);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
async function removeAssembly(assemblyId) {
|
|
1146
|
+
_validateAssemblyId(assemblyId);
|
|
1147
|
+
var indices = await listChunks(assemblyId);
|
|
1148
|
+
var removed = 0;
|
|
1149
|
+
for (var i = 0; i < indices.length; i += 1) {
|
|
1150
|
+
try { await removeChunk({ assemblyId: assemblyId, chunkIndex: indices[i] }); removed += 1; }
|
|
1151
|
+
catch (_e) { /* best-effort */ }
|
|
1152
|
+
}
|
|
1153
|
+
_emit("system.storage.chunk_scratch.removed", {
|
|
1154
|
+
metadata: { assemblyId: assemblyId, chunksRemoved: removed },
|
|
1155
|
+
});
|
|
1156
|
+
return { chunksRemoved: removed };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function listAssemblies() {
|
|
1160
|
+
var picked = _pickBackend(_pickOpts());
|
|
1161
|
+
if (typeof picked.backend.list !== "function") {
|
|
1162
|
+
throw _err("UNSUPPORTED",
|
|
1163
|
+
"chunkScratch.listAssemblies: backend '" + picked.backend.name + "' does not implement list()", true);
|
|
1164
|
+
}
|
|
1165
|
+
var listRes = await picked.backend.list(rootKeyPrefix + "/");
|
|
1166
|
+
var items = listRes && Array.isArray(listRes.items) ? listRes.items
|
|
1167
|
+
: Array.isArray(listRes) ? listRes : [];
|
|
1168
|
+
var ids = {};
|
|
1169
|
+
var prefixWithSlash = rootKeyPrefix + "/";
|
|
1170
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
1171
|
+
var item = items[i];
|
|
1172
|
+
var rawKey = typeof item === "string" ? item : item && item.key;
|
|
1173
|
+
if (typeof rawKey !== "string") continue;
|
|
1174
|
+
var rel = rawKey.indexOf(prefixWithSlash) === 0
|
|
1175
|
+
? rawKey.slice(prefixWithSlash.length) : rawKey;
|
|
1176
|
+
var slash = rel.indexOf("/");
|
|
1177
|
+
if (slash === -1) continue;
|
|
1178
|
+
ids[rel.slice(0, slash)] = true;
|
|
1179
|
+
}
|
|
1180
|
+
return canonicalJson.sortKeys(ids);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async function listStaleAssemblies(args) {
|
|
1184
|
+
args = args || {};
|
|
1185
|
+
var olderThan = (typeof args.olderThanMs === "number" && args.olderThanMs > 0)
|
|
1186
|
+
? args.olderThanMs : staleAfterMs;
|
|
1187
|
+
var cutoff = Date.now() - olderThan;
|
|
1188
|
+
var picked = _pickBackend(_pickOpts());
|
|
1189
|
+
if (typeof picked.backend.list !== "function") {
|
|
1190
|
+
throw _err("UNSUPPORTED",
|
|
1191
|
+
"chunkScratch.listStaleAssemblies: backend does not implement list()", true);
|
|
1192
|
+
}
|
|
1193
|
+
var assemblies = await listAssemblies();
|
|
1194
|
+
var stale = [];
|
|
1195
|
+
for (var i = 0; i < assemblies.length; i += 1) {
|
|
1196
|
+
var assemblyId = assemblies[i];
|
|
1197
|
+
// Use the earliest chunk's mtime as the assembly's createdAt
|
|
1198
|
+
// proxy. Backends that surface mtime via list() inspect items;
|
|
1199
|
+
// others fall through to a stat probe on the first chunk.
|
|
1200
|
+
var indices = await listChunks(assemblyId);
|
|
1201
|
+
if (indices.length === 0) { stale.push(assemblyId); continue; }
|
|
1202
|
+
var firstKey = _chunkKey(assemblyId, indices[0]);
|
|
1203
|
+
var stat = null;
|
|
1204
|
+
if (typeof picked.backend.stat === "function") {
|
|
1205
|
+
try { stat = await picked.backend.stat(firstKey); } catch (_e) { stat = null; }
|
|
1206
|
+
}
|
|
1207
|
+
var mtime = stat && (stat.mtimeMs || (stat.mtime && stat.mtime.getTime && stat.mtime.getTime()));
|
|
1208
|
+
if (typeof mtime === "number" && mtime < cutoff) {
|
|
1209
|
+
stale.push(assemblyId);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return stale;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function gc(args) {
|
|
1216
|
+
args = args || {};
|
|
1217
|
+
var stale = await listStaleAssemblies({ olderThanMs: args.olderThanMs });
|
|
1218
|
+
var removed = [];
|
|
1219
|
+
for (var i = 0; i < stale.length; i += 1) {
|
|
1220
|
+
try {
|
|
1221
|
+
var r = await removeAssembly(stale[i]);
|
|
1222
|
+
removed.push({ assemblyId: stale[i], chunksRemoved: r.chunksRemoved });
|
|
1223
|
+
} catch (_e) { /* best-effort GC */ }
|
|
1224
|
+
}
|
|
1225
|
+
_emit("system.storage.chunk_scratch.gc", {
|
|
1226
|
+
metadata: { staleCount: stale.length, removedCount: removed.length },
|
|
1227
|
+
});
|
|
1228
|
+
return { removed: removed };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
return {
|
|
1232
|
+
saveChunk: saveChunk,
|
|
1233
|
+
getChunk: getChunk,
|
|
1234
|
+
chunkExists: chunkExists,
|
|
1235
|
+
listChunks: listChunks,
|
|
1236
|
+
countChunks: countChunks,
|
|
1237
|
+
removeChunk: removeChunk,
|
|
1238
|
+
assemble: assemble,
|
|
1239
|
+
removeAssembly: removeAssembly,
|
|
1240
|
+
listAssemblies: listAssemblies,
|
|
1241
|
+
listStaleAssemblies: listStaleAssemblies,
|
|
1242
|
+
gc: gc,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
830
1246
|
function _resetForTest() {
|
|
831
1247
|
initialized = false;
|
|
832
1248
|
backends = {};
|
|
@@ -851,5 +1267,6 @@ module.exports = {
|
|
|
851
1267
|
presignedUploadPolicy: presignedUploadPolicy,
|
|
852
1268
|
listBackends: listBackends,
|
|
853
1269
|
getBackend: getBackend,
|
|
1270
|
+
chunkScratch: chunkScratch,
|
|
854
1271
|
_resetForTest: _resetForTest,
|
|
855
1272
|
};
|