@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 +2 -0
- package/lib/admin.js +2 -4
- package/lib/asset-manifest.json +1 -1
- package/lib/currency-rounding.js +2 -14
- package/lib/index.js +1 -0
- package/lib/text-guard.js +227 -0
- package/lib/webhook-subscriptions.js +11 -24
- package/lib/webhooks.js +12 -33
- 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.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
|
-
|
|
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
|
}
|
package/lib/asset-manifest.json
CHANGED
package/lib/currency-rounding.js
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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.
|
|
147
|
-
//
|
|
148
|
-
// against the by-name metadata/loopback denylist plus
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// dial.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
//
|
|
114
|
-
// (loopback / private / link-local / reserved /
|
|
115
|
-
//
|
|
116
|
-
// the by-name metadata/loopback denylist plus the *.internal
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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