@blamejs/blamejs-shop 0.5.9 → 0.5.11
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/asset-manifest.json +1 -1
- package/lib/customer-import.js +31 -8
- package/lib/product-import.js +22 -7
- package/lib/referrals.js +6 -0
- package/lib/site-redirects.js +77 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.5.x
|
|
10
10
|
|
|
11
|
+
- v0.5.11 (2026-07-03) — **A second customer or product import started while one is already running is refused instead of clobbering the first run's cancel handle.** Fixes a small concurrency defect in the customer and product CSV/row import runners. Each runner tracks a single in-flight run so the operator console's cancel action knows which run to stop. The run opener set that handle after writing the run row, so two runs started at nearly the same moment — an operator double-submitting the form or opening two tabs — could both open, and the second would overwrite the first's in-flight handle. A subsequent cancel would then target the wrong run, leaving the other running. The opener now reserves the single in-flight slot synchronously, before any await, so a second run is refused with a clear "an import is already in progress" error while the first proceeds; the reservation is released if the run fails to open, and the console can start a fresh import as soon as the running one finishes. **Fixed:** *Overlapping import runs no longer clobber the in-flight run handle* — The customer and product import runners keep one in-flight run handle so cancel targets the right run. Because the opener set that handle only after inserting the run row, two imports started concurrently could both open and the second would overwrite the first's handle, so a later cancel stopped the wrong run. The opener now claims the in-flight slot synchronously (before the first await, so the check-and-set can't interleave) and refuses a second concurrent run with an "import already in progress" error; the slot is released if the open fails, and a new import can start once the running one completes.
|
|
12
|
+
|
|
13
|
+
- v0.5.10 (2026-07-03) — **Redirect rules with a regex source are screened for nested-quantifier catastrophic backtracking before they run on the request path.** Closes the last catastrophic-backtracking (ReDoS) gap in operator-authored redirect rules. A site-redirect of kind "regex" compiles the operator's source into a pattern that resolveForPath runs against the request path on every request. The define-time screen already refused backreferences and lookahead/lookbehind, but not a nested unbounded quantifier — a group whose body carries an unbounded quantifier that is itself repeated by one, such as (a+)+, (a*)*, (?:a+)+ or (.+)+ — so an operator could register a rule whose match time is super-linear in the path length, turning every matching request into a denial of service. The screen now also refuses that shape at define time, at any group depth, while continuing to accept linear patterns that merely use a quantified non-capturing group ((?:/page/[0-9]+)?, (?:section)*) or an optional quantified group. The match is exact — only a nested UNBOUNDED quantifier is refused; a single quantifier, an optional group, and bounded repetition are all allowed — so no well-formed redirect regex that worked before is rejected. **Security:** *Redirect regex sources refuse nested unbounded quantifiers at define time* — A redirect rule of kind "regex" runs its compiled pattern against the request path in resolveForPath. The define-time screen rejected backreferences and lookaround but not a nested unbounded quantifier ((a+)+, (a*)*, (?:a+)+, (.+)+, ((ab)+)+ at any depth) — the canonical ReDoS shape — so an operator could land a rule whose match is super-linear in the path length, letting a crafted request path stall the redirect resolver. The screen now refuses that shape when the rule is defined, with an error pointing the operator at a prefix/exact rule or splitting into multiple rows. Linear patterns are unaffected: a quantified non-capturing group, an optional quantified group, a single quantifier, and bounded repetition are all still accepted, so no redirect regex that worked before is rejected.
|
|
14
|
+
|
|
11
15
|
- v0.5.9 (2026-07-03) — **A federated sign-in no longer auto-merges into a pre-existing account whose email was never proven, closing an account pre-hijacking path.** Closes an account pre-hijacking weakness in federated (Google / Apple) sign-in. When a verified OIDC sign-in found a pre-existing account with the same email, it linked the federated identity into that account on the strength of the incoming email being verified alone — without requiring the pre-existing account to have ever proven control of that email. Because a passkey (or otherwise passwordless) account is created with whatever email address was typed at registration and no ownership check, an attacker could register an account under a victim's email before the victim's first federated sign-in, and the victim's later Google/Apple sign-in would then be folded into the attacker's account — pulling the victim's guest orders in and leaving the attacker's credential enrolled. The merge now requires the pre-existing account to already carry a verified federated identity (the only account-creation path that proves the email); an account whose email was never proven falls through to the existing duplicate-email refusal, so the customer signs in with their existing method and links the provider from account settings. A first federated sign-in still creates a fresh account, and a second provider still links to an account whose email is already proven, so legitimate multi-provider sign-in is unchanged. **Security:** *Federated sign-in refuses to merge into an unverified pre-existing account* — A verified Google/Apple sign-in that matched a pre-existing account by email hash previously linked the federated identity into that account whenever the incoming identity-provider email was verified — even if the pre-existing account had never proven control of the email. A passkey/passwordless account stores the email typed at registration with no verification, so an attacker could pre-seed an account under a victim's email and capture the victim's later federated sign-in (account pre-hijacking, CWE-287). The merge now proceeds only when the pre-existing account already holds a verified federated identity — the one account-creation path that proves the email. An unverified match is refused with the existing duplicate-email conflict, directing the customer to sign in with their existing method and link the provider from account settings. First-time federated sign-in (new account) and second-provider linking into an already-proven account are unchanged.
|
|
12
16
|
|
|
13
17
|
- v0.5.8 (2026-07-03) — **Inventory cost-layer consumption and subscription plan-change execution are now correct under concurrent requests; vendored framework refreshed to 0.16.3.** Two ledger-atomicity fixes for concurrent requests, plus a vendored framework security refresh. Cost-layer (COGS) consumption debited each inventory layer with an unguarded read-modify-write against a snapshot, so two overlapping sales or write-offs of the same SKU could both draw from the same layer — driving a layer's remaining quantity negative, stranding a later layer, and understating cost of goods sold. Each layer debit is now an atomic guarded claim (the decrement only applies while the layer still holds the planned quantity); a losing claim reverts its own partial work and re-plans against a fresh snapshot, and the cost attribution rows are written only after every layer in the plan is claimed, so the combined consumption is exact regardless of interleaving. Separately, an immediate subscription plan change wrote its history row as executed before claiming the plan transition, so a double-submitted or retried change that lost the transition claim left a phantom executed row — carrying charge and credit amounts even though no plan moved, no invoice was recorded, and no credit was issued. The losing change now voids that row, so subscription history and any revenue rollup over executed changes reflect only changes that actually happened. The vendored framework is refreshed to 0.16.3, which redacts credential-shaped values in the rendered /metrics exposition (they were masked only in the internal cardinality key), closes a SQL-guard fragment-mode bypass for PostgreSQL dollar-quoted strings, and fails closed on MCP tool input whose JSON Schema omits an explicit object type — all inherited without shop code changes. **Changed:** *Vendored framework refreshed to 0.16.3* — Refreshes the framework the shop builds on from 0.16.2 to 0.16.3, a security and robustness patch across the observability, SQL-guard, MCP, YAML-parser, content-credentials, and resumable-upload primitives. Most consequential: a credential-shaped metric label value was redacted only in the internal cardinality key, not in the rendered exposition, so a raw token on a label leaked to every /metrics scrape — redaction now covers the rendered labels; and the SQL guard's raw-fragment escape hatch now rejects a PostgreSQL dollar-quoted string and a lone statement terminator, closing a bypass of the placeholder-binding floor. The minimum Node.js engine is unchanged at 24.18.0. **Fixed:** *Concurrent cost-layer consumption no longer double-debits a layer* — FIFO, LIFO, and weighted-average COGS consumption planned each layer debit from a snapshot and then applied an unconditional decrement, so two concurrent consumptions of the same SKU could both debit the same front layer — driving its remaining quantity negative and understating COGS while a later layer was left stranded. Each debit is now an atomic guarded claim that only applies while the layer still holds the planned quantity; a losing claim reverts the attempt's earlier claims and re-plans against a re-read of the active layers, within a bounded retry. Cost attribution rows are persisted only after every layer in the plan is claimed, so a mid-plan contention loss leaves no orphaned rows and the summed consumption and attributed cost are exact. · *A lost immediate plan-change claim voids its row instead of leaving a phantom executed change* — An immediate subscription plan change inserted its history row as executed and then claimed the plan transition with a conditional update. A double-submit or retry could have two changes both insert an executed row while only one wins the transition; the loser previously returned leaving a phantom executed change with a charge and credit amount even though no plan moved, no proration invoice was recorded, and no store credit was issued. The losing change now voids its row (cancelled, reason recorded), matching how the same path already handles a failed processor push, so history and any rollup over executed changes count only changes that actually settled.
|
package/lib/asset-manifest.json
CHANGED
package/lib/customer-import.js
CHANGED
|
@@ -182,16 +182,32 @@ function _addError(errors, maxErrors, rowIndex, message) {
|
|
|
182
182
|
// ---- run-row persistence -----------------------------------------------
|
|
183
183
|
|
|
184
184
|
async function _openRun(query, state, source, inputByteCount) {
|
|
185
|
+
// Reserve the single in-flight slot SYNCHRONOUSLY — before the first await —
|
|
186
|
+
// so two concurrent runs (an operator double-submitting) can't both open. A
|
|
187
|
+
// second run is refused rather than silently overwriting the first's inflight
|
|
188
|
+
// handle, which would leave `cancelInflight` targeting the wrong run. The
|
|
189
|
+
// check + set have no await between them, so the reservation is race-free in
|
|
190
|
+
// the single-threaded event loop.
|
|
191
|
+
if (state.inflight) {
|
|
192
|
+
var busy = new Error("customer-import: an import is already in progress");
|
|
193
|
+
busy.code = "IMPORT_IN_PROGRESS";
|
|
194
|
+
throw busy;
|
|
195
|
+
}
|
|
185
196
|
var runId = b.uuid.v7();
|
|
186
197
|
var startedAt = Date.now();
|
|
187
|
-
await query(
|
|
188
|
-
"INSERT INTO customer_imports " +
|
|
189
|
-
"(id, started_at, completed_at, status, source, input_byte_count, " +
|
|
190
|
-
" rows_processed, rows_created, rows_updated, rows_skipped, rows_errored, errors_json) " +
|
|
191
|
-
"VALUES (?1, ?2, NULL, 'running', ?3, ?4, 0, 0, 0, 0, 0, '[]')",
|
|
192
|
-
[runId, startedAt, source, inputByteCount],
|
|
193
|
-
);
|
|
194
198
|
state.inflight = { runId: runId, cancelled: false };
|
|
199
|
+
try {
|
|
200
|
+
await query(
|
|
201
|
+
"INSERT INTO customer_imports " +
|
|
202
|
+
"(id, started_at, completed_at, status, source, input_byte_count, " +
|
|
203
|
+
" rows_processed, rows_created, rows_updated, rows_skipped, rows_errored, errors_json) " +
|
|
204
|
+
"VALUES (?1, ?2, NULL, 'running', ?3, ?4, 0, 0, 0, 0, 0, '[]')",
|
|
205
|
+
[runId, startedAt, source, inputByteCount],
|
|
206
|
+
);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
state.inflight = null; // release the reservation if the open failed
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
195
211
|
return { runId: runId, startedAt: startedAt };
|
|
196
212
|
}
|
|
197
213
|
|
|
@@ -205,7 +221,14 @@ async function _closeRun(query, state, runId, status, counters, errors) {
|
|
|
205
221
|
"SELECT status FROM customer_imports WHERE id = ?1",
|
|
206
222
|
[runId],
|
|
207
223
|
)).rows[0];
|
|
208
|
-
|
|
224
|
+
// Treat the run as cancelled if EITHER the row already says so OR the
|
|
225
|
+
// in-memory flag for THIS run is set. The flag path covers a cancel that
|
|
226
|
+
// landed in the reservation→INSERT window: `cancelInflight` flipped the flag
|
|
227
|
+
// and ran an UPDATE that matched no row yet (the run row wasn't inserted), so
|
|
228
|
+
// reading the DB status alone would finalize a cancelled run as complete.
|
|
229
|
+
var cancelled = (current && current.status === "cancelled") ||
|
|
230
|
+
!!(state.inflight && state.inflight.runId === runId && state.inflight.cancelled);
|
|
231
|
+
var finalStatus = cancelled ? "cancelled" : status;
|
|
209
232
|
var errorsJson = b.safeJson.stringify(errors, { canonical: true });
|
|
210
233
|
await query(
|
|
211
234
|
"UPDATE customer_imports SET " +
|
package/lib/product-import.js
CHANGED
|
@@ -327,20 +327,35 @@ async function _drive(query, catalog, state, input, driveOpts) {
|
|
|
327
327
|
// Per-row normalization errors land in the error list at the
|
|
328
328
|
// matching row_index; the row is skipped for catalog writes but
|
|
329
329
|
// still counted in `rows_processed`.
|
|
330
|
+
// Reserve the single in-flight slot SYNCHRONOUSLY — before the INSERT await —
|
|
331
|
+
// so two concurrent runs (an operator double-submitting) can't both open. A
|
|
332
|
+
// second run is refused rather than overwriting the first's inflight id,
|
|
333
|
+
// which would leave `cancelInflight` targeting the wrong import. The check +
|
|
334
|
+
// set have no await between them, so the reservation is race-free.
|
|
335
|
+
if (state.inflightId) {
|
|
336
|
+
var busy = new Error("product-import: an import is already in progress");
|
|
337
|
+
busy.code = "IMPORT_IN_PROGRESS";
|
|
338
|
+
throw busy;
|
|
339
|
+
}
|
|
330
340
|
var importId = b.uuid.v7();
|
|
331
341
|
var startedAt = Date.now();
|
|
332
342
|
var inputByteCount = driveOpts.inputByteCount == null ? 0 : driveOpts.inputByteCount;
|
|
343
|
+
state.inflightId = importId;
|
|
344
|
+
state.cancelFlag = false;
|
|
333
345
|
|
|
334
346
|
// Run row goes in at `running`. The driver updates the counters +
|
|
335
347
|
// status at the end. The error rows go in as they're encountered
|
|
336
348
|
// — operators tailing `errorsForImport` see progress mid-run.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
349
|
+
try {
|
|
350
|
+
await query(
|
|
351
|
+
"INSERT INTO product_imports (id, format, on_conflict, started_at, status, input_byte_count) " +
|
|
352
|
+
"VALUES (?1, ?2, ?3, ?4, 'running', ?5)",
|
|
353
|
+
[importId, format, onConflict, startedAt, inputByteCount],
|
|
354
|
+
);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
state.inflightId = null; // release the reservation if the open failed
|
|
357
|
+
throw e;
|
|
358
|
+
}
|
|
344
359
|
|
|
345
360
|
var counters = {
|
|
346
361
|
rows_processed: 0,
|
package/lib/referrals.js
CHANGED
|
@@ -161,6 +161,12 @@ function _alphabetRe(alphabet) {
|
|
|
161
161
|
// default alphabet has none, but an operator-supplied 32-char set
|
|
162
162
|
// could legally include `]` or `-`.
|
|
163
163
|
var escaped = alphabet.replace(/[\\\]^-]/g, "\\$&");
|
|
164
|
+
// A single anchored character class (`^[…]+$`) is structurally incapable of
|
|
165
|
+
// catastrophic backtracking — one unbounded quantifier over a class, no
|
|
166
|
+
// grouped repetition — so it needs no ReDoS screen. (The framework's
|
|
167
|
+
// b.guardRegex screen is deliberately not composed here: it accepts this
|
|
168
|
+
// shape, but its text scan false-positives on `(?:…)?` group forms, so
|
|
169
|
+
// relying on it is avoided until the upstream fix lands.)
|
|
164
170
|
return new RegExp("^[" + escaped + "]+$");
|
|
165
171
|
}
|
|
166
172
|
|
package/lib/site-redirects.js
CHANGED
|
@@ -298,14 +298,90 @@ function _compileRegex(source) {
|
|
|
298
298
|
// malformed source — re-shape that into the primitive's TypeError
|
|
299
299
|
// envelope so callers can match on the consistent error shape.
|
|
300
300
|
var anchored = "^(?:" + source + ")$";
|
|
301
|
+
var compiled;
|
|
301
302
|
try {
|
|
302
|
-
|
|
303
|
+
compiled = new RegExp(anchored);
|
|
303
304
|
} catch (e) {
|
|
304
305
|
throw new TypeError(
|
|
305
306
|
"siteRedirects: regex source did not compile — " +
|
|
306
307
|
(e && e.message ? e.message : "invalid pattern")
|
|
307
308
|
);
|
|
308
309
|
}
|
|
310
|
+
// Extend the backreference / lookaround screen above with the last
|
|
311
|
+
// catastrophic-backtracking class it doesn't cover: a nested unbounded
|
|
312
|
+
// quantifier ((a+)+, (a*)*, (?:a+)+, (.+)+ …). This regex runs against the
|
|
313
|
+
// request path in `resolveForPath`, so a super-linear pattern is a DoS on
|
|
314
|
+
// every matching request. (The framework's b.guardRegex screen is not used
|
|
315
|
+
// here because its text scan misreads the `?` in a `(?:…)` group prefix and
|
|
316
|
+
// a bounded trailing `?` as quantifiers, rejecting linear patterns like
|
|
317
|
+
// `/blog(?:/page/\d+)?` — filed upstream; this walker allows them.)
|
|
318
|
+
if (_hasNestedUnboundedQuantifier(source)) {
|
|
319
|
+
throw new TypeError(
|
|
320
|
+
"siteRedirects: regex source rejected — nested unbounded quantifier " +
|
|
321
|
+
"(catastrophic-backtracking / ReDoS) surface; rewrite as `prefix` / " +
|
|
322
|
+
"`exact`, or split into multiple rows"
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return compiled;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Detect the nested-unbounded-quantifier ReDoS class: a group whose body
|
|
329
|
+
// contains an unbounded quantifier (`+`, `*`, `{n,}`) and is itself repeated
|
|
330
|
+
// by an unbounded quantifier — `(a+)+`, `(a*)*`, `(?:a+)+`, `(.+)+`,
|
|
331
|
+
// `((ab)+)+` at any depth. A single optional / linearly-repeated group
|
|
332
|
+
// (`(?:foo)?`, `(?:bar)*`, `/products/.+`) is safe and MUST be allowed. The
|
|
333
|
+
// walk mirrors the backreference/lookaround walk: char-by-char, honoring
|
|
334
|
+
// escapes, character classes, and the `(?:` / `(?<name>` group prefixes whose
|
|
335
|
+
// `?` is group syntax, not a quantifier. Lookaround is already refused before
|
|
336
|
+
// this runs, so only `(?:` reaches here.
|
|
337
|
+
function _hasNestedUnboundedQuantifier(source) {
|
|
338
|
+
var stack = []; // open groups: { bodyUnbounded: bool }
|
|
339
|
+
var inClass = false;
|
|
340
|
+
var i = 0;
|
|
341
|
+
var n = source.length;
|
|
342
|
+
function outerUnboundedAt(idx) {
|
|
343
|
+
var c = source.charAt(idx);
|
|
344
|
+
if (c === "*" || c === "+") return true;
|
|
345
|
+
if (c === "{") { return /^\{\d*,\}/.test(source.slice(idx)); } // {n,} — no upper bound
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
while (i < n) {
|
|
349
|
+
var ch = source.charAt(i);
|
|
350
|
+
if (ch === "\\") { i += 2; continue; } // escaped atom
|
|
351
|
+
if (inClass) { if (ch === "]") { inClass = false; } i += 1; continue; }
|
|
352
|
+
if (ch === "[") { inClass = true; i += 1; continue; }
|
|
353
|
+
if (ch === "(") {
|
|
354
|
+
if (source.charAt(i + 1) === "?") { // group prefix — not a quantifier
|
|
355
|
+
i += 2;
|
|
356
|
+
if (source.charAt(i) === ":") { i += 1; }
|
|
357
|
+
else if (source.charAt(i) === "<") { while (i < n && source.charAt(i) !== ">") { i += 1; } if (i < n) { i += 1; } }
|
|
358
|
+
} else { i += 1; }
|
|
359
|
+
stack.push({ bodyUnbounded: false });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (ch === ")") {
|
|
363
|
+
var g = stack.pop() || { bodyUnbounded: false };
|
|
364
|
+
var outer = outerUnboundedAt(i + 1);
|
|
365
|
+
if (g.bodyUnbounded && outer) { return true; } // nested unbounded quantifier
|
|
366
|
+
// A group repeated by an unbounded quantifier is itself an unbounded
|
|
367
|
+
// atom for its parent.
|
|
368
|
+
if (outer && stack.length) { stack[stack.length - 1].bodyUnbounded = true; }
|
|
369
|
+
i += 1;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (ch === "*" || ch === "+") {
|
|
373
|
+
if (stack.length) { stack[stack.length - 1].bodyUnbounded = true; }
|
|
374
|
+
i += 1;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (ch === "{" && /^\{\d*,\}/.test(source.slice(i))) {
|
|
378
|
+
if (stack.length) { stack[stack.length - 1].bodyUnbounded = true; }
|
|
379
|
+
i += 1;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
i += 1;
|
|
383
|
+
}
|
|
384
|
+
return false;
|
|
309
385
|
}
|
|
310
386
|
|
|
311
387
|
// ---- row hydration ------------------------------------------------------
|
package/package.json
CHANGED