@blamejs/blamejs-shop 0.3.10 → 0.3.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.3.x
10
10
 
11
+ - v0.3.12 (2026-05-30) — **Currency and outbound-host validation consolidated behind one shared check.** Input validation that was duplicated across the codebase is now a single shared module. Currency codes are validated against the ISO 4217 catalog consistently wherever the admin binds money — the same check the gift-card flow gained last release — and the outbound webhook host guard (refusing loopback, private, link-local, reserved, and cloud-metadata destinations, including trailing-dot forms) is one shared function used by both the delivery and registration paths. The shared module composes the framework's Unicode codepoint catalog so free-text fields can reject control, bidi-override, and zero-width characters when needed. Behavior is equal-or-stricter than before; no operator action is required. **Changed:** *Currency validation is consistently ISO 4217* — Admin paths that bind a currency now validate it against the ISO 4217 catalog in one place, so a code that isn't a real currency is rejected consistently rather than only in some flows. · *Outbound-host SSRF guard is a single shared check* — The webhook delivery and registration paths now share one host guard — loopback, private (RFC 1918), link-local, reserved, and cloud-metadata addresses (by IP literal or known name, including trailing-dot forms) are refused identically on both, so the two can't drift apart.
12
+
13
+ - 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.
14
+
11
15
  - 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
16
 
13
17
  - 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
@@ -33,6 +33,7 @@
33
33
  var pricing = require("./pricing");
34
34
  var collectionsModule = require("./collections");
35
35
  var quantityDiscountsModule = require("./quantity-discounts");
36
+ var textGuard = require("./text-guard");
36
37
  var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
37
38
 
38
39
  var b = require("./vendor/blamejs");
@@ -411,7 +412,34 @@ function _wrap(handler, opts) {
411
412
  return result;
412
413
  } catch (e) {
413
414
  if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
414
- return _problem(res, 500, "internal-error", (e && e.message) || String(e));
415
+ // Malformed JSON body the body parser raises a SyntaxError whose
416
+ // message echoes the parser's position ("...JSON at position 1").
417
+ // Surface a clean 400 with a generic detail rather than leaking the
418
+ // parser internals (also the defense-in-depth net for any call site
419
+ // that JSON.parses an operator-supplied field without its own guard).
420
+ if (e instanceof SyntaxError) return _problem(res, 400, "bad-request", "Invalid JSON in request.");
421
+ // Storage-engine constraint violations reach here as plain Errors
422
+ // whose message names the table/column/SQL ("UNIQUE constraint
423
+ // failed: products.slug"). Map the class to a clean 4xx with a
424
+ // GENERIC operator-facing detail — never echo the table/column/SQL.
425
+ var msg = (e && e.message) || "";
426
+ if (/UNIQUE constraint failed/i.test(msg) || /FOREIGN KEY constraint failed/i.test(msg)) {
427
+ return _problem(res, 409, "conflict",
428
+ /FOREIGN KEY/i.test(msg) ? "A referenced record does not exist." : "That value is already in use.");
429
+ }
430
+ if (/(?:CHECK|NOT NULL) constraint failed/i.test(msg)) {
431
+ return _problem(res, 400, "bad-request", "A required value is missing or invalid.");
432
+ }
433
+ // Genuine unknown error — record it server-side via the framework
434
+ // audit (drop-silent) so operators can correlate, then return a
435
+ // generic 500 with NO detail. The raw message never reaches the
436
+ // client.
437
+ b.audit.safeEmit({
438
+ action: AUDIT_NAMESPACE + "." + (opts.audit || "request") + ".error",
439
+ outcome: "failure",
440
+ metadata: { message: msg || String(e) },
441
+ });
442
+ return _problem(res, 500, "internal-error");
415
443
  }
416
444
  };
417
445
  }
