@blamejs/blamejs-shop 0.5.10 → 0.5.12

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 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.12 (2026-07-03) — **Operator password sign-in now has a brute-force lockout keyed on both the client IP and the target account.** Wires a brute-force lockout into the operator email+password sign-in (/admin/operators/signin), which previously relied only on the global per-IP rate limit and Argon2 verification. The sign-in now records each failed attempt and, before checking the password, refuses with 429 once the failure count for the request's IP or for the target operator account exceeds its threshold within a rolling 15-minute window. Keying on the account as well as the IP bounds a distributed guess — an attacker rotating IPs against one operator — that a per-IP limit alone can't see. The account threshold is deliberately higher than the per-IP threshold, so a legitimate operator is only ever account-locked under a genuine multi-IP attack, and the lock is a temporary cooldown that auto-expires with the window rather than a permanent lock an attacker could weaponize into a denial of service. The target account is resolved up front for the account key regardless of whether the email matches a real operator, and a lockout returns the same generic sign-in refusal, so no account-existence oracle is added. A successful sign-in clears the account's recent failures so an operator who mistyped isn't held under the window. The lockout composes the existing operator-session primitive and its already-deployed failed-login table — no schema change and no configuration; the failed-attempt log also feeds the operator security timeline. **Security:** *Brute-force lockout on the operator password sign-in* — The operator email+password sign-in had no per-account brute-force protection — only the global per-IP rate limit and Argon2 slowed guessing, so a distributed attack rotating IPs against a single operator account was unbounded. The sign-in now records each failed attempt and refuses with 429 (before verifying the password) once the failures for the request IP or the target account exceed their thresholds in a rolling 15-minute window. The account threshold is higher than the per-IP one, so a legitimate operator is only account-locked under a real multi-IP attack, and the lock auto-expires with the window (a cooldown, not a weaponizable permanent lock). The account is resolved whether or not the email is real and a lockout returns the same generic refusal, so there is no account-existence oracle; a successful sign-in clears the account's recent failures. Composes the existing operator-session primitive and its deployed failed-login table — no schema change, no configuration, and the failed-attempt log continues to feed the operator security timeline.
12
+
13
+ - 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.
14
+
11
15
  - 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.
12
16
 
13
17
  - 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.
package/SECURITY.md CHANGED
@@ -130,7 +130,12 @@ node -e "
130
130
  bootstrap / break-glass owner credential; once staff accounts exist,
131
131
  treat it like a recovery key — store it offline and use the
132
132
  per-operator credentials day to day. Every operator-management action
133
- and every role-denied attempt lands in the audit chain.
133
+ and every role-denied attempt lands in the audit chain. The password
134
+ sign-in throttles brute force: repeated failures from one IP, or a
135
+ distributed guess against one account, are refused with a temporary
136
+ lockout (keyed on both the IP and the account, higher threshold on the
137
+ account, auto-expiring on a rolling window) on top of the global
138
+ rate limit and Argon2id.
134
139
  - **Guest order pages are access-gated, not capability URLs.** A guest
135
140
  order's confirmation page (name, address, items) requires the placing
136
141
  browser's sealed device cookie, the signed `?k=` token carried by the
