@blamejs/blamejs-shop 0.5.6 → 0.5.7

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.5.x
10
10
 
11
+ - v0.5.7 (2026-07-03) — **Storefront redirect targets route through the framework open-redirect guard, closing a TAB-injection open redirect on return_to / redirect_to.** Closes an open-redirect (CWE-601) in the storefront's post-action redirects. The cookie-consent submit, currency and locale switchers, and the wishlist and compare toggles validated their return_to / redirect_to target with a hand-rolled leading-slash regex that accepted a horizontal TAB (0x09) at the second byte. A user agent strips ASCII TAB, CR, and LF from a URL before resolving it, so a target like /<TAB>/evil.example collapsed to the protocol-relative //evil.example and navigated the shopper off-origin under the shop's trust — a phishing bounce. Every request-derived redirect target now routes through the framework's b.safeRedirect.resolve primitive (exposed once as a shared same-origin helper), which rejects every control character including TAB, protocol-relative //host and \\host forms, and full URLs — only genuine same-origin relative paths survive; anything else falls back to a safe default. The two guards that already rejected control characters are consolidated onto the same primitive so there is one redirect-safety path to reason about, and a codebase check refuses any future hand-rolled leading-slash redirect guard. No operator action and no configuration change. **Security:** *Open-redirect on storefront return_to / redirect_to closed* — The post-action redirect guards (cookie consent, currency switch, locale switch, wishlist toggle, compare toggle, announcement dismiss) accepted a leading-slash target whose second byte was a control character. A horizontal TAB there is stripped by the browser before URL parsing, turning /<TAB>/evil.example into the protocol-relative //evil.example — an off-origin redirect an attacker could use to launder a phishing link through the shop's origin. All these targets now resolve through b.safeRedirect.resolve, which rejects control characters (TAB, CR, LF, NUL), protocol-relative and backslash forms, and off-origin absolute URLs; an invalid target falls back to a same-origin default. A codebase-patterns check prevents a hand-rolled leading-slash redirect guard from reappearing.
12
+
11
13
  - v0.5.6 (2026-07-03) — **Minimum Node is now 24.18.0; the vendored framework refresh brings a broad batch of request-path and mail-authentication security hardening.** Refreshes the vendored framework to 0.16.2 and raises the minimum supported Node.js to 24.18.0 — operators must run Node 24.18.0 or newer before upgrading. The floor moves with the framework, which requires 24.18.0 for its bundled SQLite 3.53.1, a backup-path fix, and the native OpenSSL 3.5 SLH-DSA surface. The refresh carries a large batch of security and correctness fixes that harden the request lifecycle the storefront and account portal already run on, at no code cost to the shop: the router refuses an encoded path separator or null byte and decodes the request path once, so a path-scoped security gate and the resource it guards can no longer disagree; res.redirect rejects a horizontal TAB that user agents strip before URL parsing, closing an open-redirect bypass; OAuth refresh-token and step-up replay gates treat any truthy store result as seen and bind grants to the authenticated principal; SAML Single-Logout fails closed when a verification key arrives without its algorithm; the mail-authentication builders (Authentication-Results, delivery-status, ARC) refuse CR/LF/NUL header injection and SPF evaluation fails closed on a malformed ip4 CIDR; file uploads enforce per-upload ownership and reject path-traversal ids; recursive parsers refuse pathologically deep input instead of overflowing the stack; and shared-cache counters (lockout, byte quotas, rate caps) accumulate with an atomic compare-and-set so distributed requests cannot lose increments. Every Node-version pin — engines, .nvmrc, the container base image, CI, and the docs — is synced to the new floor from the framework's own engines constraint. **Changed:** *Minimum Node.js raised to 24.18.0* — engines.node is now >=24.18.0 (was >=24.16.0). Operators must run Node 24.18.0 or newer. The floor is dictated by the vendored framework, which requires 24.18.0 for its bundled SQLite 3.53.1, a database backup-path fix, and the native OpenSSL 3.5 SLH-DSA sign/verify surface. The .nvmrc, the container base image, the CI runner pins, and the README/architecture docs are all synced to the new floor. · *Vendored framework refreshed to 0.16.2* — The framework the shop builds on is refreshed from 0.15.38 to 0.16.2. Beyond the request-path and mail hardening in this release's summary, the refresh includes: the router rejects percent-encoded path separators so a security middleware and the route it guards agree on the path; ARC and DKIM verification reject forged or body-length-truncated signatures; OCSP validation binds the response to the certificate's issuer; status-list verification binds the token type against replay; and OAuth authorization-URL builders refuse operator parameters that collide with framework-managed ones. These land in the request lifecycle the storefront and account portal already compose, with no shop code change required.