@@ -2455,6 +2483,7 @@ function mount(router, deps) {
2455
2483
  try {
2456
2484
  result = await _refundOrder(o, req.body || {});
2457
2485
  } catch (e) {
2486
+ // 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
2487
  return _problem(res, 502, "stripe-refund-failed", (e && e.message) || String(e));
2459
2488
  }
2460
2489
  _json(res, 200, result);
@@ -3017,12 +3046,11 @@ function mount(router, deps) {
3017
3046
  router.post("/admin/returns/:id/refund", _pageOrApi(false,
3018
3047
  W("return.refund", async function (req, res) {
3019
3048
  var body = req.body || {};
3020
- var rma;
3021
- try { rma = await returns.get(req.params.id); }
3022
- catch (e) {
3023
- if (e instanceof TypeError) return _problem(res, 404, "return-not-found", e.message);
3024
- throw e;
3025
- }
3049
+ // A malformed rma id is a bad request, not a missing record — let
3050
+ // the guardUuid TypeError surface as a clean 400 via _wrap, matching
3051
+ // the approve/received/reject siblings. Only a well-formed id that
3052
+ // resolves to no row (rma === null) is a genuine 404.
3053
+ var rma = await returns.get(req.params.id);
3026
3054
  if (!rma) return _problem(res, 404, "return-not-found");
3027
3055
  var rmaCtx = await _rmaProviderContext(rma);
3028
3056
  // Provider-backed path: move money first, then record the RMA.
@@ -3032,6 +3060,7 @@ function mount(router, deps) {
3032
3060
  catch (e) {
3033
3061
  var ce = _returnsClientError(e);
3034
3062
  if (ce) return _problem(res, ce.status, ce.slug, e.message);
3063
+ // 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
3064
  return _problem(res, 502, "provider-refund-failed", (e && e.message) || String(e));
3036
3065
  }
3037
3066
  _json(res, 200, result.rma);
@@ -4314,7 +4343,10 @@ function mount(router, deps) {
4314
4343
  shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
4315
4344
  }));
4316
4345
  }
4317
- return _problem(res, 500, "internal-error", (e && e.message) || String(e));
4346
+ // Record the real error server-side; return a generic 500 with no
4347
+ // detail so a renderer fault never echoes its internals to the client.
4348
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.receipt.error", outcome: "failure", metadata: { message: (e && e.message) || String(e) } });
4349
+ return _problem(res, 500, "internal-error");
4318
4350
  }
4319
4351
  _sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
4320
4352
  });
@@ -4335,7 +4367,10 @@ function mount(router, deps) {
4335
4367
  shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
4336
4368
  }));
4337
4369
  }
4338
- return _problem(res, 500, "internal-error", (e && e.message) || String(e));
4370
+ // Record the real error server-side; return a generic 500 with no
4371
+ // detail so a renderer fault never echoes its internals to the client.
4372
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.packing_slip.error", outcome: "failure", metadata: { message: (e && e.message) || String(e) } });
4373
+ return _problem(res, 500, "internal-error");
4339
4374
  }
4340
4375
  _sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
4341
4376
  });