package/lib/admin.js CHANGED
@@ -927,6 +927,21 @@ function mount(router, deps) {
927
927
  // booted) chains every operator-management action + role-denied attempt.
928
928
  var operatorAccounts = deps.operatorAccounts || null;
929
929
  var operatorAuditLog = deps.operatorAuditLog || null;
930
+ // Optional brute-force lockout for the operator password sign-in. When wired,
931
+ // the sign-in records failed attempts and refuses once the per-IP or (higher)
932
+ // per-account failure threshold trips within the rolling window. Best-effort:
933
+ // absent this dep the sign-in behaves exactly as before (rate-limit + Argon2).
934
+ var operatorSessions = deps.operatorSessions || null;
935
+
936
+ // Hash of the request's client IP, domain-separated for the operator-login
937
+ // lockout so it can't be correlated with any other IP-keyed store. Returns
938
+ // null when the IP can't be resolved (the lockout then keys on the account
939
+ // only, or is skipped for an unresolved-account attempt).
940
+ function _clientIpHash(req) {
941
+ var ip = b.requestHelpers.clientIp(req);
942
+ if (!ip) return null;
943
+ return b.crypto.namespaceHash("operator-login-ip", String(ip));
944
+ }
930
945
 
931
946
  // Unified order-event feed for the order-detail screen. When wired, the
932
947
  // /admin/orders/:id page renders a chronological timeline aggregated
@@ -14373,12 +14388,53 @@ function mount(router, deps) {
14373
14388
  var body = req.body || {};
14374
14389
  var email = typeof body.email === "string" ? body.email : "";
14375
14390
  var pass = typeof body.password === "string" ? body.password : "";
14391
+
14392
+ // Brute-force lockout (best-effort — only when operatorSessions is
14393
+ // wired). Resolve the target account up front for the per-account key:
14394
+ // the lookup runs whether or not the email matches a real operator, and
14395
+ // the refusal is the SAME generic form the bad-credential path returns,
14396
+ // so it adds no account-existence oracle. The lockout keys on BOTH the
14397
+ // client IP and the target account so a distributed (IP-rotating) guess
14398
+ // against one operator is bounded, not only per-IP.
14399
+ var ipHash = null, targetId = null;
14400
+ if (operatorSessions) {
14401
+ try { ipHash = _clientIpHash(req); } catch (_e) { ipHash = null; }
14402
+ try {
14403
+ var pre = await operatorAccounts.getByEmail(email);
14404
+ targetId = (pre && pre.id) ? pre.id : null;
14405
+ } catch (_e) { targetId = null; }
14406
+ if (ipHash) {
14407
+ var lock = null;
14408
+ try { lock = await operatorSessions.lockoutCheck(ipHash, { operator_id: targetId }); }
14409
+ catch (_e) { lock = null; }
14410
+ if (lock && lock.locked) {
14411
+ if (res.setHeader) res.setHeader("retry-after", String(lock.window_seconds || 900));
14412
+ return _sendHtml(res, 429, renderOperatorSignin({ shop_name: deps.shop_name, error: true, locked: true }));
14413
+ }
14414
+ }
14415
+ }
14416
+
14376
14417
  var account = null;
14377
14418
  try { account = await operatorAccounts.verifyPassword({ email: email, password: pass }); }
14378
14419
  catch (_e) { account = null; }
14379
14420
  if (!account) {
14421
+ // Record the failed attempt for the lockout (drop-silent — the lockout
14422
+ // is best-effort; a logging failure must not break the sign-in path).
14423
+ if (operatorSessions && ipHash) {
14424
+ try {
14425
+ await operatorSessions.recordFailedVerify({ ip_hash: ipHash, operator_id: targetId, reason: "bad-password" });
14426
+ } catch (_e) { /* drop-silent */ }
14427
+ }
14380
14428
  return _sendHtml(res, 401, renderOperatorSignin({ shop_name: deps.shop_name, error: true }));
14381
14429
  }
14430
+ // Successful sign-in clears THIS account's recent failures so a legit
14431
+ // operator who mistyped isn't held under the rolling window next time.
14432
+ // Scoped strictly to the signed-in account (never the IP) so signing in
14433
+ // can't erase another operator's failures logged from the same IP.
14434
+ if (operatorSessions) {
14435
+ try { await operatorSessions.clearFailures({ operator_id: account.id }); }
14436
+ catch (_e) { /* drop-silent */ }
14437
+ }
14382
14438
  try { _setAdminCookie(req, res, account); }
14383
14439
  catch (e) {
14384
14440
  if (e && e.code === "vault/not-initialized") {
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.10",
2
+ "version": "0.5.12",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -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
- var finalStatus = (current && current.status === "cancelled") ? "cancelled" : status;
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 " +
@@ -148,7 +148,14 @@ var MAX_UA_CLASS_LEN = 64;
148
148
  var MAX_IP_HASH_LEN = 256;
149
149
  var MIN_IP_HASH_LEN = 1;
150
150
  var DEFAULT_LOCKOUT_WINDOW = 15 * 60; // allow:raw-time-literal — lockout window stored in seconds; C.TIME returns ms (15-minute rolling window)
151
- var DEFAULT_LOCKOUT_THRESH = 5; // 5 failures trips lockout
151
+ var DEFAULT_LOCKOUT_THRESH = 5; // 5 failures (per IP) trips lockout
152
+ // Per-account threshold is deliberately HIGHER than the per-IP one: a
153
+ // distributed (IP-rotating) brute force against a single account is the thing
154
+ // the account key catches, and keeping it well above the per-IP threshold
155
+ // means a legitimate operator is only ever account-locked under a genuine
156
+ // multi-IP attack — and only for the rolling window (a temporary cooldown, not
157
+ // a permanent lock an attacker could weaponize into a denial of service).
158
+ var DEFAULT_ACCOUNT_LOCKOUT_THRESH = 20;
152
159
  var MAX_LOCKOUT_WINDOW = 24 * 60 * 60; // allow:raw-time-literal — lockout window stored in seconds; C.TIME returns ms (sanity cap — one day)
153
160
  var MIN_LOCKOUT_THRESHOLD = 1;
154
161
  var MAX_LOCKOUT_THRESHOLD = 1000;
@@ -647,14 +654,70 @@ function create(opts) {
647
654
  [ip, since],
648
655
  );
649
656
  var count = Number((r.rows[0] && r.rows[0].n) || 0);
657
+
658
+ // Optional per-account keying. The per-IP count above can't see a
659
+ // distributed brute force (one attacker rotating IPs against one
660
+ // account); counting failures for the target operator across ALL IPs
661
+ // in the same window catches it. The account threshold is higher (see
662
+ // DEFAULT_ACCOUNT_LOCKOUT_THRESH) so a legitimate operator isn't locked
663
+ // by a handful of their own mistypes, only by a genuine multi-IP attack.
664
+ var accountThreshold = _lockoutThreshold(
665
+ lopts.account_threshold == null ? DEFAULT_ACCOUNT_LOCKOUT_THRESH : lopts.account_threshold);
666
+ var acctCount = 0;
667
+ if (lopts.operator_id != null) {
668
+ var oid = _uuid(lopts.operator_id, "operator_id");
669
+ var ar = await query(
670
+ "SELECT COUNT(*) AS n FROM operator_failed_logins " +
671
+ "WHERE operator_id = ?1 AND occurred_at >= ?2",
672
+ [oid, since],
673
+ );
674
+ acctCount = Number((ar.rows[0] && ar.rows[0].n) || 0);
675
+ }
676
+
677
+ var lockedByIp = count >= threshold;
678
+ var lockedByAccount = lopts.operator_id != null && acctCount >= accountThreshold;
650
679
  return {
651
- locked: count >= threshold,
652
- count: count,
653
- threshold: threshold,
654
- window_seconds: windowSec,
680
+ locked: lockedByIp || lockedByAccount,
681
+ locked_by: lockedByIp ? "ip" : (lockedByAccount ? "account" : null),
682
+ count: count,
683
+ threshold: threshold,
684
+ account_count: acctCount,
685
+ account_threshold: accountThreshold,
686
+ window_seconds: windowSec,
655
687
  };
656
688
  },
657
689
 
690
+ // ---- clearFailures --------------------------------------------------
691
+ //
692
+ // Delete the recent failed-attempt rows for the signed-in ACCOUNT after a
693
+ // SUCCESSFUL sign-in, so a legitimate operator who mistyped a few times
694
+ // isn't held under the rolling lockout window on the next attempt.
695
+ //
696
+ // Scoped to `operator_id` — which is REQUIRED. An optional `ip_hash` only
697
+ // NARROWS the deletion to that IP (AND), never widens it. This matters:
698
+ // clearing by IP (or account-OR-IP) would wipe OTHER operators' failed
699
+ // rows from the same IP, so a shared-IP attacker could brute-force a
700
+ // victim, sign in as themselves, and reset the victim's lockout. Clearing
701
+ // strictly the caller's own account can't touch another account's rows.
702
+ // Returns the row count cleared; a no-op when `operator_id` is absent.
703
+ clearFailures: async function (input) {
704
+ input = input || {};
705
+ if (typeof input !== "object") {
706
+ throw new TypeError("operator-sessions.clearFailures: input must be an object");
707
+ }
708
+ if (input.operator_id == null) return { cleared: 0 };
709
+ var oid = _uuid(input.operator_id, "operator_id");
710
+ var params = [oid];
711
+ var sql = "DELETE FROM operator_failed_logins WHERE operator_id = ?1";
712
+ if (input.ip_hash != null) {
713
+ var ip = _requiredString(input.ip_hash, "ip_hash", MIN_IP_HASH_LEN, MAX_IP_HASH_LEN);
714
+ params.push(ip);
715
+ sql += " AND ip_hash = ?2";
716
+ }
717
+ var res = await query(sql, params);
718
+ return { cleared: Number((res && res.rowCount) || 0) };
719
+ },
720
+
658
721
  // ---- recordFailedVerify ---------------------------------------------
659
722
  //
660
723
  // Append-only event log. The login middleware calls this on every
@@ -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
- await query(
338
- "INSERT INTO product_imports (id, format, on_conflict, started_at, status, input_byte_count) " +
339
- "VALUES (?1, ?2, ?3, ?4, 'running', ?5)",
340
- [importId, format, onConflict, startedAt, inputByteCount],
341
- );
342
- state.inflightId = importId;
343
- state.cancelFlag = false;
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {