@blamejs/blamejs-shop 0.3.11 → 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,8 @@ 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
+
11
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.
12
14
 
13
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.
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");
@@ -4769,10 +4770,7 @@ function mount(router, deps) {
4769
4770
  // framework's ISO 4217 catalog (the same b.money.CURRENCIES surface the
4770
4771
  // currency-rounding + display primitives compose) and refuse unknown
4771
4772
  // 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
- }
4773
+ textGuard.currencyCode(input.currency, "giftcards: currency");
4776
4774
  if (body.amount_minor != null && body.amount_minor !== "") {
4777
4775
  input.amount_minor = _strictMinorInt(body.amount_minor, "giftcards", "amount_minor (minor units)");
4778
4776
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.11",
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;
@@ -116,15 +117,6 @@ function _ownerId(s) {
116
117
  return s;
117
118
  }
118
119
 
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
-
128
120
  function _endpointUrl(url) {
129
121
  if (typeof url !== "string" || url.length === 0) {
130
122
  throw new TypeError("webhookSubscriptions: endpoint_url must be a non-empty string");
@@ -143,21 +135,16 @@ function _endpointUrl(url) {
143
135
  }
144
136
  // SSRF: refuse internal / loopback / link-local / cloud-metadata
145
137
  // 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)) {
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) {
161
148
  throw new TypeError("webhookSubscriptions: endpoint_url host is not allowed (internal/loopback/metadata address)");
162
149
  }
163
150
  return url;
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
 
@@ -85,16 +86,6 @@ var RATE_WINDOW_MS = C.TIME.minutes(1);
85
86
 
86
87
  function _now() { return Date.now(); }
87
88
 
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
-
98
89
  function _validateUrl(url) {
99
90
  if (typeof url !== "string" || url.length === 0) {
100
91
  throw new TypeError("webhooks: url must be a non-empty string");
@@ -110,29 +101,17 @@ function _validateUrl(url) {
110
101
  // SSRF: refuse any internal / loopback / link-local / cloud-metadata
111
102
  // destination so a registered receiver can't be turned into a probe of
112
103
  // 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) {
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) {
136
115
  throw new TypeError("webhooks: url host is not allowed (internal/loopback/metadata address)");
137
116
  }
138
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.11",
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": {