12
14
 
13
15
  - v0.5.5 (2026-06-27) — **Customers can change their subscription plan — proration-aware upgrades and downgrades from the account portal.** Adds a self-serve plan-change surface to the customer subscription portal. From /account/subscriptions a customer can switch an active subscription to any other plan in the same currency: the page previews the proration for each candidate plan and offers two timings. An immediate change applies now and prorates the rest of the current period — an upgrade is charged the prorated difference (recorded on the subscription invoice ledger), and a downgrade is issued the unused remainder as store credit. A next-billing-cycle change is queued and applied automatically when the period rolls over, with the new plan starting clean and no proration. The transition is exactly-once: the immediate path claims the plan switch atomically before recording the charge or credit, and the scheduled path claims each due change with a single guarded update, so a retry or an overlapping scheduler run cannot double-charge or double-credit. A downgrade that owes a credit is never applied without paying it — if the credit write fails the change rolls back and retries. The surface mounts automatically when subscriptions and store credit are composed (the default for a configured storefront); the scheduler runs on the existing cron, and the tables it uses already shipped, so there are no schema changes and no operator action is required. **Added:** *Self-serve subscription plan changes with proration* — Customers can change the plan on an active subscription from /account/subscriptions/:id/change. The page lists every plan in the subscription's currency (excluding the current and archived plans) with a proration preview, and offers an immediate switch or a switch at the next billing date. An immediate upgrade charges the prorated difference for the rest of the period and records it on the subscription invoice ledger; an immediate downgrade issues the unused remainder of the outgoing plan as store credit. A still-pending change is shown on the subscription with a one-click cancel. Cross-currency, same-plan, and archived-plan requests are refused server-side, and the surface enforces subscription ownership before any read or write. · *Scheduler applies next-billing-cycle changes automatically* — A change queued for the next billing cycle is applied without operator involvement: a minute-cadence tick flips every due change to its new plan, records the proration, and issues any owed credit. Each due change is claimed with a single guarded update so an overlapping or retried run cannot apply it twice, and a change that owes a credit is left queued rather than applied if no credit can be issued — the credit is never dropped. The scheduler is inert on a deploy without subscriptions or store credit composed. **Changed:** *Dunning step failures are logged with their cause* — When a dunning step (reminder send, retry-charge record, pause, or cancel) fails, the schedule still advances and the step is recorded as failed — but the underlying error is now written to the server log instead of being discarded, so an operator can see why a step failed rather than only that it did.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.6",
2
+ "version": "0.5.7",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/storefront.js CHANGED
@@ -59,6 +59,25 @@ var _localeAls = new AsyncLocalStorage();
59
59
  // the client only ever sees a generic message + the correlating id.
60
60
  var _log = b.log.create({});
61
61
 
62
+ // Same-origin redirect-target guard shared by every post-action redirect
63
+ // (return_to / redirect_to / next). Delegates to the framework open-redirect
64
+ // (CWE-601) primitive rather than a hand-rolled path regex: it rejects
65
+ // protocol-relative targets ("//host", "\\host"), refuses full URLs (no
66
+ // base/allowlist is passed, so only same-origin relatives survive), and
67
+ // rejects EVERY control char including a horizontal TAB. A hand-rolled
68
+ // `/^\/[^/]/` guard misses the TAB: a user agent strips ASCII TAB/CR/LF from
69
+ // a URL before resolving it, so a `Location: /\t/evil.example` collapses to
70
+ // the protocol-relative `//evil.example` and navigates off-origin. Returns
71
+ // the safe same-origin path (query/fragment preserved) or `fallback` — pass
72
+ // "" as the fallback to use it as an accepted/rejected sentinel.
73
+ function _sameOriginRedirect(raw, fallback) {
74
+ var fb = typeof fallback === "string" ? fallback : "/";
75
+ // A pathological over-length target (header bloat) falls back before the
76
+ // primitive even parses it.
77
+ if (typeof raw === "string" && raw.length > 2048) return fb;
78
+ return b.safeRedirect.resolve(raw, { fallback: fb });
79
+ }
80
+
62
81
  // Generic 500 for an auth/ceremony route: log the real error server-side
63
82
  // (correlated by the framework request id) and return a fixed message to
64
83
  // the client so no internal error string (stack frame, DB column, vault
