@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 +4 -0
- package/lib/admin.js +59 -11
- 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 +18 -2
- package/lib/webhooks.js +19 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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;
|
|
@@ -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
|
|
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