@blamejs/blamejs-shop 0.2.22 → 0.2.23
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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/index.js +1 -0
- package/lib/security-middleware.js +217 -0
- package/lib/storefront.js +9 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.2.x
|
|
10
10
|
|
|
11
|
+
- v0.2.23 (2026-05-29) — **Rate limiting and cross-site request isolation on the request lifecycle.** Two request-lifecycle defenses are now composed into every storefront and admin request. A per-client-IP rate limit backs the whole site (a generous global budget that a normal browsing and checkout session never trips) with tighter budgets on the abusable POST/auth endpoints — sign-in, passkey enrolment, checkout, gift-card balance lookup, account registration, newsletter, review/question submit, and survey response — so credential and passkey spraying, gift-card brute-forcing, and checkout flooding are shut down. Separately, a fetch-metadata gate refuses cross-site state-changing requests using the browser's Sec-Fetch headers, adding CSRF defense-in-depth on top of the SameSite session cookies. The client IP is read from the edge-forwarded header (the container sits behind the Worker), so limits apply per real visitor; the payment-webhook and health-probe paths are exempt. No change for normal use. **Added:** *Per-client rate limiting* — A generous global token-bucket limit per client IP backs the whole site, with tight fixed-window budgets on the abusable POST/auth endpoints (login, passkey register/add, checkout, gift-card balance, register, newsletter, review/question, survey). Keyed on the real client IP forwarded from the edge; payment-webhook and health-probe paths are exempt. The single-instance container uses an in-memory backend. · *Cross-site request isolation (fetch-metadata)* — Cross-site state-changing requests (the CSRF vector) are refused using the browser's Sec-Fetch-Site/Mode/Dest headers — same-origin requests and direct navigations pass; payment webhooks are exempt. This restores defense-in-depth alongside the SameSite session cookies.
|
|
12
|
+
|
|
11
13
|
- v0.2.22 (2026-05-29) — **See your full order history, track shipments, reorder, and request returns.** The customer order surface is now complete end-to-end. A new order-history list shows every order (cursor-paginated), and each order page gains a status timeline, a carrier tracking panel (tracking number + the carrier's public tracking link), a one-click Reorder that rebuilds a cart from the order, and a Request-a-return button for eligible orders — the same Reorder/Return actions also appear per row on the account dashboard and the history list. On the operator side, the admin order screen gets a shipment panel to attach a carrier + tracking number and record shipment events, and the returns console's Refund now issues the payment-provider refund first (then records the RMA) whenever the order has a captured charge, so a return can't be marked refunded while the customer is left un-refunded; manual/guest orders with no charge fall back to an explicit record-only step. **Added:** *Order history + tracking + reorder + returns (customer)* — `/account/orders` lists all your orders with pagination; each order page shows a status timeline and carrier tracking, plus Reorder (`POST /orders/:id/reorder` rebuilds a cart from the order's lines at current prices) and Request-a-return for eligible orders. The dashboard and history rows carry the same actions. · *Shipment + tracking panel (admin)* — The admin order detail screen can attach a shipment (carrier + tracking number) and record shipment events; marking delivered advances the order's lifecycle. Tracking the order surfaces the carrier's public tracking link to the customer. **Changed:** *Return refunds move the money* — The returns console Refund action now issues the payment-provider refund first and records the RMA only on success when the order has a captured charge, so an approved return is never marked refunded without the customer actually being refunded. Orders with no captured charge use an explicit record-only step that points to the order.
|
|
12
14
|
|
|
13
15
|
- v0.2.21 (2026-05-29) — **Related products ("You may also like") on the product page.** Product pages now show a related-products rail. The picks are the other products in the product's primary collection, ordered deterministically and capped at four, rendered with the same product-card markup the home and search grids use — identical on the edge and origin paths. The rail is hidden when a product has no related items. This surfaces existing catalog relationships to shoppers without any new data or configuration. **Added:** *Related-products rail on the PDP* — A "You may also like" section on each product page lists other active products from the same primary collection (deterministic order, up to four), using the standard product-card markup byte-identically across the edge and origin renders. Hidden when there are no related products.
|
package/lib/asset-manifest.json
CHANGED
package/lib/index.js
CHANGED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Request-lifecycle security wiring — composes the vendored blamejs
|
|
4
|
+
* middleware that defends the storefront + admin request paths into a
|
|
5
|
+
* single place both the production entry point (server.js) and the
|
|
6
|
+
* end-to-end harness (test/e2e/serve.js) wire identically.
|
|
7
|
+
*
|
|
8
|
+
* Three layers, all per-client-IP:
|
|
9
|
+
*
|
|
10
|
+
* 1. A generous GLOBAL rate limit (token bucket) — the backstop
|
|
11
|
+
* against credential / passkey spraying, gift-card balance
|
|
12
|
+
* brute-force, checkout hammering, and unauthenticated row-flood
|
|
13
|
+
* writes. Sized so a normal browsing + checkout session (page
|
|
14
|
+
* navigations + form POSTs + the cart-count island fetch) never
|
|
15
|
+
* trips it; only a flood does.
|
|
16
|
+
*
|
|
17
|
+
* 2. TIGHT per-route budgets on the abusable POST / auth endpoints
|
|
18
|
+
* (login, passkey register, checkout, gift-card balance lookup,
|
|
19
|
+
* account register, newsletter, review / question submit, survey
|
|
20
|
+
* response). A human does well under five of these a minute, so a
|
|
21
|
+
* ~10/min budget is invisible to real use and shuts spray down.
|
|
22
|
+
*
|
|
23
|
+
* 3. fetch-metadata (Sec-Fetch-Site / -Mode / -Dest) — refuses
|
|
24
|
+
* cross-site state-changing requests WITHOUT needing a per-form
|
|
25
|
+
* token, restoring CSRF defense-in-depth on top of the storefront's
|
|
26
|
+
* SameSite session cookies. The payment webhook routes are exempt
|
|
27
|
+
* (a payment processor's server-to-server POST is cross-site by
|
|
28
|
+
* nature and carries its own HMAC signature check).
|
|
29
|
+
*
|
|
30
|
+
* THE CLIENT-IP CONSTRAINT. In production the Node container sits BEHIND
|
|
31
|
+
* the Cloudflare Worker: every container request arrives via the
|
|
32
|
+
* Worker's forward, so the socket peer is the CF fabric, not the user.
|
|
33
|
+
* Cloudflare injects the real client address as `cf-connecting-ip`
|
|
34
|
+
* (with `x-real-ip` as a mirror); the Worker forwards both verbatim.
|
|
35
|
+
* Every limiter here therefore keys on that header, falling back to the
|
|
36
|
+
* socket address only for direct (non-proxied) connections such as the
|
|
37
|
+
* e2e harness and local dev. Keying on the socket would put every
|
|
38
|
+
* visitor in ONE bucket behind the fabric and let a single global limit
|
|
39
|
+
* throttle the whole store.
|
|
40
|
+
*
|
|
41
|
+
* The container runs as a single instance (max_instances=1), so the
|
|
42
|
+
* default in-memory rate-limit backend is correct — there is no second
|
|
43
|
+
* replica to coordinate a shared counter with, and the in-memory token
|
|
44
|
+
* bucket / fixed window need no SQL hop.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
var b = require("./vendor/blamejs");
|
|
48
|
+
|
|
49
|
+
var C = b.constants;
|
|
50
|
+
|
|
51
|
+
// Payment webhooks are server-to-server POSTs from Stripe / PayPal:
|
|
52
|
+
// cross-site by nature, unthrottleable by a per-IP human budget, and
|
|
53
|
+
// already authenticated by an HMAC signature the edge + container both
|
|
54
|
+
// verify. They are exempt from BOTH the rate limiters and fetch-metadata.
|
|
55
|
+
var WEBHOOK_PATHS = ["/api/webhooks/stripe", "/api/webhooks/paypal"];
|
|
56
|
+
|
|
57
|
+
// Liveness / readiness probe — the container's Docker HEALTHCHECK hits
|
|
58
|
+
// this on a fixed cadence; never rate-limit it or a slow cold start
|
|
59
|
+
// could wedge the health signal.
|
|
60
|
+
var HEALTH_PATH = "/_/health";
|
|
61
|
+
|
|
62
|
+
// The abusable endpoints — POST / auth surfaces where a human does
|
|
63
|
+
// well under five requests a minute. Each gets its own per-client-IP
|
|
64
|
+
// budget so a spray against one can't borrow another's headroom, and a
|
|
65
|
+
// legitimate burst on one (a shopper re-submitting a slow checkout)
|
|
66
|
+
// never eats the login budget. Entries are matched as path PREFIXES so
|
|
67
|
+
// the dynamic `:slug` / `:token` segments and the begin/finish passkey
|
|
68
|
+
// sub-paths are all covered by a single stem.
|
|
69
|
+
//
|
|
70
|
+
// Prefix coverage notes:
|
|
71
|
+
// /account/login -> /account/login, /account/login/google, ...
|
|
72
|
+
// /account/register -> /account/register AND /account/register-begin
|
|
73
|
+
// /account/passkey/ -> register-begin / register-finish / add-* begin+finish
|
|
74
|
+
// /products/ -> the review + question submit sub-paths (gated to POST below)
|
|
75
|
+
// /survey/ -> /survey/:token (GET form + POST submit)
|
|
76
|
+
var TIGHT_PREFIXES = [
|
|
77
|
+
"/account/login",
|
|
78
|
+
"/account/register",
|
|
79
|
+
"/account/passkey/",
|
|
80
|
+
"/checkout",
|
|
81
|
+
"/gift-cards/balance",
|
|
82
|
+
"/gift-cards",
|
|
83
|
+
"/newsletter",
|
|
84
|
+
"/products/",
|
|
85
|
+
"/survey/",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the real client IP for rate-limit keying. Reads the
|
|
90
|
+
* Cloudflare-injected `cf-connecting-ip` first (the canonical single
|
|
91
|
+
* client address behind the fabric), then `x-real-ip` (its mirror),
|
|
92
|
+
* then falls back to the socket address via `b.requestHelpers.clientIp`
|
|
93
|
+
* for direct connections (e2e harness, local dev). Always returns a
|
|
94
|
+
* non-empty string so two un-identifiable clients never collapse into
|
|
95
|
+
* the same bucket as a real IP — request-shape reader, returns a
|
|
96
|
+
* default, never throws.
|
|
97
|
+
*/
|
|
98
|
+
function clientKey(req) {
|
|
99
|
+
var headers = (req && req.headers) || {};
|
|
100
|
+
var cf = headers["cf-connecting-ip"];
|
|
101
|
+
if (typeof cf === "string" && cf.length > 0) return cf.trim();
|
|
102
|
+
var real = headers["x-real-ip"];
|
|
103
|
+
if (typeof real === "string" && real.length > 0) return real.trim();
|
|
104
|
+
var sock = b.requestHelpers.clientIp(req);
|
|
105
|
+
return sock || "unknown";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the GLOBAL rate-limit options for createApp's
|
|
110
|
+
* `middleware.rateLimit`. Token-bucket so a bursty-but-bounded browsing
|
|
111
|
+
* session is smoothed rather than clipped at a window edge.
|
|
112
|
+
*
|
|
113
|
+
* burst — the standing buffer a client may spend at once.
|
|
114
|
+
* refillPerSecond — the sustained per-second throughput.
|
|
115
|
+
*
|
|
116
|
+
* 300 burst + 5/s refill = a 300-request standing buffer that refills
|
|
117
|
+
* to full over a minute, i.e. a 300/min sustained ceiling per client
|
|
118
|
+
* IP. A very active human session (rapid navigation, the cart-count
|
|
119
|
+
* island firing once per page view, form POSTs) lands far under this;
|
|
120
|
+
* an unauthenticated spray flood blows straight through it. The webhook
|
|
121
|
+
* + health paths skip the limiter entirely.
|
|
122
|
+
*/
|
|
123
|
+
function globalRateLimitOpts() {
|
|
124
|
+
return {
|
|
125
|
+
backend: "memory",
|
|
126
|
+
algorithm: "token-bucket",
|
|
127
|
+
burst: 300,
|
|
128
|
+
refillPerSecond: 5,
|
|
129
|
+
keyFn: clientKey,
|
|
130
|
+
skipPaths: WEBHOOK_PATHS.concat([HEALTH_PATH]),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _hasPrefix(pathname, prefixes) {
|
|
135
|
+
for (var i = 0; i < prefixes.length; i += 1) {
|
|
136
|
+
if (pathname.indexOf(prefixes[i]) === 0) return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Mount the per-route tight rate limiters + the fetch-metadata gate on
|
|
143
|
+
* the router inside the operator's `routes(r)` chain. Call AFTER the
|
|
144
|
+
* webhook raw-body capture + bodyParser are mounted (so the gate reads
|
|
145
|
+
* a fully-shaped request) and BEFORE the storefront / admin routes.
|
|
146
|
+
*
|
|
147
|
+
* @param r the blamejs Router passed to createApp's routes(r) callback.
|
|
148
|
+
*/
|
|
149
|
+
function mountRouteGuards(r) {
|
|
150
|
+
// --- fetch-metadata: cross-site state-change isolation -------------
|
|
151
|
+
//
|
|
152
|
+
// Refuses cross-site POST / PUT / DELETE / PATCH (the CSRF vector)
|
|
153
|
+
// using the browser-supplied Sec-Fetch-* headers — same-origin and
|
|
154
|
+
// same-site requests, plus direct navigations (typed URL / bookmark),
|
|
155
|
+
// pass through. Legacy / non-browser clients that omit Sec-Fetch-*
|
|
156
|
+
// are deferred to (allowMissing default) so server-to-server callers
|
|
157
|
+
// and old browsers aren't broken; the storefront's SameSite session
|
|
158
|
+
// cookie is the gate for those. The webhook paths are exempt because
|
|
159
|
+
// a payment processor's callback is legitimately cross-site.
|
|
160
|
+
var fmGate = b.middleware.fetchMetadata({
|
|
161
|
+
allowSameSite: true,
|
|
162
|
+
allowCrossSite: false,
|
|
163
|
+
allowMissing: true,
|
|
164
|
+
allowedNavigate: true,
|
|
165
|
+
});
|
|
166
|
+
r.use(function fetchMetadataGuard(req, res, next) {
|
|
167
|
+
var pathname = req.pathname || req.url || "/";
|
|
168
|
+
if (_hasPrefix(pathname, WEBHOOK_PATHS)) return next();
|
|
169
|
+
return fmGate(req, res, next);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// --- tight per-route rate limiters ---------------------------------
|
|
173
|
+
//
|
|
174
|
+
// One fixed-window limiter keyed on (client IP + path) so each
|
|
175
|
+
// abusable endpoint carries its own per-client budget. Fixed-window
|
|
176
|
+
// (rather than token-bucket) gives a flat, predictable "N per minute"
|
|
177
|
+
// ceiling that's easy to reason about for an auth surface. ~10/min is
|
|
178
|
+
// an order of magnitude above human use of any of these endpoints and
|
|
179
|
+
// shuts a spray down hard.
|
|
180
|
+
var tightLimiter = b.middleware.rateLimit({
|
|
181
|
+
backend: "memory",
|
|
182
|
+
algorithm: "fixed-window",
|
|
183
|
+
max: 10,
|
|
184
|
+
windowMs: C.TIME.minutes(1),
|
|
185
|
+
keyFn: function (req) {
|
|
186
|
+
return clientKey(req) + "|" + (req.pathname || req.url || "/");
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
r.use(function tightRateGuard(req, res, next) {
|
|
190
|
+
var pathname = req.pathname || req.url || "/";
|
|
191
|
+
// Never throttle the webhook or health paths.
|
|
192
|
+
if (_hasPrefix(pathname, WEBHOOK_PATHS) || pathname === HEALTH_PATH) return next();
|
|
193
|
+
if (!_hasPrefix(pathname, TIGHT_PREFIXES)) return next();
|
|
194
|
+
// `/products/` and `/gift-cards` carry GET reads (PDP, balance page
|
|
195
|
+
// render) that a shopper hits freely — only gate the state-changing
|
|
196
|
+
// / lookup POSTs there. Login / register / passkey / checkout /
|
|
197
|
+
// newsletter / survey are gated on every method (their GETs are the
|
|
198
|
+
// form render, which a spray would hammer just the same to harvest a
|
|
199
|
+
// fresh token, so the budget covers both).
|
|
200
|
+
var method = (req.method || "GET").toUpperCase();
|
|
201
|
+
var gateAllMethods = pathname.indexOf("/products/") !== 0 &&
|
|
202
|
+
pathname.indexOf("/gift-cards") !== 0;
|
|
203
|
+
if (!gateAllMethods && method !== "POST") return next();
|
|
204
|
+
return tightLimiter(req, res, next);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return { fetchMetadata: fmGate, tightLimiter: tightLimiter };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
clientKey: clientKey,
|
|
212
|
+
globalRateLimitOpts: globalRateLimitOpts,
|
|
213
|
+
mountRouteGuards: mountRouteGuards,
|
|
214
|
+
WEBHOOK_PATHS: WEBHOOK_PATHS,
|
|
215
|
+
HEALTH_PATH: HEALTH_PATH,
|
|
216
|
+
TIGHT_PREFIXES: TIGHT_PREFIXES,
|
|
217
|
+
};
|
package/lib/storefront.js
CHANGED
|
@@ -10161,9 +10161,11 @@ function mount(router, deps) {
|
|
|
10161
10161
|
|
|
10162
10162
|
// POST /cart/lines — add a line. Reads variant_id + qty from the
|
|
10163
10163
|
// form body (b.middleware.bodyParser parses it into req.body).
|
|
10164
|
-
//
|
|
10165
|
-
//
|
|
10166
|
-
//
|
|
10164
|
+
// Cross-site forgery of this state-changing POST is refused by the
|
|
10165
|
+
// app-level fetch-metadata gate (Sec-Fetch-Site) plus the SameSite
|
|
10166
|
+
// session cookie; both are wired in server.js via
|
|
10167
|
+
// lib/security-middleware. Redirects to /cart on success so a
|
|
10168
|
+
// refresh doesn't re-submit the form.
|
|
10167
10169
|
router.post("/cart/lines", async function (req, res) {
|
|
10168
10170
|
var body = req.body || {};
|
|
10169
10171
|
var variantId = body.variant_id;
|
|
@@ -10421,8 +10423,10 @@ function mount(router, deps) {
|
|
|
10421
10423
|
//
|
|
10422
10424
|
// The decision is written to a sealed first-party cookie (the gate) AND
|
|
10423
10425
|
// recorded in the cookie-consent ledger (the audit trail) when the
|
|
10424
|
-
// primitive is wired.
|
|
10425
|
-
//
|
|
10426
|
+
// primitive is wired. Cross-site forgery of this POST is refused by the
|
|
10427
|
+
// app-level fetch-metadata gate (Sec-Fetch-Site) plus the SameSite
|
|
10428
|
+
// session cookie (both wired in server.js via lib/security-middleware)
|
|
10429
|
+
// — no per-route re-check.
|
|
10426
10430
|
|
|
10427
10431
|
// Same-origin path guard for `return_to`. Accepts a single leading slash
|
|
10428
10432
|
// followed by a non-slash (so `//evil.example` and absolute URLs are
|
package/package.json
CHANGED