@@ -4729,6 +4764,13 @@ function mount(router, deps) {
4729
4764
  // the amount; the giftcards primitive enforces the rest.
4730
4765
  async function _issueGiftCard(body) {
4731
4766
  var input = { currency: typeof body.currency === "string" ? body.currency.trim().toUpperCase() : body.currency };
4767
+ // The giftcards primitive only shape-checks the currency (/^[A-Z]{3}$/),
4768
+ // so a well-formed-but-nonexistent code like "ZZZ" would issue a card in
4769
+ // a currency the rest of the shop can't price. Validate against the
4770
+ // framework's ISO 4217 catalog (the same b.money.CURRENCIES surface the
4771
+ // currency-rounding + display primitives compose) and refuse unknown
4772
+ // codes with a clean 400.
4773
+ textGuard.currencyCode(input.currency, "giftcards: currency");
4732
4774
  if (body.amount_minor != null && body.amount_minor !== "") {
4733
4775
  input.amount_minor = _strictMinorInt(body.amount_minor, "giftcards", "amount_minor (minor units)");
4734
4776
  }
@@ -5346,12 +5388,18 @@ function mount(router, deps) {
5346
5388
  // via the hidden marker so a partial JSON edit doesn't flip active.
5347
5389
  if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
5348
5390
  if (typeof body.regions_json === "string" && body.regions_json.trim() !== "") {
5349
- patch.regions = JSON.parse(body.regions_json);
5391
+ // A pasted JSON blob that doesn't parse is operator input, not a
5392
+ // server fault — throw a TypeError so both surfaces (bearer via
5393
+ // _wrap, cookie via the htmlHandler's TypeError catch) degrade to a
5394
+ // clean 400 instead of a 500 that echoes the parser's position.
5395
+ try { patch.regions = JSON.parse(body.regions_json); }
5396
+ catch (_e) { throw new TypeError("shippingZones: regions_json must be valid JSON"); }
5350
5397
  } else if (Array.isArray(body.regions)) {
5351
5398
  patch.regions = body.regions;
5352
5399
  }
5353
5400
  if (typeof body.rates_json === "string" && body.rates_json.trim() !== "") {
5354
- patch.rates = JSON.parse(body.rates_json);
5401
+ try { patch.rates = JSON.parse(body.rates_json); }
5402
+ catch (_e) { throw new TypeError("shippingZones: rates_json must be valid JSON"); }
5355
5403
  } else if (Array.isArray(body.rates)) {
5356
5404
  patch.rates = body.rates;
5357
5405
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.10",
2
+ "version": "0.3.12",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -81,6 +81,7 @@
81
81
  */
82
82
 
83
83
  var b = require("./vendor/blamejs");
84
+ var textGuard = require("./text-guard");
84
85
 
85
86
  // ---- constants ----------------------------------------------------------
86
87
 
@@ -97,20 +98,7 @@ var MAX_LIST_LIMIT = 500;
97
98
  // typo at config-time rather than at the first checkout that touches
98
99
  // the bad code.
99
100
  function _currency(code) {
100
- if (typeof code !== "string" || !/^[A-Z]{3}$/.test(code)) {
101
- throw new TypeError(
102
- "currencyRounding: currency must be a 3-letter uppercase ISO 4217 code, got " +
103
- JSON.stringify(code)
104
- );
105
- }
106
- var catalog = b.money.CURRENCIES;
107
- if (!Object.prototype.hasOwnProperty.call(catalog, code)) {
108
- throw new TypeError(
109
- "currencyRounding: currency " + JSON.stringify(code) +
110
- " is not in the ISO 4217 catalog"
111
- );
112
- }
113
- return code;
101
+ return textGuard.currencyCode(code, "currencyRounding: currency");
114
102
  }
115
103
 
116
104
  function _incrementMinor(n) {
package/lib/index.js CHANGED
@@ -252,4 +252,5 @@ Object.assign(module.exports, {
252
252
  winbackCampaigns: require("./winback-campaigns"),
253
253
  wishlistDigest: require("./wishlist-digest"),
254
254
  operatorHelpCenter: require("./operator-help-center"),
255
+ textGuard: require("./text-guard"),
255
256
  });
@@ -0,0 +1,227 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.textGuard
4
+ * @title Shop input-validation primitives over the framework's codepoint catalog
5
+ *
6
+ * @intro
7
+ * One home for the cross-cutting string validators the shop's
8
+ * handlers reach for at the request boundary: the ISO 4217 currency
9
+ * code, the URL slug label, the outbound host label, and the
10
+ * dangerous-codepoint screen for unconstrained free text. Each was
11
+ * previously open-coded at several call sites with subtly different
12
+ * shapes; centralizing them keeps the discipline identical
13
+ * everywhere and gives the codebase one place to tighten.
14
+ *
15
+ * The dangerous-codepoint surface (bidi overrides, C0 control bytes,
16
+ * null bytes, zero-width / invisible formatting, mixed-script
17
+ * confusables) is NOT reinvented here — it composes the framework's
18
+ * own codepoint catalog, the single source of truth the guard-*
19
+ * family already builds on. The framework does not yet re-export the
20
+ * catalog on its public index; until it does, this module reaches
21
+ * the catalog leaf directly. The leaf has no dependency on any shop
22
+ * module, so requiring it is refresh-safe; an upstream one-line
23
+ * `module.exports` add would let this collapse to `b.codepointClass`
24
+ * with no behavior change.
25
+ *
26
+ * Validators THROW a `TypeError` on bad input. The admin / account
27
+ * request wrappers map a thrown `TypeError` to a clean 400, so a
28
+ * typo or a hostile value surfaces as a bad-request to the caller
29
+ * instead of a 500 — the validators are written to be called at the
30
+ * handler boundary, before the value reaches storage or an outbound
31
+ * dial.
32
+ *
33
+ * Surface:
34
+ * - re-exports of the framework codepoint catalog (BIDI_RE,
35
+ * C0_CTRL_RE, ZERO_WIDTH_RE, NULL_BYTE, scriptFor,
36
+ * detectMixedScripts, detectCharThreats, assertNoCharThreats,
37
+ * applyCharStripPolicies) so consumers grab one import
38
+ * - asciiUpperLetters(s, label, n?) — fixed-length A-Z token
39
+ * - currencyCode(s, label?) — ISO 4217 shape + catalog membership
40
+ * - slugLabel(s, label, opts?) — lowercase URL-slug label
41
+ * - hostLabel(host, label?) — outbound host SSRF + by-name denylist
42
+ * - freeText(s, label, policy?) — dangerous-codepoint screen for
43
+ * unconstrained UTF-8 text fields
44
+ *
45
+ * @primitive textGuard
46
+ * @related b.money, b.ssrfGuard, b.safeUrl, shop.currencyRounding,
47
+ * shop.webhooks, shop.webhookSubscriptions
48
+ */
49
+
50
+ var b = require("./vendor/blamejs");
51
+
52
+ // The framework's codepoint catalog (bidi / control / null / zero-width
53
+ // tables + regexes, the script ranges, and the detect/assert/strip
54
+ // helpers the guard-* family composes). It is not on the framework's
55
+ // public index yet; the catalog leaf has no shop-module dependency, so
56
+ // composing it directly stays refresh-safe and keeps a single source of
57
+ // truth instead of a second copy of the tables.
58
+ // allow:vendor-hand-edit — composing the framework's codepoint catalog leaf (no shop dependency, refresh-safe); collapses to b.codepointClass once upstream re-exports it
59
+ var codepointClass = require("./vendor/blamejs/lib/codepoint-class");
60
+
61
+ // ---- shop validators ----------------------------------------------------
62
+
63
+ // asciiUpperLetters(s, label, n?) — exactly `n` (default 3) ASCII
64
+ // uppercase letters, nothing else. The generalized shape behind every
65
+ // `/^[A-Z]{3}$/` token check. ASCII-allowlist by construction, so bidi
66
+ // / control / zero-width / confusable codepoints are rejected for free
67
+ // (they are not in A-Z). Throws TypeError on a miss.
68
+ function asciiUpperLetters(s, label, n) {
69
+ var name = label || "value";
70
+ var len = n == null ? 3 : n;
71
+ if (typeof len !== "number" || !Number.isInteger(len) || len <= 0) {
72
+ throw new TypeError("textGuard: length must be a positive integer, got " + JSON.stringify(n));
73
+ }
74
+ // allow:dynamic-regex — len is a validated positive integer, not external input
75
+ var re = new RegExp("^[A-Z]{" + len + "}$");
76
+ if (typeof s !== "string" || !re.test(s)) {
77
+ throw new TypeError(
78
+ name + " must be exactly " + len + " uppercase ASCII letter(s), got " + JSON.stringify(s)
79
+ );
80
+ }
81
+ return s;
82
+ }
83
+
84
+ // currencyCode(s, label?) — ISO 4217 currency code: the asciiUpperLetters
85
+ // shape check THEN membership in the framework's catalog (b.money.CURRENCIES,
86
+ // the same surface currency-rounding + currency-display compose). Rejects
87
+ // both malformed codes ("usd", "US") and well-formed-but-nonexistent codes
88
+ // ("ZZZ") that would shape-check past `/^[A-Z]{3}$/` but bind money to a
89
+ // currency the rest of the shop can't price. Throws TypeError on a miss.
90
+ function currencyCode(s, label) {
91
+ var name = label || "currency";
92
+ if (typeof s !== "string" || !/^[A-Z]{3}$/.test(s)) {
93
+ throw new TypeError(
94
+ name + " must be a 3-letter uppercase ISO 4217 code, got " + JSON.stringify(s)
95
+ );
96
+ }
97
+ if (!Object.prototype.hasOwnProperty.call(b.money.CURRENCIES, s)) {
98
+ throw new TypeError(name + " " + JSON.stringify(s) + " is not in the ISO 4217 catalog");
99
+ }
100
+ return s;
101
+ }
102
+
103
+ // slugLabel(s, label, opts?) — a lowercase URL-slug label: starts and
104
+ // ends with [a-z0-9], inner chars [a-z0-9-], no leading / trailing /
105
+ // doubled hyphen, length 1..maxLen (default 80). ASCII-allowlist, so
106
+ // dangerous codepoints are rejected by construction. Throws TypeError.
107
+ function slugLabel(s, label, opts) {
108
+ var name = label || "slug";
109
+ opts = opts || {};
110
+ var maxLen = opts.maxLen == null ? 80 : opts.maxLen;
111
+ if (typeof maxLen !== "number" || !Number.isInteger(maxLen) || maxLen <= 0) {
112
+ throw new TypeError("textGuard: maxLen must be a positive integer, got " + JSON.stringify(opts.maxLen));
113
+ }
114
+ if (typeof s !== "string" || s.length < 1 || s.length > maxLen) {
115
+ throw new TypeError(
116
+ name + " must be a 1.." + maxLen + "-character lowercase slug, got " + JSON.stringify(s)
117
+ );
118
+ }
119
+ // Single char must itself be [a-z0-9]; longer must match start/inner/end.
120
+ var ok = s.length === 1
121
+ ? /^[a-z0-9]$/.test(s)
122
+ : /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s);
123
+ if (!ok || /--/.test(s)) {
124
+ throw new TypeError(
125
+ name + " must be a lowercase URL slug ([a-z0-9] separated by single hyphens), got " + JSON.stringify(s)
126
+ );
127
+ }
128
+ return s;
129
+ }
130
+
131
+ // Hostnames that name an internal / metadata destination by name rather
132
+ // than by IP literal. The cloud-metadata services answer on a hostname as
133
+ // well as the 169.254.169.254 link-local IP; localhost resolves to
134
+ // loopback. A literal-IP host is classified directly via b.ssrfGuard
135
+ // (no DNS); these names cover the by-name reach of the same targets.
136
+ var BLOCKED_HOSTS = Object.freeze({
137
+ "localhost": 1,
138
+ "metadata.google.internal": 1,
139
+ });
140
+
141
+ // hostLabel(host, label?) — refuse an outbound host that targets an
142
+ // internal / loopback / link-local / reserved / cloud-metadata
143
+ // destination. A literal-IP host is classified via b.ssrfGuard.classify
144
+ // (IPv4 + IPv6 + IPv4-mapped, no DNS); a hostname is matched against the
145
+ // by-name metadata / loopback denylist plus the *.internal suffix. A
146
+ // trailing FQDN dot and IPv6 brackets are stripped first so neither check
147
+ // is evaded. DNS-rebinding (a public name resolving to a private IP) is
148
+ // out of scope at this gate by design — the resolving guard belongs on
149
+ // the delivery dial. Throws TypeError when the host is not allowed.
150
+ // Returns the normalized (bracket / trailing-dot stripped, lowercased)
151
+ // host on success.
152
+ function hostLabel(host, label) {
153
+ var name = label || "host";
154
+ if (typeof host !== "string" || host.length === 0) {
155
+ throw new TypeError(name + " must be a non-empty string");
156
+ }
157
+ var h = host.replace(/^\[|\]$/g, "").toLowerCase().replace(/\.+$/, "");
158
+ if (b.ssrfGuard.classify(h) ||
159
+ BLOCKED_HOSTS[h] ||
160
+ h === "internal" || /\.internal$/.test(h)) {
161
+ throw new TypeError(name + " is not allowed (internal/loopback/metadata address)");
162
+ }
163
+ return h;
164
+ }
165
+
166
+ // freeText(s, label, policy?) — the dangerous-codepoint screen for an
167
+ // UNCONSTRAINED UTF-8 text field (customer note, review body, gift
168
+ // message, product Q&A). Unlike the ASCII-allowlist validators above,
169
+ // these fields legitimately carry arbitrary letters, so the codepoint
170
+ // catalog is the right backing: bidi overrides (CVE-2021-42574 Trojan
171
+ // Source), null bytes, and C0 control characters are refused by default;
172
+ // zero-width / invisible formatting is refused when policy.zeroWidth is
173
+ // "reject"; a mixed-script confusable (Cyrillic 'а' inside an otherwise
174
+ // Latin label) is refused when policy.mixedScript is "reject". Throws
175
+ // TypeError on a refused codepoint. Returns the input on success.
176
+ function freeText(s, label, policy) {
177
+ var name = label || "value";
178
+ if (typeof s !== "string") {
179
+ throw new TypeError(name + " must be a string");
180
+ }
181
+ policy = policy || {};
182
+ var bidiP = policy.bidi || "reject";
183
+ var nullP = policy.nullByte || "reject";
184
+ var controlP = policy.control || "reject";
185
+ if (bidiP === "reject" && codepointClass.BIDI_RE.test(s)) {
186
+ throw new TypeError(name + " contains a Unicode bidi override (CVE-2021-42574 Trojan Source)");
187
+ }
188
+ if (nullP === "reject" && s.indexOf(codepointClass.NULL_BYTE) !== -1) {
189
+ throw new TypeError(name + " contains a null byte");
190
+ }
191
+ if (controlP === "reject" && codepointClass.C0_CTRL_RE.test(s)) {
192
+ throw new TypeError(name + " contains a C0 control character");
193
+ }
194
+ if (policy.zeroWidth === "reject" && codepointClass.ZERO_WIDTH_RE.test(s)) {
195
+ throw new TypeError(name + " contains a zero-width / invisible formatting character");
196
+ }
197
+ if (policy.mixedScript === "reject") {
198
+ var scripts = codepointClass.detectMixedScripts(s, policy.allowedScripts);
199
+ if (scripts) {
200
+ throw new TypeError(
201
+ name + " mixes writing systems (" + scripts.join(", ") + ") — possible confusable / homograph"
202
+ );
203
+ }
204
+ }
205
+ return s;
206
+ }
207
+
208
+ module.exports = {
209
+ // Re-exports of the framework codepoint catalog (single source of truth).
210
+ BIDI_RE: codepointClass.BIDI_RE,
211
+ C0_CTRL_RE: codepointClass.C0_CTRL_RE,
212
+ ZERO_WIDTH_RE: codepointClass.ZERO_WIDTH_RE,
213
+ NULL_BYTE: codepointClass.NULL_BYTE,
214
+ BOM_CHAR: codepointClass.BOM_CHAR,
215
+ scriptFor: codepointClass.scriptFor,
216
+ detectMixedScripts: codepointClass.detectMixedScripts,
217
+ detectCharThreats: codepointClass.detectCharThreats,
218
+ assertNoCharThreats: codepointClass.assertNoCharThreats,
219
+ applyCharStripPolicies: codepointClass.applyCharStripPolicies,
220
+
221
+ // Shop validators.
222
+ asciiUpperLetters: asciiUpperLetters,
223
+ currencyCode: currencyCode,
224
+ slugLabel: slugLabel,
225
+ hostLabel: hostLabel,
226
+ freeText: freeText,
227
+ };
@@ -49,6 +49,7 @@
49
49
  */
50
50
 
51
51
  var b = require("./vendor/blamejs");
52
+ var textGuard = require("./text-guard");
52
53
 
53
54
  var OWNER_TYPES = Object.freeze(["operator", "app", "customer"]);
54
55
  var MAX_OWNER_ID_LEN = 200;
@@ -125,12 +126,27 @@ function _endpointUrl(url) {
125
126
  }
126
127
  // safeUrl.parse defaults to ALLOW_HTTP_TLS (https only). The framework
127
128
  // refuses http:// + non-http schemes + user:pass@ userinfo + bracketed
128
- // raw-IP forms at this gate — no separate validation needed here.
129
+ // raw-IP forms at this gate.
130
+ var parsed;
129
131
  try {
130
- b.safeUrl.parse(url);
132
+ parsed = b.safeUrl.parse(url);
131
133
  } catch (e) {
132
134
  throw new TypeError("webhookSubscriptions: endpoint_url — " + (e && e.message || "rejected"));
133
135
  }
136
+ // SSRF: refuse internal / loopback / link-local / cloud-metadata
137
+ // destinations so a registered subscription can't probe the host's own
138
+ // network or the instance-credential metadata service. textGuard.hostLabel
139
+ // classifies a literal-IP host via the framework SSRF guard (no DNS) and
140
+ // matches a hostname against the by-name metadata / loopback denylist plus
141
+ // the *.internal suffix, after stripping a trailing FQDN dot + IPv6
142
+ // brackets so neither check is evaded. DNS-rebinding (a public name
143
+ // resolving to a private IP) is out of scope here by design — the
144
+ // resolving guard belongs on the delivery dial.
145
+ try {
146
+ textGuard.hostLabel(parsed.hostname, "webhookSubscriptions: endpoint_url host");
147
+ } catch (_e) {
148
+ throw new TypeError("webhookSubscriptions: endpoint_url host is not allowed (internal/loopback/metadata address)");
149
+ }
134
150
  return url;
135
151
  }
136
152
 
package/lib/webhooks.js CHANGED
@@ -37,6 +37,7 @@
37
37
  */
38
38
 
39
39
  var b = require("./vendor/blamejs");
40
+ var textGuard = require("./text-guard");
40
41
 
41
42
  var C = b.constants;
42
43
 
@@ -91,11 +92,28 @@ function _validateUrl(url) {
91
92
  }
92
93
  // Refuse http:// outright for outbound — the framework's safe-url
93
94
  // parser carries the ALLOW_HTTP_TLS preset that admits only https.
95
+ var parsed;
94
96
  try {
95
- b.safeUrl.parse(url, { allowedProtocols: ["https:"] });
97
+ parsed = b.safeUrl.parse(url, { allowedProtocols: ["https:"] });
96
98
  } catch (e) {
97
99
  throw new TypeError("webhooks: url must be https:// — " + (e && e.message || "rejected"));
98
100
  }
101
+ // SSRF: refuse any internal / loopback / link-local / cloud-metadata
102
+ // destination so a registered receiver can't be turned into a probe of
103
+ // the host's own network or the instance-credential metadata service.
104
+ // textGuard.hostLabel classifies a literal-IP host directly via the
105
+ // framework SSRF guard (loopback / private / link-local / reserved /
106
+ // cloud-metadata, IPv4 + IPv6 + IPv4-mapped) and matches a hostname
107
+ // against the by-name metadata / loopback denylist plus the *.internal
108
+ // suffix, after stripping a trailing FQDN dot + IPv6 brackets so neither
109
+ // check is evaded. DNS-rebinding (a public name that resolves to a
110
+ // private IP) is OUT OF SCOPE here by design — the resolving guard
111
+ // belongs on the delivery dial.
112
+ try {
113
+ textGuard.hostLabel(parsed.hostname, "webhooks: url host");
114
+ } catch (_e) {
115
+ throw new TypeError("webhooks: url host is not allowed (internal/loopback/metadata address)");
116
+ }
99
117
  }
100
118
 
101
119
  function _validateEvents(events) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.10",
3
+ "version": "0.3.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": {