@blamejs/core 0.17.6 → 0.17.8
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/compliance-ai-act-transparency.js +19 -4
- package/lib/metrics.js +15 -1
- package/lib/middleware/csp-report.js +40 -8
- package/lib/queue-local.js +8 -1
- package/lib/queue-redis.js +5 -0
- package/lib/scheduler.js +8 -1
- package/package.json +1 -1
- package/sbom.cdx.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.17.x
|
|
10
10
|
|
|
11
|
+
- v0.17.8 (2026-07-17) — **The AI-Act transparency HTML emitters now escape every interpolated value, closing a reflected-XSS through a banner's language attribute and a script-context breakout through the JSON-LD disclosure.** b.compliance.aiAct.transparency.htmlBanner rendered its lang value -- a free-form string that is typically a request locale, Accept-Language, or query parameter when the banner is server-rendered -- into a double-quoted HTML attribute by raw concatenation, so a lang containing a double quote broke out of the attribute and injected active content (reflected XSS). Only the element text was escaped before; the attribute values were not. And b.compliance.aiAct.transparency.jsonLdDisclosure embedded the watermark manifest (operator-supplied strings such as the model id and deployer name) inside a <script type="application/ld+json"> element with raw JSON.stringify, which does not escape </script>; the HTML parser ends a script element at the first </script> regardless of its type, so a manifest value carrying that sequence terminated the block early and injected markup. Both emitters now escape at the sink -- every attribute value through the HTML-entity escaper and the JSON-LD payload through the script-safe serializer -- so no interpolated value can break out of its context. **Security:** *AI-Act transparency banner escapes its attribute values* — b.compliance.aiAct.transparency.htmlBanner builds a status banner whose lang attribute carries a free-form language value -- in a server-rendered banner that value commonly comes from the request (a locale, an Accept-Language header, or a query parameter). The banner escaped its visible text but concatenated the lang, article, and kind values into their double-quoted HTML attributes raw, so a lang value containing a double quote closed the attribute early and let the remainder inject an element or event handler into the page (reflected cross-site scripting, CWE-79). Every attribute value is now passed through the HTML-entity escaper before interpolation, matching the escaping the banner text already had, so a hostile value is rendered as inert text inside the attribute rather than breaking out of it; the escape-at-the-sink handling also covers the article and kind values even though their range is currently constrained. · *AI-Act JSON-LD disclosure cannot break out of its script element* — b.compliance.aiAct.transparency.jsonLdDisclosure emits the watermark manifest as JSON-LD inside a <script type="application/ld+json"> element, serializing operator-supplied manifest strings (the model id, deployer name, prompt hash, and similar). It used raw JSON.stringify, which does not escape the sequence </script>; because an HTML parser terminates a script element at the first </script> regardless of the element's type, a manifest value containing </script> (or an HTML comment opener) ended the disclosure block early and injected arbitrary markup that the browser then parsed and could execute. The disclosure now serializes the manifest with the framework's script-safe serializer, which escapes <, >, & and the U+2028/U+2029 separators to their \uXXXX form so the parsed JSON is unchanged but no substring can break out of the script context.
|
|
12
|
+
|
|
13
|
+
- v0.17.7 (2026-07-17) — **A histogram exemplar's label name can no longer forge a line into the metrics scrape, the CSP report endpoint bounds how many reports one request can carry, and a cron field of the form N/step now fires on the whole repeating series instead of once.** A histogram exemplar's label VALUES were scrubbed before rendering but its label NAMES were written verbatim into the OpenMetrics exposition, and a Prometheus label name -- unlike a value -- cannot be quoted or escaped, so a name containing a newline forged an entire metric line into every /metrics scrape (the label-name sibling of the exemplar value injection already closed). The CSP report endpoint processed an unbounded number of reports per request: a single unauthenticated POST within the body-size cap could pack well over a thousand tiny reports, each driving a full audit-chain append and report hook, an amplification vector now bounded by a per-request report cap. And the shared cron parser mis-read a field of the form N/step -- e.g. 5/15 -- as the single value N instead of the standard N, N+step, ... through the field maximum, so a job scheduled that way fired once per period instead of on the intended repeating series; a recurring queue job also silently dropped its configured retry limit when it re-enqueued the next occurrence. **Fixed:** *A cron field of the form N/step fires on the full repeating series* — The shared cron parser read a field of the form N/step -- a bare number followed by a step, such as 5/15 in the minute field -- as the single value N, dropping the step entirely, instead of the standard Vixie-cron meaning N, N+step, ... up to the field maximum (5/15 in minutes is 5, 20, 35, 50, the same way */15 is 0, 15, 30, 45). A schedule written that way therefore fired once per period instead of on the intended repeating series -- and a job that runs less often than intended (a rotation, a cleanup, a scan) is a silent operational-safety gap. The parser now anchors the range at the field maximum whenever a step is present, so N/step expands to the full series; a bare number with no step is still the single value N. The cron-recurring queue backends (local and Redis) parse through this shared code and inherit the fix. · *A recurring queue job keeps its configured retry limit across occurrences* — A cron-recurring queue job re-enqueues itself for its next firing time and carried its priority, classification, and trace id forward -- but silently dropped the operator's configured maxAttempts, so every occurrence after the first reverted to the enqueue default retry budget rather than the one the operator set. Both queue backends now carry maxAttempts forward (guarded to a positive finite value, falling back to the enqueue default only when it is unset), so a recurring job's retry limit is stable across every occurrence. **Security:** *Metrics exemplar label names cannot inject a line into the scrape* — b.metrics histogram exemplars carry their own label set, stored through the same redaction step the regular labels use -- but that step scrubbed only the label VALUES (for credential shapes) and passed every label NAME through verbatim to the shared exposition renderer. A regular label name is validated against the Prometheus name grammar at registration and refused if undeclared, but the exemplar path had no equivalent gate, and because a label name cannot be quoted or escaped in the OpenMetrics wire format, an exemplar label name containing a newline (or a quote or brace) forged a complete, attacker-shaped metric line into every /metrics scrape -- reachable wherever an operator routes request-derived data into an exemplar label name (CWE-93). The exemplar redaction step now drops any label name that is not a valid Prometheus label name (length-bounded so a hostile oversized name cannot itself become a denial of service), matching the gate regular labels already get; valid names such as trace_id are unaffected. This closes the label-name sibling of the exemplar value injection fixed earlier. · *CSP report endpoint bounds the number of reports per request* — b.middleware.cspReport accepts a batch of reports in one POST (the Reporting API delivers them batched). The body-size cap bounded the request bytes but not the number of reports inside it, so a single unauthenticated request could carry well over a thousand small report objects, and the handler drove a full audit-chain append (a hash, a seal, and a serialized database insert) plus an operator report hook for every one of them -- a per-request amplification denial-of-service against an endpoint that is public by design. The endpoint now caps the batch length at a configurable maxReports (default 100, generous for a real browser batch): an over-cap batch is refused with 413 and the documented too-many-reports rejection reason, processing none of its reports, so the amplification is bounded while a normal browser report is unaffected.
|
|
14
|
+
|
|
11
15
|
- v0.17.6 (2026-07-17) — **PDF disarm now refuses JavaScript, launch actions, and polyglots even when an operator opt says allow, a renewed cluster lease no longer stretches its own expiry into the future, and a credential-issuance proof is refused when its replay nonce is absent.** b.guardPdf.sanitize is documented to strip a PDF down to inert content and to refuse -- under every profile -- the JavaScript, launch-action, and polyglot classes it cannot safely neutralize. But its forced-reject override pinned only the exfiltration and encryption policies, omitting the JavaScript, launch-action, and polyglot policies; those default to reject, so the gap was invisible until an operator passed an explicit permissive opt, which then let sanitize hand back a live PDF still carrying JavaScript or a launch action. b.clusterProviderDb.renewLease computed a lease's time-to-live as the span between its acquire time and its expiry, but on renewal advanced only the expiry and left the acquire time frozen, so each renewal re-derived an ever-larger TTL and pushed the expiry unboundedly into the future -- a dead leader's lease then never lapsed and no follower could take over. And b.auth.oid4vci's issuer skipped the credential-proof replay/holder-binding nonce check when the expected nonce was null, the miss sentinel that a Redis-, Map-, or SQL-backed nonce store commonly returns, so a forged proof could mint a credential bound to an attacker-chosen key. **Security:** *PDF disarm refuses active content even against a permissive operator opt* — b.guardPdf.sanitize is the disarm-by-refusal primitive: it guarantees it never returns a PDF that still carries JavaScript, a launch/open action, an embedded file, or encryption, and that the JavaScript, launch-action, and polyglot classes are refused under every profile. To hold that guarantee regardless of the operator's configuration, sanitize builds a forced override that pins the relevant policies to reject -- but the override pinned only the embedded-file, open-action, magic, and encryption policies and omitted the JavaScript, launch-action, and polyglot policies. Because those three already default to reject in every shipped profile, the omission was invisible in normal use; an operator who passed an explicit permissive opt (javascriptPolicy, launchActionPolicy, or polyglotPolicy set to allow or audit) turned that opt back on inside sanitize and received a live PDF still carrying JavaScript or a launch action -- exactly the remote-code-execution and polyglot classes the primitive promises to refuse. The forced override now pins all three, so sanitize refuses them unconditionally; the overridable validate and gate entry points, which document these policies as operator-tunable, are unchanged. · *Cluster lease renewal keeps a bounded expiry so a dead leader can be taken over* — b.clusterProviderDb models a leader lease as a sliding window whose span -- expiry minus acquire time -- equals the configured lease TTL. renewLease recovered the TTL from that span but then advanced only the expiry while leaving the acquire time frozen at the original acquisition, so the next renewal measured a span that had grown by one renewal interval and re-derived an ever-larger TTL, pushing the expiry unboundedly into the future. A leader that renewed even a few times and then died left a lease whose expiry was far beyond the configured TTL, so it never lapsed within the takeover window and no follower could steal it -- the cluster could stall with no active leader. renewLease now slides both ends of the window forward on every renewal (acquire time and expiry both move to now and now-plus-TTL), keeping the span at the configured TTL so a lapsed leader's lease expires on schedule and bounded takeover works. · *Credential-issuance proof is refused when its replay nonce is absent* — b.auth.oid4vci's issuer verifies the wallet's key-binding proof against the c_nonce it minted with the access token -- the challenge that binds the proof to this issuance and prevents replay. The verifier treated three states of the expected nonce differently: a string was compared, an undefined value (the miss sentinel b.cache returns) was refused, and a null value was treated as no check required. But a nonce store fronting Redis, a Map, or a SQL row -- all accepted through the documented store option -- commonly signals a miss with null, which fell straight through the comparison and disabled the replay and holder-binding defense entirely: after the short-lived c_nonce expired (while the access token was still valid, in batch issuance), an attacker holding that token could submit a proof signed by an arbitrary key with any nonce and have a credential minted bound to that attacker-controlled key. The verifier now requires the expected nonce to be a non-empty string and fails closed on any other value, so an absent or expired nonce is refused regardless of the store's miss sentinel.
|
|
12
16
|
|
|
13
17
|
- v0.17.5 (2026-07-17) — **A tampered or over-filtered backup can no longer silently wipe the live data directory on restore, DNSSEC-strict resolution stays enforced when a stale answer is served, and a family of guards that matched a name against a lookup table now reject a prototype-member name instead of misclassifying it.** Restoring a backup whose manifest lists zero files -- because a tampered unsigned bundle stripped every entry, or an opts.filter matched nothing -- extracted an empty staging directory and swapped it over the live data directory: a silent, full data-directory wipe reported to the operator as a successful restore. The manifest validator now refuses an empty file list, and the restore path refuses to swap a zero-file extract over a non-empty data directory. Separately, b.network.dns.resolver's validate: true DNSSEC gate was skipped on the serve-stale path, so an upstream outage could downgrade a DNSSEC-strict lookup (including DANE TLSA resolution) to unauthenticated stale data; the gate now holds on the stale path too. And a group of guards resolved a host, scheme, tag, method, or agent name against a plain-object lookup table with a truthiness read, so a name colliding with an inherited object member (constructor) was misclassified -- over-rejected as reserved/forbidden, or, for the mail-server method catalogue, passed the catalogue gate unregistered. All now match against the table's own keys. **Security:** *Restore refuses a zero-file backup instead of wiping the data directory* — b.backupManifest.validate accepted a manifest with an empty files array. The bundle writer never emits one, but the validator is the single guard the restore path parses an untrusted manifest through, so a tampered unsigned bundle that stripped every file entry (while keeping the still-valid wrapped vault key, which needs no passphrase knowledge) parsed cleanly, extracted an empty staging directory, and had the atomic swap move that empty directory over the live data directory -- a silent, destructive wipe of every file, reported to the operator as a successful restore with a file count of zero. The validator now refuses an empty file list (fixing every consumer -- parse, create, serialize -- at once), so a tampered manifest is rejected before extraction. As defense in depth, b.restore.run now also refuses to swap a zero-file extract over a non-empty data directory, which additionally catches an opts.filter that matched no manifest entry; the swap is refused with the data directory left intact rather than destroyed. · *DNSSEC-strict resolution stays enforced on the serve-stale path* — b.network.dns.resolver's validate: true option refuses a response that is not DNSSEC-authenticated (AD=0), but the RFC 8767 serve-stale short-circuit returned a cached stale answer before that gate ran. An attacker who can force an upstream outage (denying the DoH endpoint) could therefore downgrade a DNSSEC-strict lookup -- including b.network.dns.resolver.queryTlsa for DANE, queryDs, or a CNAME chase -- to unauthenticated stale data cached earlier at AD=0, with the caller seeing a success rather than the documented validate-failed refusal. The AD-bit gate now applies on the serve-stale path too (the DNSSEC verdict is per-response), so a stale answer that was not authenticated is refused rather than served to a validating caller. · *Name-lookup guards reject a prototype-member name instead of misclassifying it* — A group of guards tested whether a caller- or peer-supplied name was in a fixed table -- a reserved-HELO-name set, a reserved-local-host set, a dangerous-URL-scheme denylist, a reserved agent-name set, a Tiny-PS forbidden-element set, an HTML void-element set, and the mail-server method catalogue -- with a plain-object truthiness read (TABLE[name]) that walks the prototype chain. A name that collides with an inherited object member -- constructor is the one that survives a lowercased key -- read the inherited value as truthy and was misclassified: over-rejected as reserved or forbidden (b.mail.helo, b.guardListUnsubscribe, b.guardAgentRegistry, b.mailBimi, b.htmlBalance), or, for the mail-server method catalogue, passed the catalogue gate as if it were a registered method (a fail-open at registration). Every site now matches against the table's own keys with an own-property check, so a prototype-member name is treated as any other unknown name; supported names are unaffected.
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
var validateOpts = require("./validate-opts");
|
|
44
44
|
var markupEscape = require("./markup-escape").markupEscape;
|
|
45
|
+
var safeJson = require("./safe-json");
|
|
45
46
|
var { ComplianceError } = require("./framework-error");
|
|
46
47
|
|
|
47
48
|
var BANNER_KINDS = Object.freeze([
|
|
@@ -112,10 +113,17 @@ function _articleFor(kind) {
|
|
|
112
113
|
|
|
113
114
|
function htmlBanner(opts) {
|
|
114
115
|
var b = banner(opts);
|
|
116
|
+
// Every interpolated value is entity-escaped for the double-quoted attribute
|
|
117
|
+
// context. `lang` is free-form (a request locale / Accept-Language / query
|
|
118
|
+
// param when the banner is server-rendered), so an unescaped double quote
|
|
119
|
+
// would break out of the attribute and inject active content (reflected
|
|
120
|
+
// XSS). `article` / `kind` are constrained today, but escape-at-the-sink is
|
|
121
|
+
// the invariant — a raw concat is the defect regardless of the current
|
|
122
|
+
// value domain.
|
|
115
123
|
var attrs =
|
|
116
|
-
'role="status" data-blamejs-aiAct="' + b.article +
|
|
117
|
-
'" data-blamejs-kind="' + b.kind +
|
|
118
|
-
'" lang="' + b.lang + '"';
|
|
124
|
+
'role="status" data-blamejs-aiAct="' + _escapeHtml(b.article) +
|
|
125
|
+
'" data-blamejs-kind="' + _escapeHtml(b.kind) +
|
|
126
|
+
'" lang="' + _escapeHtml(b.lang) + '"';
|
|
119
127
|
return '<div ' + attrs + '>' + _escapeHtml(b.text) + '</div>';
|
|
120
128
|
}
|
|
121
129
|
|
|
@@ -159,9 +167,16 @@ function watermark(opts) {
|
|
|
159
167
|
|
|
160
168
|
function jsonLdDisclosure(opts) {
|
|
161
169
|
var w = watermark(opts);
|
|
170
|
+
// The manifest carries operator-supplied strings (modelId, deployerName,
|
|
171
|
+
// promptHash, ...). Embedding raw JSON.stringify inside a <script> element
|
|
172
|
+
// lets a value containing "</script>" (or "<!--") terminate the block and
|
|
173
|
+
// inject markup — the HTML parser ends the element at the first "</script>"
|
|
174
|
+
// regardless of the ld+json type. b.safeJson.stringifyForScript escapes
|
|
175
|
+
// < > & (and U+2028/U+2029) to \uXXXX so the parsed JSON is unchanged but no
|
|
176
|
+
// substring can break out of the script context.
|
|
162
177
|
var script = '<script type="application/ld+json" ' +
|
|
163
178
|
'data-blamejs-aiAct="Art. 50(2)">' +
|
|
164
|
-
|
|
179
|
+
safeJson.stringifyForScript(w) + '</script>';
|
|
165
180
|
return script;
|
|
166
181
|
}
|
|
167
182
|
|
package/lib/metrics.js
CHANGED
|
@@ -223,7 +223,21 @@ function _redactLabelMap(labelObj) {
|
|
|
223
223
|
var out = {};
|
|
224
224
|
if (!labelObj || typeof labelObj !== "object") return out;
|
|
225
225
|
var keys = Object.keys(labelObj);
|
|
226
|
-
for (var i = 0; i < keys.length; i++)
|
|
226
|
+
for (var i = 0; i < keys.length; i++) {
|
|
227
|
+
var k = keys[i];
|
|
228
|
+
// Exemplar label NAMES are rendered verbatim into the OpenMetrics
|
|
229
|
+
// exposition by _renderLabels — and a label name (unlike a value) cannot
|
|
230
|
+
// be quoted or escaped in the Prometheus wire format. Regular label keys
|
|
231
|
+
// are gated (LABEL_NAME_RE at registration + _resolveLabels' undeclared
|
|
232
|
+
// refusal), but the exemplar path had no such gate, so a key carrying a
|
|
233
|
+
// newline / quote / brace forged a metric line into every scrape (CWE-93,
|
|
234
|
+
// the exemplar-KEY sibling of the already-fixed exemplar-VALUE injection).
|
|
235
|
+
// Drop any key that isn't a valid Prometheus label name; the length bound
|
|
236
|
+
// keeps a hostile multi-megabyte key from turning the regex test into a
|
|
237
|
+
// DoS, exactly as _validateLabelName caps the config-time path.
|
|
238
|
+
if (k.length > MAX_METRIC_NAME_LEN || !LABEL_NAME_RE.test(k)) continue;
|
|
239
|
+
out[k] = _validateLabelValue(labelObj[k]);
|
|
240
|
+
}
|
|
227
241
|
return out;
|
|
228
242
|
}
|
|
229
243
|
|
|
@@ -39,6 +39,15 @@ var audit = lazyRequire(function () { return require("../audit"); });
|
|
|
39
39
|
|
|
40
40
|
var DEFAULT_MAX_BYTES = C.BYTES.kib(64);
|
|
41
41
|
var SAMPLE_TRUNCATE = 200;
|
|
42
|
+
// A single POST may carry a batch of reports (Reporting API). The byte
|
|
43
|
+
// cap bounds the body, but NOT the number of reports inside it — a
|
|
44
|
+
// 64 KiB body of ~40-byte report objects is ~1600 reports, and each one
|
|
45
|
+
// drives a full audit-chain append (SHA3 hash + seal + DB insert) plus an
|
|
46
|
+
// onReport hook. On an unauthenticated endpoint that is a per-request
|
|
47
|
+
// amplification vector, so the batch length is capped independently. A
|
|
48
|
+
// real browser batch is a handful; 100 is generous headroom. Operators
|
|
49
|
+
// raise or lower it via opts.maxReports.
|
|
50
|
+
var DEFAULT_MAX_REPORTS = 100;
|
|
42
51
|
|
|
43
52
|
function _truncate(value) {
|
|
44
53
|
if (typeof value !== "string") return value;
|
|
@@ -108,16 +117,25 @@ function _normalizeOne(reportLike) {
|
|
|
108
117
|
* alerting use as `onReport`: a flood of 413s signals a misconfigured
|
|
109
118
|
* `Reporting-Endpoints` URL or a report-bomb. It receives
|
|
110
119
|
* `(req, res, { status, reason })` where `reason` is one of
|
|
111
|
-
* `method-not-allowed` / `payload-too-large` / `
|
|
112
|
-
* after the rejection response is written; a
|
|
113
|
-
* so a broken metrics sink can't crash the
|
|
120
|
+
* `method-not-allowed` / `payload-too-large` / `too-many-reports` /
|
|
121
|
+
* `invalid-json`. Invoked after the rejection response is written; a
|
|
122
|
+
* throwing hook is swallowed so a broken metrics sink can't crash the
|
|
123
|
+
* endpoint.
|
|
124
|
+
*
|
|
125
|
+
* A single POST may batch multiple reports; the batch length is capped
|
|
126
|
+
* at `maxReports` (default 100) independently of the byte cap so an
|
|
127
|
+
* unauthenticated caller can't force one audit-chain append + `onReport`
|
|
128
|
+
* hook per entry across a body packed with thousands of tiny reports. A
|
|
129
|
+
* batch over the cap is refused whole with HTTP 413 + `onReject`
|
|
130
|
+
* `too-many-reports`.
|
|
114
131
|
*
|
|
115
132
|
* @opts
|
|
116
133
|
* {
|
|
117
|
-
* onReport:
|
|
118
|
-
* onReject:
|
|
119
|
-
* maxBytes:
|
|
120
|
-
*
|
|
134
|
+
* onReport: function(report): void,
|
|
135
|
+
* onReject: function(req, res, { status, reason }): void,
|
|
136
|
+
* maxBytes: number, // default 64 KiB
|
|
137
|
+
* maxReports: number, // default 100 — batch length cap
|
|
138
|
+
* audit: boolean, // default true
|
|
121
139
|
* }
|
|
122
140
|
*
|
|
123
141
|
* @example
|
|
@@ -132,14 +150,17 @@ function _normalizeOne(reportLike) {
|
|
|
132
150
|
*/
|
|
133
151
|
function create(opts) {
|
|
134
152
|
opts = opts || {};
|
|
135
|
-
validateOpts(opts, ["audit", "onReport", "onReject", "maxBytes"], "middleware.cspReport");
|
|
153
|
+
validateOpts(opts, ["audit", "onReport", "onReject", "maxBytes", "maxReports"], "middleware.cspReport");
|
|
136
154
|
if (opts.onReject !== undefined && opts.onReject !== null &&
|
|
137
155
|
typeof opts.onReject !== "function") {
|
|
138
156
|
throw new TypeError("middleware.cspReport: opts.onReject must be a function");
|
|
139
157
|
}
|
|
140
158
|
validateOpts.optionalPositiveInt(opts.maxBytes, "middleware.cspReport: maxBytes");
|
|
159
|
+
validateOpts.optionalPositiveInt(opts.maxReports, "middleware.cspReport: maxReports");
|
|
141
160
|
var maxBytes = (opts.maxBytes === undefined || opts.maxBytes === null)
|
|
142
161
|
? DEFAULT_MAX_BYTES : opts.maxBytes;
|
|
162
|
+
var maxReports = (opts.maxReports === undefined || opts.maxReports === null)
|
|
163
|
+
? DEFAULT_MAX_REPORTS : opts.maxReports;
|
|
143
164
|
var auditOn = opts.audit !== false;
|
|
144
165
|
var onReport = (typeof opts.onReport === "function") ? opts.onReport : null;
|
|
145
166
|
var onReject = (typeof opts.onReject === "function") ? opts.onReject : null;
|
|
@@ -177,6 +198,17 @@ function create(opts) {
|
|
|
177
198
|
return;
|
|
178
199
|
}
|
|
179
200
|
var reports = Array.isArray(parsed) ? parsed : [parsed];
|
|
201
|
+
// Fail closed on a report-bomb: a batch whose length exceeds maxReports
|
|
202
|
+
// is refused whole (413) rather than driving one audit-chain append +
|
|
203
|
+
// onReport hook per entry. This bounds the per-request work an
|
|
204
|
+
// unauthenticated caller can force. A throwing/absent audit or hook is
|
|
205
|
+
// unaffected — nothing is processed on this path.
|
|
206
|
+
if (reports.length > maxReports) {
|
|
207
|
+
res.writeHead(413); // HTTP 413 status
|
|
208
|
+
res.end();
|
|
209
|
+
_emitReject(req, res, 413, "too-many-reports");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
180
212
|
for (var i = 0; i < reports.length; i++) {
|
|
181
213
|
var normalized = _normalizeOne(reports[i]);
|
|
182
214
|
if (!normalized) continue;
|
package/lib/queue-local.js
CHANGED
|
@@ -467,7 +467,8 @@ function create(config) {
|
|
|
467
467
|
// dispatches both calls to the same backend.
|
|
468
468
|
var rowBuilt = _select()
|
|
469
469
|
.columns(["_id", "queueName", "payload", "repeatCron", "repeatTimezone",
|
|
470
|
-
"flowId", "flowChildName", "priority", "classification", "traceId"
|
|
470
|
+
"flowId", "flowChildName", "priority", "classification", "traceId",
|
|
471
|
+
"maxAttempts"])
|
|
471
472
|
.where("_id", jobId)
|
|
472
473
|
.toSql();
|
|
473
474
|
var rowRes = await store.execute(rowBuilt.sql, rowBuilt.params);
|
|
@@ -503,6 +504,11 @@ function create(config) {
|
|
|
503
504
|
var unsealedRow = cryptoField.unsealRow(SEAL_TABLE, row);
|
|
504
505
|
var cron = scheduler.parseCron(unsealedRow.repeatCron);
|
|
505
506
|
var nextMs = scheduler.nextCronFire(cron, new Date(nowMs), unsealedRow.repeatTimezone || null);
|
|
507
|
+
// Carry the operator's maxAttempts forward: the next occurrence must
|
|
508
|
+
// keep the same retry budget the operator configured, not silently
|
|
509
|
+
// reset to the enqueue default. A non-positive / non-finite value
|
|
510
|
+
// falls back to undefined so enqueue applies its own default.
|
|
511
|
+
var repeatMax = Number(unsealedRow.maxAttempts);
|
|
506
512
|
await enqueue(unsealedRow.queueName,
|
|
507
513
|
unsealedRow.payload ? safeJson.parse(unsealedRow.payload, { maxBytes: C.BYTES.mib(64) }) : null,
|
|
508
514
|
{
|
|
@@ -514,6 +520,7 @@ function create(config) {
|
|
|
514
520
|
availableAt: nextMs,
|
|
515
521
|
repeat: { cron: unsealedRow.repeatCron, timezone: unsealedRow.repeatTimezone },
|
|
516
522
|
priority: Number(unsealedRow.priority) || 0,
|
|
523
|
+
maxAttempts: (isFinite(repeatMax) && repeatMax > 0) ? repeatMax : undefined,
|
|
517
524
|
classification: unsealedRow.classification || null,
|
|
518
525
|
traceId: unsealedRow.traceId || null,
|
|
519
526
|
});
|
package/lib/queue-redis.js
CHANGED
|
@@ -532,12 +532,17 @@ function create(opts) {
|
|
|
532
532
|
var cron = scheduler.parseCron(unsealed.repeatCron);
|
|
533
533
|
var nextMs = scheduler.nextCronFire(
|
|
534
534
|
cron, new Date(nowMs), unsealed.repeatTimezone || null);
|
|
535
|
+
// Carry the operator's maxAttempts forward so the next occurrence keeps
|
|
536
|
+
// the configured retry budget instead of silently resetting to the
|
|
537
|
+
// enqueue default (mirrors queue-local's cron-repeat).
|
|
538
|
+
var repeatMax = Number(unsealed.maxAttempts);
|
|
535
539
|
await enqueue(unsealed.queueName,
|
|
536
540
|
unsealed.payload ? safeJson.parse(unsealed.payload) : null,
|
|
537
541
|
{
|
|
538
542
|
availableAt: nextMs,
|
|
539
543
|
repeat: { cron: unsealed.repeatCron, timezone: unsealed.repeatTimezone },
|
|
540
544
|
priority: Number(unsealed.priority) || 0,
|
|
545
|
+
maxAttempts: (isFinite(repeatMax) && repeatMax > 0) ? repeatMax : undefined,
|
|
541
546
|
classification: unsealed.classification || null,
|
|
542
547
|
traceId: unsealed.traceId || null,
|
|
543
548
|
});
|
package/lib/scheduler.js
CHANGED
|
@@ -135,7 +135,14 @@ function _parseCronField(text, range) {
|
|
|
135
135
|
hi = parseInt(seg[1], 10);
|
|
136
136
|
} else {
|
|
137
137
|
lo = parseInt(part, 10);
|
|
138
|
-
|
|
138
|
+
// A bare number followed by a step (`N/step`) is the Vixie-cron
|
|
139
|
+
// shorthand for `N-max/step` (e.g. `5/15` in minutes = 5,20,35,50,
|
|
140
|
+
// matching crontab.guru and `*/15` = 0,15,30,45). Without a step a
|
|
141
|
+
// bare number is the single value {N}. Anchoring `hi` at range.max
|
|
142
|
+
// when a step is present is what turns the step into a repeat; keeping
|
|
143
|
+
// hi = lo silently collapsed the schedule to a single fire — the same
|
|
144
|
+
// silent-under-firing class rejected for over-range steps above.
|
|
145
|
+
hi = (stepIdx !== -1) ? range.max : lo;
|
|
139
146
|
}
|
|
140
147
|
if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo > hi ||
|
|
141
148
|
lo < range.min || hi > range.max) {
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:628c839a-3725-4405-b9d5-cd446477675a",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-17T19:11:48.442Z",
|
|
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.17.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.17.8",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.17.
|
|
25
|
+
"version": "0.17.8",
|
|
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.17.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.17.8",
|
|
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.17.
|
|
57
|
+
"ref": "@blamejs/core@0.17.8",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|