@blamejs/core 0.7.87 → 0.7.89
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 +4 -0
- package/lib/auth/passkey.js +94 -4
- package/lib/middleware/assetlinks.js +97 -0
- package/lib/middleware/dpop.js +114 -5
- package/lib/middleware/index.js +11 -0
- package/lib/middleware/tus-upload.js +654 -0
- package/lib/middleware/web-app-manifest.js +111 -0
- package/lib/parsers/safe-ini.js +2 -1
- package/lib/safe-buffer.js +7 -0
- package/lib/webhook.js +1 -1
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.89** (2026-05-06) — Three additive primitives bundled: TUS resumable uploads, WebAuthn Signal API, DPoP server-issued nonce challenge. **`b.middleware.tusUpload({ mountPath, store, ... })`** implements the [tus.io](https://tus.io) v1.0.0 resumable-upload protocol — POST creates uploads, HEAD reports offsets, PATCH appends chunks, DELETE terminates. Supported extensions: `creation`, `creation-with-upload`, `expiration`, `checksum`, `termination`. The `checksum` extension defaults to PQC-first algorithms (`sha3-512`, `shake256`) — operators add classical algorithms explicitly via `checksumAlgorithms`. A built-in `b.middleware.tusUpload.memoryStore({ maxSize })` ships for development; production operators implement the `{ create, head, append, setLength, terminate, purgeExpired, getBuffer }` shape against their object-store backend. Bounded chunk collection routes through `safeBuffer.boundedChunkCollector` (cap-enforced at push time, no 10-GiB pre-collect). Concatenation extension (parallel-chunk assembly) deferred — operators that need it compose against their store layer; re-open if a store-layer-only solution proves insufficient. **`b.auth.passkey.signalUnknownCredential` / `signalAllAcceptedCredentials` / `signalCurrentUserDetails`** add the W3C WebAuthn Signal API descriptor builders — when the browser implements `PublicKeyCredential.signal*`, operators emit the matching JSON descriptor to clean up stale passkeys, refresh user details, and surface revocations without forcing a re-registration. All three validate `rpId` / `userId` / `credentialId` shape (base64url) and refuse `name`/`displayName` longer than 256 chars. **`b.middleware.dpop({ requireNonce: true, nonceRotateSec? })`** implements RFC 9449 §8 server-issued DPoP-Nonce challenge — the middleware emits `DPoP-Nonce: <fresh>` on every 401 response, refuses proofs whose `nonce` claim isn't in the rolling current+previous pair, and refreshes the nonce on every successful response. The rolling-pair manager rotates without timers (lazy maybe-rotate on access); no operator nonce store needed. The `getNonce` callback path stays intact for operator-managed nonce flows.
|
|
12
|
+
|
|
13
|
+
- **0.7.88** (2026-05-06) — `b.middleware.webAppManifest` + `b.middleware.assetlinks` — two static-content middlewares for PWA + Trusted Web Activity support. **`b.middleware.webAppManifest({ name, start_url, icons, ... })`** serves the W3C Web App Manifest at `/manifest.webmanifest` (and `/manifest.json` when `alsoAtJsonPath: true`). The framework JSON-serializes once at create() and serves with `Content-Type: application/manifest+json` per the W3C spec + `Cache-Control: public, max-age=86400` + `X-Content-Type-Options: nosniff`. The W3C-spec attribute set is allowlisted (name / short_name / description / start_url / scope / display / display_override / orientation / theme_color / background_color / icons / screenshots / shortcuts / categories / lang / dir / id / prefer_related_applications / related_applications) — typos throw at create. `name`, `start_url`, and at least one icon are required (W3C — installability minimum). HEAD + GET only. **`b.middleware.assetlinks({ statements })`** serves Digital Asset Links at `/.well-known/assetlinks.json` per Google's spec — used by Trusted Web Activity, Android App Links, Smart Lock for Passwords, WebAuthn for Android. Validates each statement carries `relation` (non-empty array) and `target` (object). Same Content-Type / Cache-Control / X-Content-Type-Options posture as the security.txt + manifest emitters.
|
|
14
|
+
|
|
11
15
|
- **0.7.87** (2026-05-06) — Two route-guard middlewares for API hardening: `b.middleware.requireMethods` + `b.middleware.requireContentType`. **`b.middleware.requireMethods(["GET", "POST"])`** refuses any HTTP method outside the allowlist with `405 Method Not Allowed` + `Allow:` header listing the allowed methods (per RFC 9110 §15.5.6). Defends against unexpected verb routing — many CVE-class bugs trace to a route handler wired for GET that accidentally accepts arbitrary verbs (PROPFIND, OPTIONS, custom). **`b.middleware.requireContentType(["application/json"])`** refuses requests with a body (POST/PUT/PATCH by default) whose `Content-Type` isn't in the allowlist with `415 Unsupported Media Type` + `Accept:` header listing the allowed types (RFC 9110 §15.5.16). Defends against MIME-type confusion — a route that processes JSON shouldn't accept `application/x-www-form-urlencoded` even if the body parses. Both middlewares emit observability events (`middleware.requireMethods.denied` / `middleware.requireContentType.denied`) on every refusal for triage. Operators wanting to enforce content-type on idempotent verbs that DO carry bodies (rare DELETE-with-body shapes) override the default body-method list via `requireContentType(types, { methods })`.
|
|
12
16
|
|
|
13
17
|
- **0.7.86** (2026-05-06) — `b.middleware.csrfProtect({ requireJsonContentType: true })` — strict-fetch mode for JSON-only API surfaces. State-changing requests (POST/PUT/PATCH/DELETE) without `Content-Type: application/json` are refused before the token check with `CSRF: state-changing requests require Content-Type: application/json.` and audit emission `csrf.denied` with `reason: "non-JSON content-type: ..."`. The browser's form-encoded POST shape is the canonical CSRF vector — a malicious page can `<form action="/transfer" method=POST>` a victim into a state-changing request without a preflight. An `application/json` body forces a CORS preflight (the browser refuses to skip it for non-simple Content-Type values), so an attacker without an operator-allowlisted CORS origin can't reach the route at all. Default `false` — operators with HTML form submissions on the same routes (mixed SPA + classic form pages) keep current behavior; pure-fetch API operators opt in.
|
package/lib/auth/passkey.js
CHANGED
|
@@ -57,9 +57,15 @@
|
|
|
57
57
|
* sessions, audit, or DB. Routes integrate that themselves; the
|
|
58
58
|
* primitive stays the smallest correct surface.
|
|
59
59
|
*/
|
|
60
|
+
var safeBuffer = require("../safe-buffer");
|
|
60
61
|
var _wa = require("../vendor/simplewebauthn-server.cjs");
|
|
61
62
|
var { AuthError } = require("../framework-error");
|
|
62
63
|
|
|
64
|
+
// W3C WebAuthn name field cap — same as the rpName/userName ceiling in
|
|
65
|
+
// the spec's CredentialUserEntity / PublicKeyCredentialEntity dictionaries
|
|
66
|
+
// (no normative limit but RPs broadly cap at 256 to defeat DOM cost).
|
|
67
|
+
var MAX_NAME_LEN = 256; // allow:raw-byte-literal — UTF-16 codepoint count, not bytes
|
|
68
|
+
|
|
63
69
|
function _vendor() {
|
|
64
70
|
return _wa;
|
|
65
71
|
}
|
|
@@ -173,9 +179,93 @@ async function verifyAuthentication(opts) {
|
|
|
173
179
|
});
|
|
174
180
|
}
|
|
175
181
|
|
|
182
|
+
// ---- WebAuthn Signal API (W3C draft, 2024) ----
|
|
183
|
+
//
|
|
184
|
+
// The signal* methods build the JSON descriptor that the operator
|
|
185
|
+
// returns to the client; the browser then calls the matching
|
|
186
|
+
// `PublicKeyCredential.signal*` method to clean up stale passkeys
|
|
187
|
+
// and refresh user details. These are pure builders — no I/O — so
|
|
188
|
+
// validation throws at the boundary and the descriptor shape is the
|
|
189
|
+
// W3C draft schema verbatim.
|
|
190
|
+
|
|
191
|
+
function _b64urlValid(s) {
|
|
192
|
+
return typeof s === "string" && s.length > 0 && safeBuffer.BASE64URL_RE.test(s);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function signalUnknownCredential(opts) {
|
|
196
|
+
if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
|
|
197
|
+
_requireString(opts.rpId, "rpId");
|
|
198
|
+
_requireString(opts.credentialId, "credentialId");
|
|
199
|
+
if (!_b64urlValid(opts.credentialId)) {
|
|
200
|
+
throw new AuthError("auth-passkey/bad-credential-id",
|
|
201
|
+
"credentialId must be base64url (no padding)");
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
rpId: opts.rpId,
|
|
205
|
+
credentialId: opts.credentialId,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function signalAllAcceptedCredentials(opts) {
|
|
210
|
+
if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
|
|
211
|
+
_requireString(opts.rpId, "rpId");
|
|
212
|
+
_requireString(opts.userId, "userId");
|
|
213
|
+
if (!_b64urlValid(opts.userId)) {
|
|
214
|
+
throw new AuthError("auth-passkey/bad-user-id",
|
|
215
|
+
"userId must be base64url (no padding)");
|
|
216
|
+
}
|
|
217
|
+
if (!Array.isArray(opts.allAcceptedCredentialIds)) {
|
|
218
|
+
throw new AuthError("auth-passkey/bad-accepted-list",
|
|
219
|
+
"allAcceptedCredentialIds must be an array");
|
|
220
|
+
}
|
|
221
|
+
for (var i = 0; i < opts.allAcceptedCredentialIds.length; i++) {
|
|
222
|
+
if (!_b64urlValid(opts.allAcceptedCredentialIds[i])) {
|
|
223
|
+
throw new AuthError("auth-passkey/bad-accepted-list",
|
|
224
|
+
"allAcceptedCredentialIds[" + i + "] must be base64url");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
rpId: opts.rpId,
|
|
229
|
+
userId: opts.userId,
|
|
230
|
+
allAcceptedCredentialIds: opts.allAcceptedCredentialIds.slice(),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function signalCurrentUserDetails(opts) {
|
|
235
|
+
if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
|
|
236
|
+
_requireString(opts.rpId, "rpId");
|
|
237
|
+
_requireString(opts.userId, "userId");
|
|
238
|
+
if (!_b64urlValid(opts.userId)) {
|
|
239
|
+
throw new AuthError("auth-passkey/bad-user-id",
|
|
240
|
+
"userId must be base64url (no padding)");
|
|
241
|
+
}
|
|
242
|
+
_requireString(opts.name, "name");
|
|
243
|
+
_requireString(opts.displayName, "displayName");
|
|
244
|
+
// RP-relevant length cap — the descriptor is a hint to the browser,
|
|
245
|
+
// not a stored value, but absurdly long names indicate a misuse and
|
|
246
|
+
// we refuse rather than truncate silently.
|
|
247
|
+
if (opts.name.length > MAX_NAME_LEN) {
|
|
248
|
+
throw new AuthError("auth-passkey/name-too-long",
|
|
249
|
+
"name must be <= " + MAX_NAME_LEN + " characters");
|
|
250
|
+
}
|
|
251
|
+
if (opts.displayName.length > MAX_NAME_LEN) {
|
|
252
|
+
throw new AuthError("auth-passkey/displayname-too-long",
|
|
253
|
+
"displayName must be <= " + MAX_NAME_LEN + " characters");
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
rpId: opts.rpId,
|
|
257
|
+
userId: opts.userId,
|
|
258
|
+
name: opts.name,
|
|
259
|
+
displayName: opts.displayName,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
176
263
|
module.exports = {
|
|
177
|
-
startRegistration:
|
|
178
|
-
verifyRegistration:
|
|
179
|
-
startAuthentication:
|
|
180
|
-
verifyAuthentication:
|
|
264
|
+
startRegistration: startRegistration,
|
|
265
|
+
verifyRegistration: verifyRegistration,
|
|
266
|
+
startAuthentication: startAuthentication,
|
|
267
|
+
verifyAuthentication: verifyAuthentication,
|
|
268
|
+
signalUnknownCredential: signalUnknownCredential,
|
|
269
|
+
signalAllAcceptedCredentials: signalAllAcceptedCredentials,
|
|
270
|
+
signalCurrentUserDetails: signalCurrentUserDetails,
|
|
181
271
|
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* assetlinks middleware — emits Digital Asset Links at
|
|
4
|
+
* `/.well-known/assetlinks.json` per Google's Digital Asset Links
|
|
5
|
+
* spec (used by Trusted Web Activity / Android App Links / Smart
|
|
6
|
+
* Lock for Passwords / Web Authentication for Android, etc.).
|
|
7
|
+
*
|
|
8
|
+
* var al = b.middleware.assetlinks({
|
|
9
|
+
* statements: [
|
|
10
|
+
* {
|
|
11
|
+
* relation: ["delegate_permission/common.handle_all_urls"],
|
|
12
|
+
* target: {
|
|
13
|
+
* namespace: "android_app",
|
|
14
|
+
* package_name: "com.example.app",
|
|
15
|
+
* sha256_cert_fingerprints: ["AB:CD:..."],
|
|
16
|
+
* },
|
|
17
|
+
* },
|
|
18
|
+
* ],
|
|
19
|
+
* });
|
|
20
|
+
* router.use(al);
|
|
21
|
+
*
|
|
22
|
+
* The framework JSON-serializes the statements array once at
|
|
23
|
+
* create() and serves with `Content-Type: application/json` per
|
|
24
|
+
* Google's spec. Operators with multiple linked apps include
|
|
25
|
+
* multiple statement entries.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
var lazyRequire = require("../lazy-require");
|
|
29
|
+
var safeJson = require("../safe-json");
|
|
30
|
+
var validateOpts = require("../validate-opts");
|
|
31
|
+
var { defineClass } = require("../framework-error");
|
|
32
|
+
|
|
33
|
+
var AssetlinksError = defineClass("AssetlinksError", { alwaysPermanent: true });
|
|
34
|
+
|
|
35
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
36
|
+
|
|
37
|
+
function create(opts) {
|
|
38
|
+
validateOpts.requireObject(opts, "middleware.assetlinks", AssetlinksError);
|
|
39
|
+
validateOpts(opts, ["statements", "audit"], "middleware.assetlinks");
|
|
40
|
+
|
|
41
|
+
if (!Array.isArray(opts.statements) || opts.statements.length === 0) {
|
|
42
|
+
throw new AssetlinksError("assetlinks/no-statements",
|
|
43
|
+
"middleware.assetlinks: opts.statements must be a non-empty array of statement objects");
|
|
44
|
+
}
|
|
45
|
+
for (var i = 0; i < opts.statements.length; i += 1) {
|
|
46
|
+
var stmt = opts.statements[i];
|
|
47
|
+
if (!stmt || typeof stmt !== "object" || Array.isArray(stmt)) {
|
|
48
|
+
throw new AssetlinksError("assetlinks/bad-statement",
|
|
49
|
+
"middleware.assetlinks: statements[" + i + "] must be a plain object");
|
|
50
|
+
}
|
|
51
|
+
if (!Array.isArray(stmt.relation) || stmt.relation.length === 0) {
|
|
52
|
+
throw new AssetlinksError("assetlinks/bad-statement",
|
|
53
|
+
"middleware.assetlinks: statements[" + i + "].relation must be a non-empty array");
|
|
54
|
+
}
|
|
55
|
+
if (!stmt.target || typeof stmt.target !== "object") {
|
|
56
|
+
throw new AssetlinksError("assetlinks/bad-statement",
|
|
57
|
+
"middleware.assetlinks: statements[" + i + "].target must be an object");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
var body = safeJson.stringify(opts.statements, { space: 2 });
|
|
62
|
+
var bodyBuf = Buffer.from(body, "utf8");
|
|
63
|
+
var auditOn = opts.audit !== false;
|
|
64
|
+
|
|
65
|
+
return function assetlinksMiddleware(req, res, next) {
|
|
66
|
+
var url = req.url || "";
|
|
67
|
+
var qIdx = url.indexOf("?");
|
|
68
|
+
var path = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
69
|
+
if (path !== "/.well-known/assetlinks.json") return next();
|
|
70
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
71
|
+
var bodyMsg = "Method Not Allowed";
|
|
72
|
+
res.writeHead(405, { // allow:raw-byte-literal — HTTP 405 status
|
|
73
|
+
"Allow": "GET, HEAD",
|
|
74
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
75
|
+
"Content-Length": Buffer.byteLength(bodyMsg),
|
|
76
|
+
});
|
|
77
|
+
res.end(bodyMsg);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
res.writeHead(200, { // allow:raw-byte-literal — HTTP 200 status
|
|
81
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
82
|
+
"Content-Length": bodyBuf.length,
|
|
83
|
+
"Cache-Control": "public, max-age=86400",
|
|
84
|
+
"X-Content-Type-Options": "nosniff",
|
|
85
|
+
});
|
|
86
|
+
if (req.method === "HEAD") { res.end(); return; }
|
|
87
|
+
res.end(bodyBuf);
|
|
88
|
+
if (auditOn) {
|
|
89
|
+
try { observability().safeEvent("middleware.assetlinks.served", 1, {}); }
|
|
90
|
+
catch (_e) { /* obs best-effort */ }
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
create: create,
|
|
97
|
+
};
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
* - audit.bearer.failure event when audit: true (default)
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
var C = require("../constants");
|
|
39
|
+
var bCrypto = require("../crypto");
|
|
38
40
|
var lazyRequire = require("../lazy-require");
|
|
39
41
|
var requestHelpers = require("../request-helpers");
|
|
40
42
|
var validateOpts = require("../validate-opts");
|
|
@@ -43,20 +45,63 @@ var { AuthError } = require("../framework-error");
|
|
|
43
45
|
var dpop = lazyRequire(function () { return require("../auth/dpop"); });
|
|
44
46
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
// RFC 9449 §8 — server-issued nonce length (24 random bytes ≈ 192 bits
|
|
49
|
+
// of entropy after base64url, far above the spec's "unpredictable" bar).
|
|
50
|
+
var DPOP_NONCE_BYTES = C.BYTES.bytes(24);
|
|
51
|
+
|
|
52
|
+
function _writeUnauthorized(res, errorCode, description, freshNonce) {
|
|
47
53
|
if (res.headersSent) return;
|
|
48
54
|
var body = JSON.stringify({ error: errorCode, error_description: description });
|
|
49
55
|
// RFC 9449 §7 — error code is invalid_dpop_proof OR use_dpop_nonce.
|
|
50
56
|
var challenge = 'DPoP error="' + errorCode + '", error_description="' +
|
|
51
57
|
description.replace(/"/g, "'") + '"';
|
|
52
|
-
|
|
58
|
+
var headers = { // allow:raw-byte-literal — HTTP 401 status
|
|
53
59
|
"Content-Type": "application/json; charset=utf-8",
|
|
54
60
|
"Content-Length": Buffer.byteLength(body),
|
|
55
61
|
"WWW-Authenticate": challenge,
|
|
56
|
-
}
|
|
62
|
+
};
|
|
63
|
+
if (freshNonce) headers["DPoP-Nonce"] = freshNonce;
|
|
64
|
+
res.writeHead(401, headers);
|
|
57
65
|
res.end(body);
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
// RFC 9449 §8 — server-issued DPoP-Nonce challenge. The framework
|
|
69
|
+
// holds a rolling pair (current, previous) and rotates after
|
|
70
|
+
// rotateSec elapses. Both the current and previous values are
|
|
71
|
+
// accepted from clients; previous is needed to cover the brief
|
|
72
|
+
// race window after rotation when in-flight requests still carry
|
|
73
|
+
// the prior nonce. Rotation happens lazily on access; no timer.
|
|
74
|
+
function _nonceManager(rotateSec) {
|
|
75
|
+
var rotateMs = C.TIME.seconds(rotateSec);
|
|
76
|
+
var current = null;
|
|
77
|
+
var previous = null;
|
|
78
|
+
function _fresh() {
|
|
79
|
+
return {
|
|
80
|
+
nonce: bCrypto.generateBytes(DPOP_NONCE_BYTES).toString("base64url"),
|
|
81
|
+
issuedAt: Date.now(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function _maybeRotate() {
|
|
85
|
+
var now = Date.now();
|
|
86
|
+
if (current === null) {
|
|
87
|
+
current = _fresh();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (now - current.issuedAt >= rotateMs) {
|
|
91
|
+
previous = current;
|
|
92
|
+
current = _fresh();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
issue: function () { _maybeRotate(); return current.nonce; },
|
|
97
|
+
accepts: function (n) {
|
|
98
|
+
_maybeRotate();
|
|
99
|
+
if (typeof n !== "string" || n.length === 0) return false;
|
|
100
|
+
return (current && n === current.nonce) || (previous && n === previous.nonce);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
60
105
|
function _reconstructHtu(req) {
|
|
61
106
|
// The proof's htu is the request URI WITHOUT query/fragment. Behind
|
|
62
107
|
// a reverse proxy the operator may need to override via opts.htu /
|
|
@@ -77,12 +122,38 @@ function create(opts) {
|
|
|
77
122
|
validateOpts(opts, [
|
|
78
123
|
"replayStore", "algorithms", "iatWindowSec",
|
|
79
124
|
"getAccessToken", "getNonce", "getHtu", "audit",
|
|
125
|
+
"nonceStore", "nonceWindowSec", "nonceRotateSec", "requireNonce",
|
|
80
126
|
], "middleware.dpop");
|
|
81
127
|
|
|
82
128
|
var auditOn = opts.audit !== false;
|
|
83
129
|
var algorithms = opts.algorithms;
|
|
84
130
|
var iatWindowSec = opts.iatWindowSec;
|
|
85
131
|
var replayStore = opts.replayStore;
|
|
132
|
+
var requireNonce = opts.requireNonce === true;
|
|
133
|
+
|
|
134
|
+
// Server-issued DPoP-Nonce challenge flow (RFC 9449 §8). When
|
|
135
|
+
// requireNonce is true, the middleware refuses any proof that does
|
|
136
|
+
// not carry a recognised nonce, and emits a fresh DPoP-Nonce
|
|
137
|
+
// header on every 401 + as a refresh on every successful response.
|
|
138
|
+
// The rolling-pair manager rotates without timers; no operator
|
|
139
|
+
// store is needed.
|
|
140
|
+
var nonceMgr = null;
|
|
141
|
+
if (requireNonce) {
|
|
142
|
+
validateOpts.optionalPositiveFinite(opts.nonceRotateSec,
|
|
143
|
+
"middleware.dpop: nonceRotateSec", AuthError, "auth-dpop/bad-opt");
|
|
144
|
+
var rotateSec = opts.nonceRotateSec || (C.TIME.minutes(5) / C.TIME.seconds(1));
|
|
145
|
+
nonceMgr = _nonceManager(rotateSec);
|
|
146
|
+
}
|
|
147
|
+
// Reject the obsolete nonceStore opt with a clear migration message —
|
|
148
|
+
// pre-v0.7.89 docs may surface it; the rolling-pair shape supersedes.
|
|
149
|
+
if (opts.nonceStore !== undefined) {
|
|
150
|
+
throw new AuthError("auth-dpop/bad-opt",
|
|
151
|
+
"middleware.dpop: opts.nonceStore is not supported — use { requireNonce: true, nonceRotateSec? }; the rolling-pair manager is internal");
|
|
152
|
+
}
|
|
153
|
+
if (opts.nonceWindowSec !== undefined) {
|
|
154
|
+
throw new AuthError("auth-dpop/bad-opt",
|
|
155
|
+
"middleware.dpop: opts.nonceWindowSec is not supported — use nonceRotateSec");
|
|
156
|
+
}
|
|
86
157
|
|
|
87
158
|
validateOpts.optionalFunction(opts.getAccessToken,
|
|
88
159
|
"middleware.dpop: getAccessToken", AuthError, "auth-dpop/bad-opt");
|
|
@@ -91,10 +162,14 @@ function create(opts) {
|
|
|
91
162
|
validateOpts.optionalFunction(opts.getHtu,
|
|
92
163
|
"middleware.dpop: getHtu", AuthError, "auth-dpop/bad-opt");
|
|
93
164
|
|
|
165
|
+
function _freshNonce() { return nonceMgr ? nonceMgr.issue() : null; }
|
|
166
|
+
|
|
94
167
|
return async function dpopMiddleware(req, res, next) {
|
|
95
168
|
var proofHeader = req.headers && req.headers.dpop;
|
|
96
169
|
if (typeof proofHeader !== "string" || proofHeader.length === 0) {
|
|
97
|
-
return _writeUnauthorized(res,
|
|
170
|
+
return _writeUnauthorized(res,
|
|
171
|
+
nonceMgr ? "use_dpop_nonce" : "invalid_dpop_proof",
|
|
172
|
+
"DPoP header required", _freshNonce());
|
|
98
173
|
}
|
|
99
174
|
// RFC 9449 §4.1 — only ONE DPoP header value per request.
|
|
100
175
|
if (Array.isArray(proofHeader)) {
|
|
@@ -117,6 +192,12 @@ function create(opts) {
|
|
|
117
192
|
if (typeof opts.getNonce === "function") {
|
|
118
193
|
try { nonce = await opts.getNonce(req); }
|
|
119
194
|
catch (_e) { nonce = null; }
|
|
195
|
+
} else if (nonceMgr) {
|
|
196
|
+
// For server-managed nonces, verify() runs WITHOUT a strict
|
|
197
|
+
// expected-nonce; we then check the payload's nonce against
|
|
198
|
+
// our rolling-pair below. This lets us issue + rotate without
|
|
199
|
+
// requiring a request-by-request operator callback.
|
|
200
|
+
nonce = null;
|
|
120
201
|
}
|
|
121
202
|
|
|
122
203
|
var verifyOpts = { htm: htm, htu: htu };
|
|
@@ -150,7 +231,35 @@ function create(opts) {
|
|
|
150
231
|
errorCode = "use_dpop_nonce";
|
|
151
232
|
}
|
|
152
233
|
return _writeUnauthorized(res, errorCode,
|
|
153
|
-
(e && e.message) || "DPoP proof verification failed"
|
|
234
|
+
(e && e.message) || "DPoP proof verification failed",
|
|
235
|
+
_freshNonce());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Server-managed nonce check — payload MUST carry a recognized
|
|
239
|
+
// rolling-pair nonce. Missing or stale → 401 + DPoP-Nonce.
|
|
240
|
+
if (nonceMgr) {
|
|
241
|
+
var presented = result.payload && result.payload.nonce;
|
|
242
|
+
if (typeof presented !== "string" || !nonceMgr.accepts(presented)) {
|
|
243
|
+
if (auditOn) {
|
|
244
|
+
try {
|
|
245
|
+
audit().safeEmit({
|
|
246
|
+
action: "auth.bearer.failure",
|
|
247
|
+
actor: { clientIp: requestHelpers.clientIp(req) },
|
|
248
|
+
outcome: "fail",
|
|
249
|
+
metadata: { method: "dpop", reason: "stale-nonce", route: req.url },
|
|
250
|
+
});
|
|
251
|
+
} catch (_ignored) { /* drop-silent */ }
|
|
252
|
+
}
|
|
253
|
+
return _writeUnauthorized(res, "use_dpop_nonce",
|
|
254
|
+
"DPoP-Nonce required (server-managed challenge)", _freshNonce());
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Refresh the nonce on every successful response so the client
|
|
259
|
+
// always carries the latest one (RFC 9449 §8.1 recommendation).
|
|
260
|
+
if (nonceMgr && !res.headersSent) {
|
|
261
|
+
try { res.setHeader("DPoP-Nonce", _freshNonce()); }
|
|
262
|
+
catch (_e) { /* drop-silent — header set best-effort */ }
|
|
154
263
|
}
|
|
155
264
|
|
|
156
265
|
req.dpop = result;
|
package/lib/middleware/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* 7. errorHandler — must be LAST so it catches everything that throws
|
|
18
18
|
*/
|
|
19
19
|
var apiEncrypt = require("./api-encrypt");
|
|
20
|
+
var assetlinks = require("./assetlinks");
|
|
20
21
|
var attachUser = require("./attach-user");
|
|
21
22
|
var bearerAuth = require("./bearer-auth");
|
|
22
23
|
var bodyParser = require("./body-parser");
|
|
@@ -45,6 +46,8 @@ var requireMethods = require("./require-methods");
|
|
|
45
46
|
var securityHeaders = require("./security-headers");
|
|
46
47
|
var securityTxt = require("./security-txt");
|
|
47
48
|
var sse = require("./sse");
|
|
49
|
+
var tusUpload = require("./tus-upload");
|
|
50
|
+
var webAppManifest = require("./web-app-manifest");
|
|
48
51
|
|
|
49
52
|
module.exports = {
|
|
50
53
|
requestId: requestId.create,
|
|
@@ -72,10 +75,13 @@ module.exports = {
|
|
|
72
75
|
sse: sse.create,
|
|
73
76
|
requestLog: requestLog.create,
|
|
74
77
|
apiEncrypt: apiEncrypt,
|
|
78
|
+
assetlinks: assetlinks.create,
|
|
75
79
|
dbRoleFor: dbRoleFor.create,
|
|
76
80
|
dpop: dpop.create,
|
|
77
81
|
hostAllowlist: hostAllowlist.create,
|
|
78
82
|
networkAllowlist: networkAllowlist.create,
|
|
83
|
+
tusUpload: tusUpload.create,
|
|
84
|
+
webAppManifest: webAppManifest.create,
|
|
79
85
|
|
|
80
86
|
// Module exports for advanced use (constants, raw factory access)
|
|
81
87
|
_modules: {
|
|
@@ -101,9 +107,14 @@ module.exports = {
|
|
|
101
107
|
sse: sse,
|
|
102
108
|
requestLog: requestLog,
|
|
103
109
|
apiEncrypt: apiEncrypt,
|
|
110
|
+
assetlinks: assetlinks,
|
|
104
111
|
dbRoleFor: dbRoleFor,
|
|
105
112
|
dpop: dpop,
|
|
106
113
|
hostAllowlist: hostAllowlist,
|
|
107
114
|
networkAllowlist: networkAllowlist,
|
|
115
|
+
tusUpload: tusUpload,
|
|
116
|
+
webAppManifest: webAppManifest,
|
|
108
117
|
},
|
|
109
118
|
};
|
|
119
|
+
|
|
120
|
+
module.exports.tusUpload.memoryStore = tusUpload.memoryStore;
|
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* TUS resumable upload middleware (tus.io v1.0.0).
|
|
4
|
+
*
|
|
5
|
+
* var tus = b.middleware.tusUpload({
|
|
6
|
+
* mountPath: "/uploads",
|
|
7
|
+
* store: b.middleware.tusUpload.memoryStore({ maxSize: C.BYTES.gib(2) }),
|
|
8
|
+
* maxSize: C.BYTES.gib(2),
|
|
9
|
+
* maxChunkSize: C.BYTES.mib(64),
|
|
10
|
+
* expirationSec: C.TIME.hours(24) / 1000,
|
|
11
|
+
* extensions: ["creation", "creation-with-upload", "expiration",
|
|
12
|
+
* "checksum", "termination"],
|
|
13
|
+
* checksumAlgorithms: ["sha3-512", "shake256"],
|
|
14
|
+
* onComplete: async function (uploadId, meta) { ... },
|
|
15
|
+
* audit: true,
|
|
16
|
+
* });
|
|
17
|
+
* router.use(tus);
|
|
18
|
+
*
|
|
19
|
+
* Wire-shape per tus.io 1.0.0 §2:
|
|
20
|
+
* POST <mountPath> → 201 + Location: <mountPath>/<id>
|
|
21
|
+
* HEAD <mountPath>/<id> → 200 + Upload-Offset, Upload-Length, Upload-Metadata
|
|
22
|
+
* PATCH <mountPath>/<id> → 204 + Upload-Offset
|
|
23
|
+
* DELETE <mountPath>/<id> → 204
|
|
24
|
+
* OPTIONS <mountPath> → 204 + Tus-* discovery
|
|
25
|
+
*
|
|
26
|
+
* Extensions implemented:
|
|
27
|
+
* - creation (§4) POST creates a new upload; Upload-Defer-Length
|
|
28
|
+
* supported per §4.3
|
|
29
|
+
* - creation-with-upload (§4.4) Content-Type application/offset+octet-stream
|
|
30
|
+
* on POST appends in the same call
|
|
31
|
+
* - expiration (§4.5) Upload-Expires header on every response;
|
|
32
|
+
* store.terminate() purges expired uploads
|
|
33
|
+
* - checksum (§3.5) Upload-Checksum: <algo> <base64> validated
|
|
34
|
+
* against received bytes; mismatch → 460
|
|
35
|
+
* - termination (§3.4) DELETE removes the upload
|
|
36
|
+
*
|
|
37
|
+
* Concatenation (§4.6) is intentionally not in v1 — operators that need
|
|
38
|
+
* parallel-chunk assembly compose it in their own store layer; re-open
|
|
39
|
+
* if an operator demonstrates a use case the store-level approach
|
|
40
|
+
* cannot satisfy.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
var nodeCrypto = require("crypto"); // for createHash() in checksum extension
|
|
44
|
+
var C = require("../constants");
|
|
45
|
+
var bCrypto = require("../crypto");
|
|
46
|
+
var lazyRequire = require("../lazy-require");
|
|
47
|
+
var safeAsync = require("../safe-async");
|
|
48
|
+
var safeBuffer = require("../safe-buffer");
|
|
49
|
+
var validateOpts = require("../validate-opts");
|
|
50
|
+
var { defineClass } = require("../framework-error");
|
|
51
|
+
|
|
52
|
+
// Observability metric prefix for the TUS middleware. The framework
|
|
53
|
+
// audit pipeline routes through `observability.safeEvent` (metrics +
|
|
54
|
+
// counters) for hot-path lifecycle signals, not `audit.safeEmit`,
|
|
55
|
+
// because PATCH chunks fire dozens of times per upload and the
|
|
56
|
+
// audit chain is reserved for security-relevant state transitions.
|
|
57
|
+
var TUS_ID_BYTES = C.BYTES.bytes(18); // 144 bits ≈ 24 base64url chars per upload id
|
|
58
|
+
|
|
59
|
+
// HTTP status codes used by TUS — hoisted to named constants so the
|
|
60
|
+
// raw-byte-literal detector doesn't fire on every status path.
|
|
61
|
+
var STATUS_OK = 200; // allow:raw-byte-literal — HTTP status
|
|
62
|
+
var STATUS_CREATED = 201; // allow:raw-byte-literal — HTTP status
|
|
63
|
+
var STATUS_NO_CONTENT = 204; // allow:raw-byte-literal — HTTP status
|
|
64
|
+
var STATUS_BAD_REQUEST = 400; // allow:raw-byte-literal — HTTP status
|
|
65
|
+
var STATUS_NOT_FOUND = 404; // allow:raw-byte-literal — HTTP status
|
|
66
|
+
var STATUS_METHOD_NOT_ALLOWED = 405; // allow:raw-byte-literal — HTTP status
|
|
67
|
+
var STATUS_CONFLICT = 409; // allow:raw-byte-literal — HTTP status
|
|
68
|
+
var STATUS_PRECONDITION_FAILED = 412; // allow:raw-byte-literal — HTTP status
|
|
69
|
+
var STATUS_PAYLOAD_TOO_LARGE = 413; // allow:raw-byte-literal — HTTP status
|
|
70
|
+
var STATUS_UNSUPPORTED_MEDIA = 415; // allow:raw-byte-literal — HTTP status
|
|
71
|
+
var STATUS_CHECKSUM_MISMATCH = 460; // allow:raw-byte-literal — TUS-specific status (§3.5)
|
|
72
|
+
var STATUS_INTERNAL_ERROR = 500; // allow:raw-byte-literal — HTTP status
|
|
73
|
+
|
|
74
|
+
var TusError = defineClass("TusError", { alwaysPermanent: true });
|
|
75
|
+
|
|
76
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
77
|
+
|
|
78
|
+
var TUS_VERSION = "1.0.0";
|
|
79
|
+
var SUPPORTED_VERSIONS = ["1.0.0"];
|
|
80
|
+
var DEFAULT_EXTENSIONS = [
|
|
81
|
+
"creation", "creation-with-upload", "expiration",
|
|
82
|
+
"checksum", "termination",
|
|
83
|
+
];
|
|
84
|
+
var DEFAULT_CHECKSUM_ALGORITHMS = ["sha3-512", "shake256"];
|
|
85
|
+
var KNOWN_CHECKSUM_ALGORITHMS = {
|
|
86
|
+
"sha3-512": "sha3-512",
|
|
87
|
+
"shake256": "shake256",
|
|
88
|
+
"sha-256": "sha256",
|
|
89
|
+
"sha-512": "sha512",
|
|
90
|
+
"sha3-256": "sha3-256",
|
|
91
|
+
};
|
|
92
|
+
var KNOWN_EXTENSIONS = {
|
|
93
|
+
"creation": true,
|
|
94
|
+
"creation-with-upload": true,
|
|
95
|
+
"expiration": true,
|
|
96
|
+
"checksum": true,
|
|
97
|
+
"termination": true,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function _b64uId() {
|
|
101
|
+
return bCrypto.generateBytes(TUS_ID_BYTES).toString("base64url");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _parseMetadata(headerValue) {
|
|
105
|
+
// RFC-style key-value list: `key1 base64val1,key2 base64val2`. Per
|
|
106
|
+
// tus.io 1.0.0 §3.2 keys are ASCII printable except space/comma; values
|
|
107
|
+
// are base64-encoded UTF-8 octet sequences.
|
|
108
|
+
if (typeof headerValue !== "string" || headerValue.length === 0) return null;
|
|
109
|
+
var pairs = headerValue.split(",");
|
|
110
|
+
var out = {};
|
|
111
|
+
for (var i = 0; i < pairs.length; i++) {
|
|
112
|
+
var raw = pairs[i].trim();
|
|
113
|
+
if (raw.length === 0) continue;
|
|
114
|
+
var sp = raw.indexOf(" ");
|
|
115
|
+
var key, val;
|
|
116
|
+
if (sp === -1) { key = raw; val = ""; }
|
|
117
|
+
else { key = raw.slice(0, sp); val = raw.slice(sp + 1); }
|
|
118
|
+
if (!/^[!-+\--.0-~]+$/.test(key)) return null; // printable, no space/comma
|
|
119
|
+
if (val.length > 0 && !/^[A-Za-z0-9+/=]+$/.test(val)) return null;
|
|
120
|
+
var decoded = "";
|
|
121
|
+
if (val.length > 0) {
|
|
122
|
+
try { decoded = Buffer.from(val, "base64").toString("utf8"); }
|
|
123
|
+
catch (_e) { return null; }
|
|
124
|
+
}
|
|
125
|
+
out[key] = decoded;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _serializeMetadata(metaObj) {
|
|
131
|
+
if (!metaObj || typeof metaObj !== "object") return "";
|
|
132
|
+
var keys = Object.keys(metaObj);
|
|
133
|
+
var parts = [];
|
|
134
|
+
for (var i = 0; i < keys.length; i++) {
|
|
135
|
+
var k = keys[i];
|
|
136
|
+
var v = metaObj[k];
|
|
137
|
+
var encoded = (typeof v === "string" && v.length > 0)
|
|
138
|
+
? Buffer.from(v, "utf8").toString("base64")
|
|
139
|
+
: "";
|
|
140
|
+
parts.push(encoded.length > 0 ? (k + " " + encoded) : k);
|
|
141
|
+
}
|
|
142
|
+
return parts.join(",");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _parseChecksumHeader(headerValue, allowedSet) {
|
|
146
|
+
// tus.io 1.0.0 §3.5: `Upload-Checksum: <algo> <base64-digest>`.
|
|
147
|
+
if (typeof headerValue !== "string") return null;
|
|
148
|
+
var sp = headerValue.indexOf(" ");
|
|
149
|
+
if (sp === -1) return { error: "malformed" };
|
|
150
|
+
var algo = headerValue.slice(0, sp).trim().toLowerCase();
|
|
151
|
+
var digestB64 = headerValue.slice(sp + 1).trim();
|
|
152
|
+
if (algo.length === 0 || digestB64.length === 0) return { error: "malformed" };
|
|
153
|
+
if (!allowedSet[algo]) return { error: "algo-unsupported" };
|
|
154
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(digestB64)) return { error: "malformed" };
|
|
155
|
+
var nodeAlgo = KNOWN_CHECKSUM_ALGORITHMS[algo];
|
|
156
|
+
if (!nodeAlgo) return { error: "algo-unsupported" };
|
|
157
|
+
return { algo: algo, nodeAlgo: nodeAlgo, digestB64: digestB64 };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function memoryStore(opts) {
|
|
161
|
+
opts = opts || {};
|
|
162
|
+
var maxSize = opts.maxSize;
|
|
163
|
+
if (maxSize !== undefined && (typeof maxSize !== "number" || !isFinite(maxSize) || maxSize <= 0)) {
|
|
164
|
+
throw new TusError("tus/bad-store-opts",
|
|
165
|
+
"tusUpload.memoryStore: maxSize must be a positive finite number");
|
|
166
|
+
}
|
|
167
|
+
var defaultExpirationMs = opts.defaultExpirationMs || C.TIME.hours(24);
|
|
168
|
+
|
|
169
|
+
var uploads = new Map(); // id -> { length, deferLength, metadata, buf, offset, expireAt, completed, terminated }
|
|
170
|
+
|
|
171
|
+
function create(meta) {
|
|
172
|
+
var id = _b64uId();
|
|
173
|
+
var now = Date.now();
|
|
174
|
+
var rec = {
|
|
175
|
+
id: id,
|
|
176
|
+
length: (typeof meta.length === "number" && isFinite(meta.length)) ? meta.length : null,
|
|
177
|
+
deferLength: meta.deferLength === true,
|
|
178
|
+
metadata: meta.metadata || {},
|
|
179
|
+
buf: Buffer.alloc(0),
|
|
180
|
+
offset: 0,
|
|
181
|
+
expireAt: now + (meta.expirationMs || defaultExpirationMs),
|
|
182
|
+
completed: false,
|
|
183
|
+
terminated: false,
|
|
184
|
+
hashState: null,
|
|
185
|
+
};
|
|
186
|
+
uploads.set(id, rec);
|
|
187
|
+
return Promise.resolve(rec);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function head(id) {
|
|
191
|
+
var rec = uploads.get(id);
|
|
192
|
+
if (!rec || rec.terminated) return Promise.resolve(null);
|
|
193
|
+
if (rec.expireAt && rec.expireAt < Date.now()) {
|
|
194
|
+
uploads.delete(id);
|
|
195
|
+
return Promise.resolve(null);
|
|
196
|
+
}
|
|
197
|
+
return Promise.resolve(rec);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function append(id, chunk, offset) {
|
|
201
|
+
var rec = uploads.get(id);
|
|
202
|
+
if (!rec || rec.terminated) return Promise.reject(new TusError("tus/upload-not-found", "upload " + id + " not found"));
|
|
203
|
+
if (offset !== rec.offset) {
|
|
204
|
+
return Promise.reject(new TusError("tus/offset-mismatch", "expected offset " + rec.offset + ", got " + offset));
|
|
205
|
+
}
|
|
206
|
+
if (rec.length !== null && rec.offset + chunk.length > rec.length) {
|
|
207
|
+
return Promise.reject(new TusError("tus/length-exceeded", "chunk would exceed declared Upload-Length"));
|
|
208
|
+
}
|
|
209
|
+
if (maxSize !== undefined && rec.offset + chunk.length > maxSize) {
|
|
210
|
+
return Promise.reject(new TusError("tus/length-exceeded", "chunk would exceed memoryStore maxSize"));
|
|
211
|
+
}
|
|
212
|
+
rec.buf = Buffer.concat([rec.buf, chunk]);
|
|
213
|
+
rec.offset += chunk.length;
|
|
214
|
+
if (rec.length !== null && rec.offset === rec.length) rec.completed = true;
|
|
215
|
+
return Promise.resolve(rec);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function setLength(id, length) {
|
|
219
|
+
var rec = uploads.get(id);
|
|
220
|
+
if (!rec) return Promise.reject(new TusError("tus/upload-not-found", "upload " + id + " not found"));
|
|
221
|
+
if (rec.length !== null) return Promise.reject(new TusError("tus/length-already-set", "Upload-Length already declared"));
|
|
222
|
+
rec.length = length;
|
|
223
|
+
rec.deferLength = false;
|
|
224
|
+
return Promise.resolve(rec);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function terminate(id) {
|
|
228
|
+
var rec = uploads.get(id);
|
|
229
|
+
if (!rec) return Promise.resolve(false);
|
|
230
|
+
rec.terminated = true;
|
|
231
|
+
uploads.delete(id);
|
|
232
|
+
return Promise.resolve(true);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function purgeExpired() {
|
|
236
|
+
var now = Date.now();
|
|
237
|
+
var removed = 0;
|
|
238
|
+
for (var entry of uploads) {
|
|
239
|
+
if (entry[1].expireAt && entry[1].expireAt < now) { uploads.delete(entry[0]); removed++; }
|
|
240
|
+
}
|
|
241
|
+
return Promise.resolve(removed);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getBuffer(id) {
|
|
245
|
+
var rec = uploads.get(id);
|
|
246
|
+
return Promise.resolve(rec ? rec.buf : null);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
name: "memory",
|
|
251
|
+
create: create,
|
|
252
|
+
head: head,
|
|
253
|
+
append: append,
|
|
254
|
+
setLength: setLength,
|
|
255
|
+
terminate: terminate,
|
|
256
|
+
purgeExpired: purgeExpired,
|
|
257
|
+
getBuffer: getBuffer,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function _writeError(res, status, body) {
|
|
262
|
+
if (res.headersSent) return;
|
|
263
|
+
var bodyStr = body || "";
|
|
264
|
+
res.writeHead(status, {
|
|
265
|
+
"Tus-Resumable": TUS_VERSION,
|
|
266
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
267
|
+
"Content-Length": Buffer.byteLength(bodyStr),
|
|
268
|
+
});
|
|
269
|
+
res.end(bodyStr);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _emitTusBaseHeaders(res, extra) {
|
|
273
|
+
var headers = Object.assign({
|
|
274
|
+
"Tus-Resumable": TUS_VERSION,
|
|
275
|
+
}, extra || {});
|
|
276
|
+
return headers;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function _readChunk(req, maxChunkSize) {
|
|
280
|
+
return new Promise(function (resolve, reject) {
|
|
281
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
282
|
+
maxBytes: maxChunkSize,
|
|
283
|
+
errorClass: TusError,
|
|
284
|
+
sizeCode: "tus/chunk-too-large",
|
|
285
|
+
sizeMessage: "PATCH body exceeded maxChunkSize",
|
|
286
|
+
});
|
|
287
|
+
req.on("data", function (c) {
|
|
288
|
+
try { collector.push(c); }
|
|
289
|
+
catch (e) { req.removeAllListeners("data"); reject(e); }
|
|
290
|
+
});
|
|
291
|
+
req.on("end", function () { resolve(collector.result()); });
|
|
292
|
+
req.on("error", function (e) { reject(e); });
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function create(opts) {
|
|
297
|
+
validateOpts.requireObject(opts, "middleware.tusUpload", TusError);
|
|
298
|
+
validateOpts(opts, [
|
|
299
|
+
"mountPath", "store", "maxSize", "maxChunkSize",
|
|
300
|
+
"expirationSec", "extensions", "checksumAlgorithms",
|
|
301
|
+
"onComplete", "onCreate", "onTerminate", "audit",
|
|
302
|
+
], "middleware.tusUpload");
|
|
303
|
+
|
|
304
|
+
var mountPath = opts.mountPath;
|
|
305
|
+
if (typeof mountPath !== "string" || mountPath.length === 0 || mountPath.charAt(0) !== "/") {
|
|
306
|
+
throw new TusError("tus/bad-mountpath",
|
|
307
|
+
"middleware.tusUpload: mountPath must be a non-empty path starting with '/'");
|
|
308
|
+
}
|
|
309
|
+
if (mountPath.length > 1 && mountPath.charAt(mountPath.length - 1) === "/") {
|
|
310
|
+
mountPath = mountPath.slice(0, -1);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
var store = opts.store;
|
|
314
|
+
if (!store || typeof store.create !== "function" || typeof store.head !== "function" ||
|
|
315
|
+
typeof store.append !== "function" || typeof store.terminate !== "function") {
|
|
316
|
+
throw new TusError("tus/bad-store",
|
|
317
|
+
"middleware.tusUpload: store must implement { create, head, append, terminate }");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
var maxSize = opts.maxSize;
|
|
321
|
+
if (maxSize !== undefined && (typeof maxSize !== "number" || !isFinite(maxSize) || maxSize <= 0)) {
|
|
322
|
+
throw new TusError("tus/bad-opts", "middleware.tusUpload: maxSize must be a positive finite number");
|
|
323
|
+
}
|
|
324
|
+
var maxChunkSize = opts.maxChunkSize;
|
|
325
|
+
if (maxChunkSize === undefined) maxChunkSize = C.BYTES.mib(64);
|
|
326
|
+
if (typeof maxChunkSize !== "number" || !isFinite(maxChunkSize) || maxChunkSize <= 0) {
|
|
327
|
+
throw new TusError("tus/bad-opts", "middleware.tusUpload: maxChunkSize must be a positive finite number");
|
|
328
|
+
}
|
|
329
|
+
var expirationSec = opts.expirationSec;
|
|
330
|
+
if (expirationSec !== undefined && (typeof expirationSec !== "number" || !isFinite(expirationSec) || expirationSec <= 0)) {
|
|
331
|
+
throw new TusError("tus/bad-opts", "middleware.tusUpload: expirationSec must be a positive finite number");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
var extensions = Array.isArray(opts.extensions) ? opts.extensions.slice() : DEFAULT_EXTENSIONS.slice();
|
|
335
|
+
for (var i = 0; i < extensions.length; i++) {
|
|
336
|
+
if (!KNOWN_EXTENSIONS[extensions[i]]) {
|
|
337
|
+
throw new TusError("tus/bad-opts",
|
|
338
|
+
"middleware.tusUpload: unknown extension '" + extensions[i] + "'");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
var hasCreation = extensions.indexOf("creation") !== -1;
|
|
342
|
+
var hasCreationWithBody = extensions.indexOf("creation-with-upload") !== -1;
|
|
343
|
+
var hasExpiration = extensions.indexOf("expiration") !== -1;
|
|
344
|
+
var hasChecksum = extensions.indexOf("checksum") !== -1;
|
|
345
|
+
var hasTermination = extensions.indexOf("termination") !== -1;
|
|
346
|
+
|
|
347
|
+
var checksumAlgorithms = Array.isArray(opts.checksumAlgorithms)
|
|
348
|
+
? opts.checksumAlgorithms.slice()
|
|
349
|
+
: DEFAULT_CHECKSUM_ALGORITHMS.slice();
|
|
350
|
+
var checksumAlgorithmSet = {};
|
|
351
|
+
for (var j = 0; j < checksumAlgorithms.length; j++) {
|
|
352
|
+
var algo = checksumAlgorithms[j];
|
|
353
|
+
if (!KNOWN_CHECKSUM_ALGORITHMS[algo]) {
|
|
354
|
+
throw new TusError("tus/bad-opts",
|
|
355
|
+
"middleware.tusUpload: unknown checksum algorithm '" + algo + "'");
|
|
356
|
+
}
|
|
357
|
+
checksumAlgorithmSet[algo] = true;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
validateOpts.optionalFunction(opts.onComplete, "middleware.tusUpload: onComplete", TusError, "tus/bad-opts");
|
|
361
|
+
validateOpts.optionalFunction(opts.onCreate, "middleware.tusUpload: onCreate", TusError, "tus/bad-opts");
|
|
362
|
+
validateOpts.optionalFunction(opts.onTerminate, "middleware.tusUpload: onTerminate", TusError, "tus/bad-opts");
|
|
363
|
+
|
|
364
|
+
var auditOn = opts.audit !== false;
|
|
365
|
+
|
|
366
|
+
if (hasExpiration && typeof store.purgeExpired === "function") {
|
|
367
|
+
safeAsync.repeating(function () {
|
|
368
|
+
store.purgeExpired().catch(function () { /* drop-silent — sweep best-effort */ });
|
|
369
|
+
}, C.TIME.minutes(5), { name: "tus-upload-sweep" });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function _expirationHeader(rec) {
|
|
373
|
+
if (!hasExpiration || !rec || !rec.expireAt) return null;
|
|
374
|
+
return new Date(rec.expireAt).toUTCString();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function _emitMetric(verb, _outcome, _metadata) {
|
|
378
|
+
if (!auditOn) return;
|
|
379
|
+
try { observability().safeEvent("middleware.tusUpload." + verb, 1, {}); }
|
|
380
|
+
catch (_e) { /* drop-silent — observability sink */ }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function _handleOptions(req, res) {
|
|
384
|
+
var headers = _emitTusBaseHeaders(res, {
|
|
385
|
+
"Tus-Version": SUPPORTED_VERSIONS.join(","),
|
|
386
|
+
"Tus-Extension": extensions.join(","),
|
|
387
|
+
});
|
|
388
|
+
if (maxSize !== undefined) headers["Tus-Max-Size"] = String(maxSize);
|
|
389
|
+
if (hasChecksum) headers["Tus-Checksum-Algorithm"] = checksumAlgorithms.join(",");
|
|
390
|
+
res.writeHead(STATUS_NO_CONTENT, headers);
|
|
391
|
+
res.end();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function _handleCreate(req, res) {
|
|
395
|
+
if (!hasCreation) return _writeError(res, STATUS_METHOD_NOT_ALLOWED, "creation extension not enabled");
|
|
396
|
+
var lengthHdr = req.headers["upload-length"];
|
|
397
|
+
var deferHdr = req.headers["upload-defer-length"];
|
|
398
|
+
var metadataHdr = req.headers["upload-metadata"];
|
|
399
|
+
|
|
400
|
+
var uploadLength = null;
|
|
401
|
+
var deferLength = false;
|
|
402
|
+
if (lengthHdr !== undefined) {
|
|
403
|
+
uploadLength = parseInt(lengthHdr, 10);
|
|
404
|
+
if (!isFinite(uploadLength) || uploadLength < 0 || String(uploadLength) !== String(lengthHdr).trim()) {
|
|
405
|
+
return _writeError(res, STATUS_BAD_REQUEST, "Upload-Length must be a non-negative integer");
|
|
406
|
+
}
|
|
407
|
+
if (maxSize !== undefined && uploadLength > maxSize) {
|
|
408
|
+
return _writeError(res, STATUS_PAYLOAD_TOO_LARGE, "Upload-Length exceeds Tus-Max-Size");
|
|
409
|
+
}
|
|
410
|
+
} else if (String(deferHdr).trim() === "1") {
|
|
411
|
+
deferLength = true;
|
|
412
|
+
} else {
|
|
413
|
+
return _writeError(res, STATUS_BAD_REQUEST, "Upload-Length or Upload-Defer-Length: 1 required");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
var metadata = null;
|
|
417
|
+
if (metadataHdr !== undefined) {
|
|
418
|
+
metadata = _parseMetadata(metadataHdr);
|
|
419
|
+
if (metadata === null) return _writeError(res, STATUS_BAD_REQUEST, "malformed Upload-Metadata");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
var rec;
|
|
423
|
+
try {
|
|
424
|
+
rec = await store.create({
|
|
425
|
+
length: uploadLength,
|
|
426
|
+
deferLength: deferLength,
|
|
427
|
+
metadata: metadata || {},
|
|
428
|
+
expirationMs: expirationSec ? C.TIME.seconds(expirationSec) : undefined,
|
|
429
|
+
});
|
|
430
|
+
} catch (e) {
|
|
431
|
+
_emitMetric("create.fail");
|
|
432
|
+
return _writeError(res, STATUS_INTERNAL_ERROR, (e && e.message) || "store create failed");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (typeof opts.onCreate === "function") {
|
|
436
|
+
try { await opts.onCreate(rec.id, { length: uploadLength, metadata: metadata }); }
|
|
437
|
+
catch (_e) { /* operator hook — drop-silent */ }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
var location = mountPath + "/" + rec.id;
|
|
441
|
+
var headers = _emitTusBaseHeaders(res, { "Location": location });
|
|
442
|
+
var expHdr = _expirationHeader(rec);
|
|
443
|
+
if (expHdr) headers["Upload-Expires"] = expHdr;
|
|
444
|
+
|
|
445
|
+
// creation-with-upload: append the body in the same request when
|
|
446
|
+
// Content-Type is application/offset+octet-stream.
|
|
447
|
+
var contentType = req.headers["content-type"];
|
|
448
|
+
if (hasCreationWithBody && contentType === "application/offset+octet-stream") {
|
|
449
|
+
var chunk;
|
|
450
|
+
try { chunk = await _readChunk(req, maxChunkSize); }
|
|
451
|
+
catch (e) { return _writeError(res, e.code === "tus/chunk-too-large" ? STATUS_PAYLOAD_TOO_LARGE : STATUS_BAD_REQUEST, e.message); }
|
|
452
|
+
try {
|
|
453
|
+
rec = await store.append(rec.id, chunk, 0);
|
|
454
|
+
} catch (e) {
|
|
455
|
+
return _writeError(res, e.code === "tus/length-exceeded" ? STATUS_PAYLOAD_TOO_LARGE : STATUS_BAD_REQUEST, e.message);
|
|
456
|
+
}
|
|
457
|
+
headers["Upload-Offset"] = String(rec.offset);
|
|
458
|
+
if (rec.completed && typeof opts.onComplete === "function") {
|
|
459
|
+
try { await opts.onComplete(rec.id, { metadata: rec.metadata, store: store }); }
|
|
460
|
+
catch (_e) { /* operator hook — drop-silent */ }
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
res.writeHead(STATUS_CREATED, headers);
|
|
465
|
+
res.end();
|
|
466
|
+
_emitMetric("create.ok");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function _handleHead(req, res, id) {
|
|
470
|
+
var rec;
|
|
471
|
+
try { rec = await store.head(id); }
|
|
472
|
+
catch (_e) { rec = null; }
|
|
473
|
+
if (!rec) return _writeError(res, STATUS_NOT_FOUND, "upload not found");
|
|
474
|
+
var headers = _emitTusBaseHeaders(res, {
|
|
475
|
+
"Upload-Offset": String(rec.offset),
|
|
476
|
+
"Cache-Control": "no-store",
|
|
477
|
+
});
|
|
478
|
+
if (rec.length !== null) headers["Upload-Length"] = String(rec.length);
|
|
479
|
+
else if (rec.deferLength) headers["Upload-Defer-Length"] = "1";
|
|
480
|
+
if (rec.metadata && Object.keys(rec.metadata).length > 0) {
|
|
481
|
+
headers["Upload-Metadata"] = _serializeMetadata(rec.metadata);
|
|
482
|
+
}
|
|
483
|
+
var expHdr = _expirationHeader(rec);
|
|
484
|
+
if (expHdr) headers["Upload-Expires"] = expHdr;
|
|
485
|
+
res.writeHead(STATUS_OK, headers);
|
|
486
|
+
res.end();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function _handlePatch(req, res, id) {
|
|
490
|
+
var contentType = req.headers["content-type"];
|
|
491
|
+
if (contentType !== "application/offset+octet-stream") {
|
|
492
|
+
return _writeError(res, STATUS_UNSUPPORTED_MEDIA, "Content-Type must be application/offset+octet-stream");
|
|
493
|
+
}
|
|
494
|
+
var offsetHdr = req.headers["upload-offset"];
|
|
495
|
+
if (offsetHdr === undefined) return _writeError(res, STATUS_BAD_REQUEST, "Upload-Offset required");
|
|
496
|
+
var offset = parseInt(offsetHdr, 10);
|
|
497
|
+
if (!isFinite(offset) || offset < 0 || String(offset) !== String(offsetHdr).trim()) {
|
|
498
|
+
return _writeError(res, STATUS_BAD_REQUEST, "Upload-Offset must be a non-negative integer");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
var rec;
|
|
502
|
+
try { rec = await store.head(id); }
|
|
503
|
+
catch (_e) { rec = null; }
|
|
504
|
+
if (!rec) return _writeError(res, STATUS_NOT_FOUND, "upload not found");
|
|
505
|
+
|
|
506
|
+
if (rec.length === null && req.headers["upload-length"] !== undefined) {
|
|
507
|
+
// Upload-Defer-Length finalization (§4.3) — declare length on first PATCH
|
|
508
|
+
var declared = parseInt(req.headers["upload-length"], 10);
|
|
509
|
+
if (!isFinite(declared) || declared < 0) {
|
|
510
|
+
return _writeError(res, STATUS_BAD_REQUEST, "Upload-Length must be a non-negative integer");
|
|
511
|
+
}
|
|
512
|
+
if (maxSize !== undefined && declared > maxSize) {
|
|
513
|
+
return _writeError(res, STATUS_PAYLOAD_TOO_LARGE, "Upload-Length exceeds Tus-Max-Size");
|
|
514
|
+
}
|
|
515
|
+
try { rec = await store.setLength(id, declared); }
|
|
516
|
+
catch (e) { return _writeError(res, STATUS_CONFLICT, e.message); }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (offset !== rec.offset) {
|
|
520
|
+
return _writeError(res, STATUS_CONFLICT, "Upload-Offset mismatch (expected " + rec.offset + ")");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
var checksum = null;
|
|
524
|
+
if (req.headers["upload-checksum"] !== undefined) {
|
|
525
|
+
if (!hasChecksum) return _writeError(res, STATUS_BAD_REQUEST, "checksum extension not enabled");
|
|
526
|
+
checksum = _parseChecksumHeader(req.headers["upload-checksum"], checksumAlgorithmSet);
|
|
527
|
+
if (!checksum || checksum.error) {
|
|
528
|
+
if (checksum && checksum.error === "algo-unsupported") {
|
|
529
|
+
return _writeError(res, STATUS_BAD_REQUEST, "checksum algorithm unsupported");
|
|
530
|
+
}
|
|
531
|
+
return _writeError(res, STATUS_BAD_REQUEST, "malformed Upload-Checksum");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
var chunk;
|
|
536
|
+
try { chunk = await _readChunk(req, maxChunkSize); }
|
|
537
|
+
catch (e) { return _writeError(res, e.code === "tus/chunk-too-large" ? STATUS_PAYLOAD_TOO_LARGE : STATUS_BAD_REQUEST, e.message); }
|
|
538
|
+
|
|
539
|
+
if (checksum) {
|
|
540
|
+
var hasher = nodeCrypto.createHash(checksum.nodeAlgo);
|
|
541
|
+
hasher.update(chunk);
|
|
542
|
+
var digestB64 = hasher.digest("base64");
|
|
543
|
+
if (digestB64 !== checksum.digestB64) {
|
|
544
|
+
return _writeError(res, STATUS_CHECKSUM_MISMATCH, "Upload-Checksum mismatch");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
try { rec = await store.append(id, chunk, offset); }
|
|
549
|
+
catch (e) {
|
|
550
|
+
var sc = STATUS_INTERNAL_ERROR;
|
|
551
|
+
if (e.code === "tus/offset-mismatch") sc = STATUS_CONFLICT;
|
|
552
|
+
else if (e.code === "tus/length-exceeded") sc = STATUS_PAYLOAD_TOO_LARGE;
|
|
553
|
+
else if (e.code === "tus/upload-not-found") sc = STATUS_NOT_FOUND;
|
|
554
|
+
return _writeError(res, sc, e.message);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
var headers = _emitTusBaseHeaders(res, { "Upload-Offset": String(rec.offset) });
|
|
558
|
+
var expHdr = _expirationHeader(rec);
|
|
559
|
+
if (expHdr) headers["Upload-Expires"] = expHdr;
|
|
560
|
+
|
|
561
|
+
res.writeHead(STATUS_NO_CONTENT, headers);
|
|
562
|
+
res.end();
|
|
563
|
+
|
|
564
|
+
if (rec.completed && typeof opts.onComplete === "function") {
|
|
565
|
+
try { await opts.onComplete(id, { metadata: rec.metadata, store: store }); }
|
|
566
|
+
catch (_e) { /* operator hook — drop-silent */ }
|
|
567
|
+
_emitMetric("complete.ok");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function _handleDelete(req, res, id) {
|
|
572
|
+
if (!hasTermination) return _writeError(res, STATUS_METHOD_NOT_ALLOWED, "termination extension not enabled");
|
|
573
|
+
var existed;
|
|
574
|
+
try { existed = await store.terminate(id); }
|
|
575
|
+
catch (_e) { existed = false; }
|
|
576
|
+
if (!existed) return _writeError(res, STATUS_NOT_FOUND, "upload not found");
|
|
577
|
+
if (typeof opts.onTerminate === "function") {
|
|
578
|
+
try { await opts.onTerminate(id); }
|
|
579
|
+
catch (_e) { /* operator hook — drop-silent */ }
|
|
580
|
+
}
|
|
581
|
+
res.writeHead(STATUS_NO_CONTENT, _emitTusBaseHeaders(res, {}));
|
|
582
|
+
res.end();
|
|
583
|
+
_emitMetric("terminate.ok");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return async function tusUploadMiddleware(req, res, next) {
|
|
587
|
+
var url = req.url || "/";
|
|
588
|
+
var qIdx = url.indexOf("?");
|
|
589
|
+
var path = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
590
|
+
|
|
591
|
+
var isCollection = (path === mountPath);
|
|
592
|
+
var isResource = false;
|
|
593
|
+
var resourceId = null;
|
|
594
|
+
if (path.indexOf(mountPath + "/") === 0) {
|
|
595
|
+
resourceId = path.slice(mountPath.length + 1);
|
|
596
|
+
// No further sub-paths allowed — TUS resources are flat.
|
|
597
|
+
if (resourceId.indexOf("/") === -1 && /^[A-Za-z0-9_-]{1,128}$/.test(resourceId)) {
|
|
598
|
+
isResource = true;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (!isCollection && !isResource) return next();
|
|
602
|
+
|
|
603
|
+
// Tus-Resumable header gate (§2.2). OPTIONS is exempt; all other
|
|
604
|
+
// verbs must declare a supported version.
|
|
605
|
+
var method = (req.method || "").toUpperCase();
|
|
606
|
+
if (method !== "OPTIONS") {
|
|
607
|
+
var version = req.headers["tus-resumable"];
|
|
608
|
+
if (version === undefined) {
|
|
609
|
+
return _writeError(res, STATUS_PRECONDITION_FAILED, "Tus-Resumable header required");
|
|
610
|
+
}
|
|
611
|
+
if (SUPPORTED_VERSIONS.indexOf(version) === -1) {
|
|
612
|
+
var hdrs = _emitTusBaseHeaders(res, { "Tus-Version": SUPPORTED_VERSIONS.join(",") });
|
|
613
|
+
res.writeHead(STATUS_PRECONDITION_FAILED, hdrs);
|
|
614
|
+
res.end("Tus-Resumable version unsupported");
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
if (method === "OPTIONS") return await _handleOptions(req, res);
|
|
621
|
+
if (isCollection && method === "POST") return await _handleCreate(req, res);
|
|
622
|
+
if (isResource && method === "HEAD") return await _handleHead(req, res, resourceId);
|
|
623
|
+
if (isResource && method === "PATCH") return await _handlePatch(req, res, resourceId);
|
|
624
|
+
if (isResource && method === "DELETE") return await _handleDelete(req, res, resourceId);
|
|
625
|
+
} catch (e) {
|
|
626
|
+
_emitMetric("error.fail");
|
|
627
|
+
return _writeError(res, STATUS_INTERNAL_ERROR, (e && e.message) || "internal error");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
var allow = isCollection ? "OPTIONS, POST" : "OPTIONS, HEAD, PATCH" + (hasTermination ? ", DELETE" : "");
|
|
631
|
+
res.writeHead(STATUS_METHOD_NOT_ALLOWED, _emitTusBaseHeaders(res, {
|
|
632
|
+
"Allow": allow,
|
|
633
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
634
|
+
"Content-Length": "0",
|
|
635
|
+
}));
|
|
636
|
+
res.end();
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function close(middleware) {
|
|
641
|
+
// Reserved for future store-close hook; the sweep timer is the only
|
|
642
|
+
// resource currently bound, and it lives inside the middleware closure.
|
|
643
|
+
if (middleware && typeof middleware.close === "function") middleware.close();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
module.exports = {
|
|
647
|
+
create: create,
|
|
648
|
+
memoryStore: memoryStore,
|
|
649
|
+
close: close,
|
|
650
|
+
TusError: TusError,
|
|
651
|
+
TUS_VERSION: TUS_VERSION,
|
|
652
|
+
KNOWN_EXTENSIONS: KNOWN_EXTENSIONS,
|
|
653
|
+
KNOWN_CHECKSUM_ALGORITHMS: KNOWN_CHECKSUM_ALGORITHMS,
|
|
654
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* web-app-manifest middleware — emits the W3C Web App Manifest at
|
|
4
|
+
* `/manifest.webmanifest` (and `/manifest.json` when `alsoAtJsonPath`
|
|
5
|
+
* is true) per the W3C Web App Manifest specification.
|
|
6
|
+
*
|
|
7
|
+
* var mf = b.middleware.webAppManifest({
|
|
8
|
+
* name: "Example App",
|
|
9
|
+
* short_name: "Example",
|
|
10
|
+
* start_url: "/",
|
|
11
|
+
* display: "standalone",
|
|
12
|
+
* theme_color: "#1976d2",
|
|
13
|
+
* background_color: "#ffffff",
|
|
14
|
+
* icons: [
|
|
15
|
+
* { src: "/icons/192.png", sizes: "192x192", type: "image/png" },
|
|
16
|
+
* { src: "/icons/512.png", sizes: "512x512", type: "image/png" },
|
|
17
|
+
* ],
|
|
18
|
+
* });
|
|
19
|
+
* router.use(mf);
|
|
20
|
+
*
|
|
21
|
+
* The manifest is JSON-serialized once at create() and served with
|
|
22
|
+
* `Content-Type: application/manifest+json` per the W3C spec.
|
|
23
|
+
*
|
|
24
|
+
* Per W3C — `name`, `start_url`, and at least one icon are required
|
|
25
|
+
* for an installable PWA. The framework throws at create() when any
|
|
26
|
+
* of those are missing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
var lazyRequire = require("../lazy-require");
|
|
30
|
+
var safeJson = require("../safe-json");
|
|
31
|
+
var validateOpts = require("../validate-opts");
|
|
32
|
+
var { defineClass } = require("../framework-error");
|
|
33
|
+
|
|
34
|
+
var WebAppManifestError = defineClass("WebAppManifestError", { alwaysPermanent: true });
|
|
35
|
+
|
|
36
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
37
|
+
|
|
38
|
+
function _isPlainArray(x) { return Array.isArray(x); }
|
|
39
|
+
|
|
40
|
+
function create(opts) {
|
|
41
|
+
validateOpts.requireObject(opts, "middleware.webAppManifest", WebAppManifestError);
|
|
42
|
+
// Allowlist subset of W3C-spec attributes operators commonly set.
|
|
43
|
+
// Anything outside the list throws at create — typos surface at boot.
|
|
44
|
+
validateOpts(opts, [
|
|
45
|
+
"name", "short_name", "description", "start_url", "scope",
|
|
46
|
+
"display", "display_override", "orientation",
|
|
47
|
+
"theme_color", "background_color",
|
|
48
|
+
"icons", "screenshots", "shortcuts",
|
|
49
|
+
"categories", "lang", "dir", "id",
|
|
50
|
+
"prefer_related_applications", "related_applications",
|
|
51
|
+
"alsoAtJsonPath", "audit",
|
|
52
|
+
], "middleware.webAppManifest");
|
|
53
|
+
|
|
54
|
+
validateOpts.requireNonEmptyString(opts.name,
|
|
55
|
+
"middleware.webAppManifest: name", WebAppManifestError, "manifest/no-name");
|
|
56
|
+
validateOpts.requireNonEmptyString(opts.start_url,
|
|
57
|
+
"middleware.webAppManifest: start_url", WebAppManifestError, "manifest/no-start-url");
|
|
58
|
+
if (!_isPlainArray(opts.icons) || opts.icons.length === 0) {
|
|
59
|
+
throw new WebAppManifestError("manifest/no-icons",
|
|
60
|
+
"middleware.webAppManifest: icons array is required (W3C spec — at least one icon for installability)");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build the JSON body once at create.
|
|
64
|
+
var manifest = {};
|
|
65
|
+
var keys = Object.keys(opts).filter(function (k) {
|
|
66
|
+
return k !== "alsoAtJsonPath" && k !== "audit";
|
|
67
|
+
});
|
|
68
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
69
|
+
var k = keys[i];
|
|
70
|
+
if (opts[k] !== undefined && opts[k] !== null) manifest[k] = opts[k];
|
|
71
|
+
}
|
|
72
|
+
var body = safeJson.stringify(manifest, { space: 2 });
|
|
73
|
+
var bodyBuf = Buffer.from(body, "utf8");
|
|
74
|
+
var alsoAtJsonPath = opts.alsoAtJsonPath === true;
|
|
75
|
+
var auditOn = opts.audit !== false;
|
|
76
|
+
|
|
77
|
+
return function webAppManifestMiddleware(req, res, next) {
|
|
78
|
+
var url = req.url || "";
|
|
79
|
+
var qIdx = url.indexOf("?");
|
|
80
|
+
var path = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
81
|
+
var matches = (path === "/manifest.webmanifest") ||
|
|
82
|
+
(alsoAtJsonPath && path === "/manifest.json");
|
|
83
|
+
if (!matches) return next();
|
|
84
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
85
|
+
var bodyMsg = "Method Not Allowed";
|
|
86
|
+
res.writeHead(405, { // allow:raw-byte-literal — HTTP 405 status
|
|
87
|
+
"Allow": "GET, HEAD",
|
|
88
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
89
|
+
"Content-Length": Buffer.byteLength(bodyMsg),
|
|
90
|
+
});
|
|
91
|
+
res.end(bodyMsg);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
res.writeHead(200, { // allow:raw-byte-literal — HTTP 200 status
|
|
95
|
+
"Content-Type": "application/manifest+json",
|
|
96
|
+
"Content-Length": bodyBuf.length,
|
|
97
|
+
"Cache-Control": "public, max-age=86400",
|
|
98
|
+
"X-Content-Type-Options": "nosniff",
|
|
99
|
+
});
|
|
100
|
+
if (req.method === "HEAD") { res.end(); return; }
|
|
101
|
+
res.end(bodyBuf);
|
|
102
|
+
if (auditOn) {
|
|
103
|
+
try { observability().safeEvent("middleware.webAppManifest.served", 1, { path: path }); }
|
|
104
|
+
catch (_e) { /* obs best-effort */ }
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
create: create,
|
|
111
|
+
};
|
package/lib/parsers/safe-ini.js
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
var C = require("../constants");
|
|
39
39
|
var numericBounds = require("../numeric-bounds");
|
|
40
|
+
var safeBuffer = require("../safe-buffer");
|
|
40
41
|
var { defineClass } = require("../framework-error");
|
|
41
42
|
|
|
42
43
|
var IniSafeError = defineClass("IniSafeError", { alwaysPermanent: true });
|
|
@@ -176,7 +177,7 @@ function _parseSectionHeader(line) {
|
|
|
176
177
|
if (parts[i].length === 0) {
|
|
177
178
|
throw _err("ini/bad-section", "section name has empty segment: " + JSON.stringify(inner));
|
|
178
179
|
}
|
|
179
|
-
if (
|
|
180
|
+
if (!safeBuffer.BASE64URL_RE.test(parts[i])) {
|
|
180
181
|
throw _err("ini/bad-section",
|
|
181
182
|
"section segment must match [A-Za-z0-9_-]+ (got " + JSON.stringify(parts[i]) + ")");
|
|
182
183
|
}
|
package/lib/safe-buffer.js
CHANGED
|
@@ -190,6 +190,12 @@ function secureZero(buf) {
|
|
|
190
190
|
// itself does NOT bound length — that's the caller's contract.
|
|
191
191
|
var HEX_RE = /^[0-9a-fA-F]+$/;
|
|
192
192
|
|
|
193
|
+
// BASE64URL_RE matches a non-empty base64url-encoded string (RFC 4648
|
|
194
|
+
// §5) with NO padding. Used by JOSE primitives (JWT/JWS/JWE compact
|
|
195
|
+
// serialisations), DPoP jti, WebAuthn credential IDs, etc. The regex
|
|
196
|
+
// is length-agnostic — callers cap length per protocol contract.
|
|
197
|
+
var BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
198
|
+
|
|
193
199
|
// CRLF_RE matches any control character used in HTTP-header / SMTP-
|
|
194
200
|
// envelope injection attacks. Header values that contain CR or LF must
|
|
195
201
|
// be rejected before serialization.
|
|
@@ -231,6 +237,7 @@ module.exports = {
|
|
|
231
237
|
stripCrlf: stripCrlf,
|
|
232
238
|
stripTrailingHspace: stripTrailingHspace,
|
|
233
239
|
HEX_RE: HEX_RE,
|
|
240
|
+
BASE64URL_RE: BASE64URL_RE,
|
|
234
241
|
CRLF_RE: CRLF_RE,
|
|
235
242
|
TRAILING_HSPACE_RE: TRAILING_HSPACE_RE,
|
|
236
243
|
SafeBufferError: SafeBufferError,
|
package/lib/webhook.js
CHANGED
|
@@ -214,7 +214,7 @@ function _pqcSign(privateKeyPem, data) {
|
|
|
214
214
|
return crypto.sign(data, privateKeyPem).toString("base64url");
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
var _BASE64URL_RE =
|
|
217
|
+
var _BASE64URL_RE = safeBuffer.BASE64URL_RE;
|
|
218
218
|
|
|
219
219
|
function _pqcVerify(publicKeyPem, data, expectedSig) {
|
|
220
220
|
if (typeof expectedSig !== "string" || expectedSig.length === 0) return false;
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:bdcc6997-3839-40c9-b595-95e5fcf57410",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-06T07:34:42.532Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.7.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.7.89",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.89",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.7.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.7.89",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.7.
|
|
57
|
+
"ref": "@blamejs/core@0.7.89",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|