@blamejs/blamejs-shop 0.3.10 → 0.3.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 +2 -0
- package/lib/admin.js +61 -11
- package/lib/asset-manifest.json +1 -1
- package/lib/webhook-subscriptions.js +31 -2
- package/lib/webhooks.js +40 -1
- 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.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.11 (2026-05-30) — **Webhook endpoints reject internal addresses; admin errors stay clean and typed.** Outbound webhook endpoints can no longer be pointed at loopback, private, link-local, or cloud-metadata addresses, closing a server-side request forgery path from operator-supplied URLs. Several admin endpoints that previously returned a 500 on bad input now return the correct 4xx — a duplicate slug or SKU is a 409 Conflict, a missing referenced record and malformed JSON are a 400 — and none of them echo internal database or parser text back in the response. Gift cards now reject a currency that isn't a real ISO 4217 code, and the returns refund action reports a malformed id the same way its sibling actions do. No configuration change is required; upgrading picks these up automatically. **Fixed:** *Admin errors return the right status and don't leak internals* — Creating a product, variant, or inventory row with a duplicate key now returns 409 Conflict instead of 500; attaching media to a missing product, and pasting malformed JSON into the shipping-zone editor, now return 400. None of these responses include raw database or JSON-parser text — the detail is a generic, operator-facing message and the underlying error is recorded server-side. The same applies to the receipt and packing-slip routes. · *Gift cards validate the currency* — Issuing a gift card with a currency that isn't a real ISO 4217 code is now rejected with a 400 instead of creating a card in a non-existent currency. · *Consistent error status for refunds* — The returns refund action reports a malformed return id with the same 400 its approve, receive, and reject siblings use; a well-formed id that doesn't exist is still a 404. **Security:** *Webhook endpoints can't target internal addresses* — A webhook endpoint URL that points at a loopback, private (RFC 1918), link-local, reserved, or cloud-metadata address — by IP literal or by a known name such as localhost, metadata.google.internal, or any *.internal host — is now refused when the subscription is created, on both the delivery and outbound-registration paths. A trailing-dot form of those hosts (for example localhost. or 169.254.169.254.) is normalized before the check so it can't slip past. Public https endpoints are unaffected.
|
|
12
|
+
|
|
11
13
|
- v0.3.10 (2026-05-30) — **Order discounts are split across lines for accurate partial refunds.** When an order carries a cart-level discount, the admin console now records how that discount split across the order's lines — proportional to each line's value — so a later partial refund of one line knows that line's discounted share rather than the whole-order amount. A new read-only Discount splits screen shows the per-line breakdown for an order. This is back-office bookkeeping recorded after the order is placed; it has no effect on what the shopper is charged, and an order with no discount records nothing. **Added:** *Per-line discount allocation + admin view* — An order that carried an automatic cart discount now gets a recorded breakdown of how that discount split across its lines — proportional to each line's subtotal, summing exactly to the discount. A read-only Discount splits admin screen shows the breakdown for an order. The recording runs after the order is placed and never affects the charged amount; an order with no discount records nothing.
|
|
12
14
|
|
|
13
15
|
- v0.3.9 (2026-05-30) — **Automatic discounts now apply at checkout.** The automatic-discount rules you define in the admin console — an amount or percentage off a cart that meets a threshold — now reduce the order total at checkout. When a rule matches, the discount is applied (capped so it never exceeds the order subtotal) and shown in the quote the shopper pays; when no rule matches, or none are defined, the total is exactly as before, so a store that has not set up rules is unaffected. The admin screen for managing rules already shipped; this wires it through to the checkout quote and the charge. **Added:** *Automatic cart discounts at checkout* — When a cart matches a configured automatic-discount rule, the checkout reduces the order total by the rule's saving — capped to the order subtotal so the total is never negative — and the reduced amount is what the shopper is charged. With no matching rule, or no rules defined, the order total is unchanged, so the change stays invisible until you set rules up; any rule-engine error degrades to no discount rather than blocking checkout.
|
package/lib/admin.js
CHANGED
|
@@ -411,7 +411,34 @@ function _wrap(handler, opts) {
|
|
|
411
411
|
return result;
|
|
412
412
|
} catch (e) {
|
|
413
413
|
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
414
|
-
|
|
414
|
+
// Malformed JSON body — the body parser raises a SyntaxError whose
|
|
415
|
+
// message echoes the parser's position ("...JSON at position 1").
|
|
416
|
+
// Surface a clean 400 with a generic detail rather than leaking the
|
|
417
|
+
// parser internals (also the defense-in-depth net for any call site
|
|
418
|
+
// that JSON.parses an operator-supplied field without its own guard).
|
|
419
|
+
if (e instanceof SyntaxError) return _problem(res, 400, "bad-request", "Invalid JSON in request.");
|
|
420
|
+
// Storage-engine constraint violations reach here as plain Errors
|
|
421
|
+
// whose message names the table/column/SQL ("UNIQUE constraint
|
|
422
|
+
// failed: products.slug"). Map the class to a clean 4xx with a
|
|
423
|
+
// GENERIC operator-facing detail — never echo the table/column/SQL.
|
|
424
|
+
var msg = (e && e.message) || "";
|
|
425
|
+
if (/UNIQUE constraint failed/i.test(msg) || /FOREIGN KEY constraint failed/i.test(msg)) {
|
|
426
|
+
return _problem(res, 409, "conflict",
|
|
427
|
+
/FOREIGN KEY/i.test(msg) ? "A referenced record does not exist." : "That value is already in use.");
|
|
428
|
+
}
|
|
429
|
+
if (/(?:CHECK|NOT NULL) constraint failed/i.test(msg)) {
|
|
430
|
+
return _problem(res, 400, "bad-request", "A required value is missing or invalid.");
|
|
431
|
+
}
|
|
432
|
+
// Genuine unknown error — record it server-side via the framework
|
|
433
|
+
// audit (drop-silent) so operators can correlate, then return a
|
|
434
|
+
// generic 500 with NO detail. The raw message never reaches the
|
|
435
|
+
// client.
|
|
436
|
+
b.audit.safeEmit({
|
|
437
|
+
action: AUDIT_NAMESPACE + "." + (opts.audit || "request") + ".error",
|
|
438
|
+
outcome: "failure",
|
|
439
|
+
metadata: { message: msg || String(e) },
|
|
440
|
+
});
|
|
441
|
+
return _problem(res, 500, "internal-error");
|
|
415
442
|
}
|
|
416
443
|
};
|
|
417
444
|
}
|
|
@@ -2455,6 +2482,7 @@ function mount(router, deps) {
|
|
|
2455
2482
|
try {
|
|
2456
2483
|
result = await _refundOrder(o, req.body || {});
|
|
2457
2484
|
} catch (e) {
|
|
2485
|
+
// allow:admin-5xx-echoes-raw-error-message — 502 surfaces the PAYMENT PROVIDER's refund-failure reason (e.g. "charge already refunded"), an operator-actionable upstream message, not a server/storage internal.
|
|
2458
2486
|
return _problem(res, 502, "stripe-refund-failed", (e && e.message) || String(e));
|
|
2459
2487
|
}
|
|
2460
2488
|
_json(res, 200, result);
|
|
@@ -3017,12 +3045,11 @@ function mount(router, deps) {
|
|
|
3017
3045
|
router.post("/admin/returns/:id/refund", _pageOrApi(false,
|
|
3018
3046
|
W("return.refund", async function (req, res) {
|
|
3019
3047
|
var body = req.body || {};
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
}
|
|
3048
|
+
// A malformed rma id is a bad request, not a missing record — let
|
|
3049
|
+
// the guardUuid TypeError surface as a clean 400 via _wrap, matching
|
|
3050
|
+
// the approve/received/reject siblings. Only a well-formed id that
|
|
3051
|
+
// resolves to no row (rma === null) is a genuine 404.
|
|
3052
|
+
var rma = await returns.get(req.params.id);
|
|
3026
3053
|
if (!rma) return _problem(res, 404, "return-not-found");
|
|
3027
3054
|
var rmaCtx = await _rmaProviderContext(rma);
|
|
3028
3055
|
// Provider-backed path: move money first, then record the RMA.
|
|
@@ -3032,6 +3059,7 @@ function mount(router, deps) {
|
|
|
3032
3059
|
catch (e) {
|
|
3033
3060
|
var ce = _returnsClientError(e);
|
|
3034
3061
|
if (ce) return _problem(res, ce.status, ce.slug, e.message);
|
|
3062
|
+
// allow:admin-5xx-echoes-raw-error-message — 502 surfaces the PAYMENT PROVIDER's refund-failure reason, an operator-actionable upstream message, not a server/storage internal.
|
|
3035
3063
|
return _problem(res, 502, "provider-refund-failed", (e && e.message) || String(e));
|
|
3036
3064
|
}
|
|
3037
3065
|
_json(res, 200, result.rma);
|
|
@@ -4314,7 +4342,10 @@ function mount(router, deps) {
|
|
|
4314
4342
|
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
4315
4343
|
}));
|
|
4316
4344
|
}
|
|
4317
|
-
|
|
4345
|
+
// Record the real error server-side; return a generic 500 with no
|
|
4346
|
+
// detail so a renderer fault never echoes its internals to the client.
|
|
4347
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.receipt.error", outcome: "failure", metadata: { message: (e && e.message) || String(e) } });
|
|
4348
|
+
return _problem(res, 500, "internal-error");
|
|
4318
4349
|
}
|
|
4319
4350
|
_sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
|
|
4320
4351
|
});
|
|
@@ -4335,7 +4366,10 @@ function mount(router, deps) {
|
|
|
4335
4366
|
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
4336
4367
|
}));
|
|
4337
4368
|
}
|
|
4338
|
-
|
|
4369
|
+
// Record the real error server-side; return a generic 500 with no
|
|
4370
|
+
// detail so a renderer fault never echoes its internals to the client.
|
|
4371
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.packing_slip.error", outcome: "failure", metadata: { message: (e && e.message) || String(e) } });
|
|
4372
|
+
return _problem(res, 500, "internal-error");
|
|
4339
4373
|
}
|
|
4340
4374
|
_sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
|
|
4341
4375
|
});
|
|
@@ -4729,6 +4763,16 @@ function mount(router, deps) {
|
|
|
4729
4763
|
// the amount; the giftcards primitive enforces the rest.
|
|
4730
4764
|
async function _issueGiftCard(body) {
|
|
4731
4765
|
var input = { currency: typeof body.currency === "string" ? body.currency.trim().toUpperCase() : body.currency };
|
|
4766
|
+
// The giftcards primitive only shape-checks the currency (/^[A-Z]{3}$/),
|
|
4767
|
+
// so a well-formed-but-nonexistent code like "ZZZ" would issue a card in
|
|
4768
|
+
// a currency the rest of the shop can't price. Validate against the
|
|
4769
|
+
// framework's ISO 4217 catalog (the same b.money.CURRENCIES surface the
|
|
4770
|
+
// currency-rounding + display primitives compose) and refuse unknown
|
|
4771
|
+
// codes with a clean 400.
|
|
4772
|
+
if (typeof input.currency === "string" && /^[A-Z]{3}$/.test(input.currency) &&
|
|
4773
|
+
!Object.prototype.hasOwnProperty.call(b.money.CURRENCIES, input.currency)) {
|
|
4774
|
+
throw new TypeError("giftcards: currency " + JSON.stringify(input.currency) + " is not a known ISO 4217 code");
|
|
4775
|
+
}
|
|
4732
4776
|
if (body.amount_minor != null && body.amount_minor !== "") {
|
|
4733
4777
|
input.amount_minor = _strictMinorInt(body.amount_minor, "giftcards", "amount_minor (minor units)");
|
|
4734
4778
|
}
|
|
@@ -5346,12 +5390,18 @@ function mount(router, deps) {
|
|
|
5346
5390
|
// via the hidden marker so a partial JSON edit doesn't flip active.
|
|
5347
5391
|
if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
|
|
5348
5392
|
if (typeof body.regions_json === "string" && body.regions_json.trim() !== "") {
|
|
5349
|
-
|
|
5393
|
+
// A pasted JSON blob that doesn't parse is operator input, not a
|
|
5394
|
+
// server fault — throw a TypeError so both surfaces (bearer via
|
|
5395
|
+
// _wrap, cookie via the htmlHandler's TypeError catch) degrade to a
|
|
5396
|
+
// clean 400 instead of a 500 that echoes the parser's position.
|
|
5397
|
+
try { patch.regions = JSON.parse(body.regions_json); }
|
|
5398
|
+
catch (_e) { throw new TypeError("shippingZones: regions_json must be valid JSON"); }
|
|
5350
5399
|
} else if (Array.isArray(body.regions)) {
|
|
5351
5400
|
patch.regions = body.regions;
|
|
5352
5401
|
}
|
|
5353
5402
|
if (typeof body.rates_json === "string" && body.rates_json.trim() !== "") {
|
|
5354
|
-
patch.rates = JSON.parse(body.rates_json);
|
|
5403
|
+
try { patch.rates = JSON.parse(body.rates_json); }
|
|
5404
|
+
catch (_e) { throw new TypeError("shippingZones: rates_json must be valid JSON"); }
|
|
5355
5405
|
} else if (Array.isArray(body.rates)) {
|
|
5356
5406
|
patch.rates = body.rates;
|
|
5357
5407
|
}
|
package/lib/asset-manifest.json
CHANGED
|
@@ -116,6 +116,15 @@ function _ownerId(s) {
|
|
|
116
116
|
return s;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Hostnames that name an internal/metadata destination by name rather
|
|
120
|
+
// than by IP literal — the same denylist the delivery primitive
|
|
121
|
+
// (lib/webhooks.js) applies. A literal-IP host is classified directly via
|
|
122
|
+
// b.ssrfGuard.classify (no DNS); these names cover the by-name reach.
|
|
123
|
+
var SUBSCRIPTION_BLOCKED_HOSTS = Object.freeze({
|
|
124
|
+
"localhost": 1,
|
|
125
|
+
"metadata.google.internal": 1,
|
|
126
|
+
});
|
|
127
|
+
|
|
119
128
|
function _endpointUrl(url) {
|
|
120
129
|
if (typeof url !== "string" || url.length === 0) {
|
|
121
130
|
throw new TypeError("webhookSubscriptions: endpoint_url must be a non-empty string");
|
|
@@ -125,12 +134,32 @@ function _endpointUrl(url) {
|
|
|
125
134
|
}
|
|
126
135
|
// safeUrl.parse defaults to ALLOW_HTTP_TLS (https only). The framework
|
|
127
136
|
// refuses http:// + non-http schemes + user:pass@ userinfo + bracketed
|
|
128
|
-
// raw-IP forms at this gate
|
|
137
|
+
// raw-IP forms at this gate.
|
|
138
|
+
var parsed;
|
|
129
139
|
try {
|
|
130
|
-
b.safeUrl.parse(url);
|
|
140
|
+
parsed = b.safeUrl.parse(url);
|
|
131
141
|
} catch (e) {
|
|
132
142
|
throw new TypeError("webhookSubscriptions: endpoint_url — " + (e && e.message || "rejected"));
|
|
133
143
|
}
|
|
144
|
+
// SSRF: refuse internal / loopback / link-local / cloud-metadata
|
|
145
|
+
// destinations so a registered subscription can't probe the host's own
|
|
146
|
+
// network or the instance-credential metadata service. A literal-IP host
|
|
147
|
+
// is classified via b.ssrfGuard.classify (no DNS); a hostname is matched
|
|
148
|
+
// against the by-name metadata/loopback denylist plus *.internal.
|
|
149
|
+
// DNS-rebinding (a public name resolving to a private IP) is out of scope
|
|
150
|
+
// here by design — resolving the name would add a DNS dependency the
|
|
151
|
+
// registration path avoids; the resolving guard belongs on the delivery
|
|
152
|
+
// dial.
|
|
153
|
+
// Strip a trailing FQDN dot (and IPv6 brackets) BEFORE every check: a
|
|
154
|
+
// trailing dot is semantically the same host but otherwise evades both the
|
|
155
|
+
// by-name denylist ("localhost." / "metadata.google.internal.") and the
|
|
156
|
+
// literal-IP classifier ("169.254.169.254." won't parse as an IP).
|
|
157
|
+
var host = (parsed.hostname || "").replace(/^\[|\]$/g, "").toLowerCase().replace(/\.+$/, "");
|
|
158
|
+
if (b.ssrfGuard.classify(host) ||
|
|
159
|
+
SUBSCRIPTION_BLOCKED_HOSTS[host] ||
|
|
160
|
+
host === "internal" || /\.internal$/.test(host)) {
|
|
161
|
+
throw new TypeError("webhookSubscriptions: endpoint_url host is not allowed (internal/loopback/metadata address)");
|
|
162
|
+
}
|
|
134
163
|
return url;
|
|
135
164
|
}
|
|
136
165
|
|
package/lib/webhooks.js
CHANGED
|
@@ -85,17 +85,56 @@ var RATE_WINDOW_MS = C.TIME.minutes(1);
|
|
|
85
85
|
|
|
86
86
|
function _now() { return Date.now(); }
|
|
87
87
|
|
|
88
|
+
// Hostnames that name an internal/metadata destination by name rather
|
|
89
|
+
// than by IP literal. The cloud-metadata services answer on a hostname
|
|
90
|
+
// as well as the 169.254.169.254 link-local IP; localhost resolves to
|
|
91
|
+
// loopback. A literal-IP host is classified directly via b.ssrfGuard
|
|
92
|
+
// (no DNS); these names cover the by-name reach of the same targets.
|
|
93
|
+
var WEBHOOK_BLOCKED_HOSTS = Object.freeze({
|
|
94
|
+
"localhost": 1,
|
|
95
|
+
"metadata.google.internal": 1,
|
|
96
|
+
});
|
|
97
|
+
|
|
88
98
|
function _validateUrl(url) {
|
|
89
99
|
if (typeof url !== "string" || url.length === 0) {
|
|
90
100
|
throw new TypeError("webhooks: url must be a non-empty string");
|
|
91
101
|
}
|
|
92
102
|
// Refuse http:// outright for outbound — the framework's safe-url
|
|
93
103
|
// parser carries the ALLOW_HTTP_TLS preset that admits only https.
|
|
104
|
+
var parsed;
|
|
94
105
|
try {
|
|
95
|
-
b.safeUrl.parse(url, { allowedProtocols: ["https:"] });
|
|
106
|
+
parsed = b.safeUrl.parse(url, { allowedProtocols: ["https:"] });
|
|
96
107
|
} catch (e) {
|
|
97
108
|
throw new TypeError("webhooks: url must be https:// — " + (e && e.message || "rejected"));
|
|
98
109
|
}
|
|
110
|
+
// SSRF: refuse any internal / loopback / link-local / cloud-metadata
|
|
111
|
+
// destination so a registered receiver can't be turned into a probe of
|
|
112
|
+
// the host's own network or the instance-credential metadata service.
|
|
113
|
+
// A literal-IP host is classified directly via b.ssrfGuard.classify
|
|
114
|
+
// (loopback / private / link-local / reserved / cloud-metadata all
|
|
115
|
+
// refused, IPv4 + IPv6 + IPv4-mapped); a hostname is matched against
|
|
116
|
+
// the by-name metadata/loopback denylist plus the *.internal suffix.
|
|
117
|
+
// DNS-rebinding (a public name that resolves to a private IP) is OUT OF
|
|
118
|
+
// SCOPE here by design — resolving the name would add a DNS dependency
|
|
119
|
+
// the outbound path otherwise avoids, and the delivery send path is the
|
|
120
|
+
// place a resolving guard belongs. This gate stops the literal-IP and
|
|
121
|
+
// known-name SSRF reachable directly from operator-supplied input.
|
|
122
|
+
// Strip a trailing FQDN dot (and IPv6 brackets) BEFORE every check: a
|
|
123
|
+
// trailing dot is semantically the same host but otherwise evades both the
|
|
124
|
+
// by-name denylist ("localhost." / "metadata.google.internal.") and the
|
|
125
|
+
// literal-IP classifier ("169.254.169.254." won't parse as an IP).
|
|
126
|
+
var host = (parsed.hostname || "").replace(/^\[|\]$/g, "").toLowerCase().replace(/\.+$/, "");
|
|
127
|
+
var isBlocked = false;
|
|
128
|
+
if (b.ssrfGuard.classify(host)) {
|
|
129
|
+
isBlocked = true; // literal IP in a refused range
|
|
130
|
+
} else if (WEBHOOK_BLOCKED_HOSTS[host]) {
|
|
131
|
+
isBlocked = true; // localhost / metadata.google.internal
|
|
132
|
+
} else if (host === "internal" || /\.internal$/.test(host)) {
|
|
133
|
+
isBlocked = true; // any *.internal cloud-private name
|
|
134
|
+
}
|
|
135
|
+
if (isBlocked) {
|
|
136
|
+
throw new TypeError("webhooks: url host is not allowed (internal/loopback/metadata address)");
|
|
137
|
+
}
|
|
99
138
|
}
|
|
100
139
|
|
|
101
140
|
function _validateEvents(events) {
|
package/package.json
CHANGED