@@ -12485,10 +12504,7 @@ function mount(router, deps) {
12485
12504
  router.post("/announcements/:slug/dismiss", async function (req, res) {
12486
12505
  var slug = (req.params && typeof req.params.slug === "string") ? req.params.slug : "";
12487
12506
  var body = req.body || {};
12488
- var to = (typeof body.return_to === "string") ? body.return_to : "/";
12489
- if (to.charAt(0) !== "/" || to.charAt(1) === "/" || to.indexOf("\\") !== -1 || /[\x00-\x1f\x7f]/.test(to)) {
12490
- to = "/";
12491
- }
12507
+ var to = _sameOriginRedirect(body.return_to, "/");
12492
12508
  if (/^[a-z0-9-]{1,64}$/.test(slug)) {
12493
12509
  // Durable record (best-effort — keyed on the cart session id; absent
12494
12510
  // a session the cookie still carries the dismissal for this browser).
@@ -13125,13 +13141,9 @@ function mount(router, deps) {
13125
13141
  // setting a cookie, so a hostile link can't 500 the route.
13126
13142
  router.get("/locale", function (req, res) {
13127
13143
  var q = req.query || {};
13128
- var to = (typeof q.to === "string") ? q.to : "/";
13129
- // Same-origin path only: must start with a single "/" (not "//" — a
13130
- // protocol-relative URL and not a scheme). Anything else falls
13131
- // back to the home path.
13132
- if (to.charAt(0) !== "/" || to.charAt(1) === "/" || to.indexOf("\\") !== -1 || /[\x00-\x1f\x7f]/.test(to)) {
13133
- to = "/";
13134
- }
13144
+ // Same-origin path only (rejects protocol-relative "//", backslash, and
13145
+ // control chars including TAB); anything else falls back to home.
13146
+ var to = _sameOriginRedirect(q.to, "/");
13135
13147
  var lang = (typeof q.lang === "string") ? q.lang : "";
13136
13148
  if (lang && LOCALE_COOKIE_RE.test(lang) && lang.length <= LOCALE_COOKIE_MAX && supportedTags.indexOf(lang) !== -1) {
13137
13149
  _setLocaleCookie(res, lang);
@@ -13898,7 +13910,7 @@ function mount(router, deps) {
13898
13910
  // slash, no scheme / host / protocol-relative `//`) so the switcher
13899
13911
  // can't be turned into an open redirect.
13900
13912
  var rawTo = typeof body.redirect_to === "string" ? body.redirect_to : "/";
13901
- var to = (/^\/(?!\/)/.test(rawTo)) ? rawTo : "/";
13913
+ var to = _sameOriginRedirect(rawTo, "/");
13902
13914
  if (chosen === _ccyBase || _ccyOptions.indexOf(chosen) === -1) {
13903
13915
  _clearCurrencyCookie(res);
13904
13916
  } else {
@@ -14445,7 +14457,8 @@ function mount(router, deps) {
14445
14457
  // failure still lands the shopper somewhere sane.
14446
14458
  var rt = (req.body || {}).return_to;
14447
14459
  var dest = null;
14448
- if (typeof rt === "string" && /^\/[^/]/.test(rt)) dest = rt;
14460
+ var safeRt = _sameOriginRedirect(rt, "");
14461
+ if (safeRt) dest = safeRt;
14449
14462
 
14450
14463
  var notice;
14451
14464
  try {
@@ -17801,10 +17814,11 @@ function mount(router, deps) {
17801
17814
  var okKind = removed ? "removed" : "added";
17802
17815
  var rt = (req.body || {}).return_to;
17803
17816
  var dest;
17804
- if (typeof rt === "string" && /^\/[^/]/.test(rt)) {
17817
+ var safeRt = _sameOriginRedirect(rt, "");
17818
+ if (safeRt) {
17805
17819
  // Only thread the success marker when returning to the wishlist
17806
17820
  // page (the PDP heart toggle is its own visible cue).
17807
- dest = rt.indexOf("/account/wishlist") === 0 ? "/account/wishlist?ok=" + okKind : rt;
17821
+ dest = safeRt.indexOf("/account/wishlist") === 0 ? "/account/wishlist?ok=" + okKind : safeRt;
17808
17822
  } else {
17809
17823
  var product = null;
17810
17824
  try { product = await deps.catalog.products.get(productId); } catch (_e) { product = null; }
@@ -22131,8 +22145,7 @@ function mount(router, deps) {
22131
22145
  // refused), capping length defensively. Anything else collapses to the
22132
22146
  // safe default the caller passes.
22133
22147
  function _consentReturnTo(raw, fallback) {
22134
- if (typeof raw === "string" && raw.length <= 512 && /^\/[^/]/.test(raw)) return raw;
22135
- return fallback;
22148
+ return _sameOriginRedirect(raw, fallback);
22136
22149
  }
22137
22150
 
22138
22151
  // Translate a posted choice + per-category checkboxes into the four
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
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": {