@blamejs/blamejs-shop 0.5.20 → 0.5.22
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/README.md +1 -0
- package/SECURITY.md +23 -0
- package/lib/addresses.js +17 -3
- package/lib/admin.js +14 -5
- package/lib/affiliates.js +7 -23
- package/lib/announcement-bar.js +5 -22
- package/lib/api-keys.js +4 -13
- package/lib/asset-manifest.json +1 -1
- package/lib/banner-ab-tests.js +5 -9
- package/lib/blog-articles.js +8 -26
- package/lib/captcha-gate.js +7 -17
- package/lib/carrier-accounts.js +3 -10
- package/lib/catalog-drafts.js +5 -12
- package/lib/category-navigation.js +7 -20
- package/lib/cms-blocks.js +4 -7
- package/lib/compliance-export.js +11 -11
- package/lib/consent-ledger.js +2 -4
- package/lib/cost-layers.js +56 -21
- package/lib/customer-import.js +2 -2
- package/lib/customer-notes.js +7 -23
- package/lib/customer-roles.js +3 -5
- package/lib/customer-segments.js +17 -14
- package/lib/customer-surveys.js +7 -14
- package/lib/customers.js +2 -4
- package/lib/dispute-resolution.js +5 -13
- package/lib/dropship-forwarding.js +3 -5
- package/lib/dunning.js +2 -7
- package/lib/email-ab-tests.js +4 -8
- package/lib/email-campaigns.js +39 -15
- package/lib/email-warmup.js +2 -7
- package/lib/experiments.js +6 -10
- package/lib/externaldb-d1.js +47 -31
- package/lib/geolocation.js +18 -28
- package/lib/gift-options.js +5 -23
- package/lib/knowledge-base.js +7 -22
- package/lib/line-gift-wrap.js +7 -17
- package/lib/live-chat.js +5 -18
- package/lib/metered-usage.js +3 -8
- package/lib/notifications.js +3 -7
- package/lib/operator-accounts.js +3 -5
- package/lib/operator-approvals.js +6 -8
- package/lib/operator-audit-log.js +2 -7
- package/lib/operator-help-center.js +5 -21
- package/lib/operator-inbox.js +4 -11
- package/lib/operator-roles.js +5 -7
- package/lib/order-escalation.js +2 -2
- package/lib/order-export.js +24 -20
- package/lib/order-notes.js +4 -14
- package/lib/order-ratings.js +4 -10
- package/lib/payment-methods.js +5 -13
- package/lib/payment-retries.js +2 -7
- package/lib/pixel-events.js +21 -28
- package/lib/plan-changes.js +2 -7
- package/lib/product-qa.js +4 -8
- package/lib/promo-banners.js +7 -15
- package/lib/purchase-orders.js +3 -6
- package/lib/push-notifications.js +7 -32
- package/lib/pwa-manifest.js +6 -11
- package/lib/quotes.js +5 -9
- package/lib/reviews.js +4 -10
- package/lib/robots-config.js +6 -14
- package/lib/sales-tax-filings.js +2 -2
- package/lib/search-facets.js +5 -5
- package/lib/search-ranking.js +4 -7
- package/lib/search-synonyms.js +7 -21
- package/lib/security-middleware.js +86 -58
- package/lib/seller-signup.js +10 -26
- package/lib/sidebar-widgets.js +6 -15
- package/lib/site-redirects.js +5 -16
- package/lib/sitemap-generator.js +3 -7
- package/lib/sms-dispatcher.js +7 -23
- package/lib/stock-receipts.js +4 -10
- package/lib/storefront-dashboards.js +2 -10
- package/lib/storefront-forms.js +9 -22
- package/lib/storefront-pages.js +6 -20
- package/lib/storefront.js +2 -2
- package/lib/subscription-billing.js +3 -8
- package/lib/subscription-controls.js +2 -7
- package/lib/suggestion-box.js +9 -26
- package/lib/support-tickets.js +6 -24
- package/lib/tax-remittance.js +2 -2
- package/lib/tenants.js +2 -7
- package/lib/text-guard.js +179 -35
- package/lib/theme-assets.js +4 -16
- package/lib/translations.js +3 -6
- package/lib/trust-badges.js +4 -7
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs/CHANGELOG.md +32 -0
- package/lib/vendor/blamejs/NOTICE +1 -1
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/lib/network-tls.js +98 -13
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs/lib/vendor/blamejs-pki.cjs +568 -304
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/sbom.cdx.json +6 -6
- package/lib/vendor-invoices.js +3 -6
- package/lib/vendors.js +5 -19
- package/lib/webhook-receiver.js +3 -10
- package/lib/webhook-subscriptions.js +3 -13
- package/lib/wishlist-sharing.js +5 -10
- package/package.json +1 -1
package/lib/customer-surveys.js
CHANGED
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
*/
|
|
70
70
|
|
|
71
71
|
var b = require("./vendor/blamejs");
|
|
72
|
+
var textGuard = require("./text-guard");
|
|
72
73
|
var C = b.constants;
|
|
73
74
|
|
|
74
75
|
// ---- constants ----------------------------------------------------------
|
|
@@ -104,8 +105,6 @@ var MAX_EXPIRES_HOURS = 24 * 365; // 1 year
|
|
|
104
105
|
var DEFAULT_LIMIT = 50;
|
|
105
106
|
var MAX_LIMIT = 500;
|
|
106
107
|
|
|
107
|
-
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
108
|
-
var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
|
|
109
108
|
|
|
110
109
|
// NPS / CSAT / CES enforce a primary-question shape so the rollup
|
|
111
110
|
// math has something well-defined to bucket against. Custom surveys
|
|
@@ -143,9 +142,7 @@ function _title(s) {
|
|
|
143
142
|
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
144
143
|
throw new TypeError("customerSurveys: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
145
144
|
}
|
|
146
|
-
|
|
147
|
-
throw new TypeError("customerSurveys: title must not contain control bytes");
|
|
148
|
-
}
|
|
145
|
+
textGuard.freeText(s, "customerSurveys: title", { bidiMarks: "allow" });
|
|
149
146
|
return s;
|
|
150
147
|
}
|
|
151
148
|
|
|
@@ -173,9 +170,7 @@ function _sourceEventId(s) {
|
|
|
173
170
|
if (typeof s !== "string" || s.length > MAX_SOURCE_EVENT_LEN) {
|
|
174
171
|
throw new TypeError("customerSurveys: source_event_id must be a string <= " + MAX_SOURCE_EVENT_LEN + " chars");
|
|
175
172
|
}
|
|
176
|
-
|
|
177
|
-
throw new TypeError("customerSurveys: source_event_id must not contain control bytes");
|
|
178
|
-
}
|
|
173
|
+
textGuard.freeText(s, "customerSurveys: source_event_id", { singleLine: "reject" });
|
|
179
174
|
return s;
|
|
180
175
|
}
|
|
181
176
|
|
|
@@ -192,9 +187,7 @@ function _closeReason(s) {
|
|
|
192
187
|
if (typeof s !== "string" || !s.length || s.length > MAX_CLOSE_REASON_LEN) {
|
|
193
188
|
throw new TypeError("customerSurveys: reason must be a non-empty string <= " + MAX_CLOSE_REASON_LEN + " chars");
|
|
194
189
|
}
|
|
195
|
-
|
|
196
|
-
throw new TypeError("customerSurveys: reason must not contain control bytes");
|
|
197
|
-
}
|
|
190
|
+
textGuard.freeText(s, "customerSurveys: reason", { bidiMarks: "allow" });
|
|
198
191
|
return s;
|
|
199
192
|
}
|
|
200
193
|
|
|
@@ -245,7 +238,7 @@ function _questions(arr, kind) {
|
|
|
245
238
|
if (typeof q.label !== "string" || !q.label.length || q.label.length > MAX_LABEL_LEN) {
|
|
246
239
|
throw new TypeError("customerSurveys: questions[" + i + "].label must be a non-empty string <= " + MAX_LABEL_LEN + " chars");
|
|
247
240
|
}
|
|
248
|
-
if (
|
|
241
|
+
if (textGuard.hasCodepointThreat(q.label)) {
|
|
249
242
|
throw new TypeError("customerSurveys: questions[" + i + "].label must not contain control bytes");
|
|
250
243
|
}
|
|
251
244
|
var required = q.required == null ? true : q.required;
|
|
@@ -284,7 +277,7 @@ function _questions(arr, kind) {
|
|
|
284
277
|
throw new TypeError("customerSurveys: questions[" + i + "].options[" + k +
|
|
285
278
|
"] must be a non-empty string <= " + MAX_OPTION_LEN + " chars");
|
|
286
279
|
}
|
|
287
|
-
if (
|
|
280
|
+
if (textGuard.hasCodepointThreat(opt)) {
|
|
288
281
|
throw new TypeError("customerSurveys: questions[" + i + "].options[" + k +
|
|
289
282
|
"] must not contain control bytes");
|
|
290
283
|
}
|
|
@@ -392,7 +385,7 @@ function _validateAnswers(answers, questions) {
|
|
|
392
385
|
throw new TypeError("customerSurveys: answer for " + JSON.stringify(q.id) +
|
|
393
386
|
" must be <= " + q.max + " chars");
|
|
394
387
|
}
|
|
395
|
-
if (
|
|
388
|
+
if (textGuard.hasCodepointThreat(a)) {
|
|
396
389
|
throw new TypeError("customerSurveys: answer for " + JSON.stringify(q.id) +
|
|
397
390
|
" must not contain control bytes");
|
|
398
391
|
}
|
package/lib/customers.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
var nodeCrypto = require("node:crypto"); // allow:non-shop-require — Apple mandates ES256/P1363; framework has no classical ES256 signer
|
|
37
37
|
|
|
38
38
|
var b = require("./vendor/blamejs");
|
|
39
|
+
var textGuard = require("./text-guard");
|
|
39
40
|
|
|
40
41
|
var EMAIL_NAMESPACE = "customer-email";
|
|
41
42
|
// Federated identity providers the OIDC sign-in path accepts.
|
|
@@ -43,7 +44,6 @@ var OAUTH_PROVIDERS = ["google", "apple"];
|
|
|
43
44
|
var MAX_DISPLAY_NAME_LEN = 128;
|
|
44
45
|
var MAX_CRED_FIELD_BYTES = 4096;
|
|
45
46
|
var TRANSPORTS_RE = /^[a-z]+(?:,[a-z]+)*$/;
|
|
46
|
-
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
47
47
|
|
|
48
48
|
// Pagination cursor for the operator-facing `list` — newest first by
|
|
49
49
|
// (created_at, id), HMAC-tagged via b.pagination so an operator can't
|
|
@@ -105,9 +105,7 @@ function _displayName(s) {
|
|
|
105
105
|
if (s.length > MAX_DISPLAY_NAME_LEN) {
|
|
106
106
|
throw new TypeError("customers: display_name must be <= " + MAX_DISPLAY_NAME_LEN + " characters");
|
|
107
107
|
}
|
|
108
|
-
|
|
109
|
-
throw new TypeError("customers: display_name contains control bytes");
|
|
110
|
-
}
|
|
108
|
+
textGuard.freeText(s, "customers: display_name", { singleLine: "reject", bidiMarks: "allow" });
|
|
111
109
|
return s;
|
|
112
110
|
}
|
|
113
111
|
|
|
@@ -114,7 +114,6 @@ var PROCESSOR_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
|
114
114
|
var REASON_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
115
115
|
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
116
116
|
var OPERATOR_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
117
|
-
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
118
117
|
|
|
119
118
|
var KINDS = Object.freeze([
|
|
120
119
|
"chargeback",
|
|
@@ -151,6 +150,7 @@ var STATUSES = Object.freeze([
|
|
|
151
150
|
]);
|
|
152
151
|
|
|
153
152
|
var b = require("./vendor/blamejs");
|
|
153
|
+
var textGuard = require("./text-guard");
|
|
154
154
|
|
|
155
155
|
// ---- validators ---------------------------------------------------------
|
|
156
156
|
|
|
@@ -249,9 +249,7 @@ function _narrative(s) {
|
|
|
249
249
|
" exceeds cap " + MAX_NARRATIVE_LEN
|
|
250
250
|
);
|
|
251
251
|
}
|
|
252
|
-
|
|
253
|
-
throw new TypeError("disputeResolution: narrative must not contain control bytes");
|
|
254
|
-
}
|
|
252
|
+
textGuard.freeText(s, "disputeResolution: narrative", { bidiMarks: "allow" });
|
|
255
253
|
return s;
|
|
256
254
|
}
|
|
257
255
|
|
|
@@ -265,9 +263,7 @@ function _blobRef(s) {
|
|
|
265
263
|
" exceeds cap " + MAX_BLOB_REF_LEN
|
|
266
264
|
);
|
|
267
265
|
}
|
|
268
|
-
|
|
269
|
-
throw new TypeError("disputeResolution: blob_ref must not contain control bytes");
|
|
270
|
-
}
|
|
266
|
+
textGuard.freeText(s, "disputeResolution: blob_ref");
|
|
271
267
|
return s;
|
|
272
268
|
}
|
|
273
269
|
|
|
@@ -281,9 +277,7 @@ function _notesOpt(s) {
|
|
|
281
277
|
"disputeResolution: notes length " + s.length + " exceeds cap " + MAX_NOTES_LEN
|
|
282
278
|
);
|
|
283
279
|
}
|
|
284
|
-
|
|
285
|
-
throw new TypeError("disputeResolution: notes must not contain control bytes");
|
|
286
|
-
}
|
|
280
|
+
textGuard.freeText(s, "disputeResolution: notes", { bidiMarks: "allow" });
|
|
287
281
|
return s;
|
|
288
282
|
}
|
|
289
283
|
|
|
@@ -307,9 +301,7 @@ function _writeoffReason(s) {
|
|
|
307
301
|
" exceeds cap " + MAX_WRITEOFF_REASON_LEN
|
|
308
302
|
);
|
|
309
303
|
}
|
|
310
|
-
|
|
311
|
-
throw new TypeError("disputeResolution: writeoff reason must not contain control bytes");
|
|
312
|
-
}
|
|
304
|
+
textGuard.freeText(s, "disputeResolution: writeoff reason", { bidiMarks: "allow" });
|
|
313
305
|
return s;
|
|
314
306
|
}
|
|
315
307
|
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
*/
|
|
67
67
|
|
|
68
68
|
var b = require("./vendor/blamejs");
|
|
69
|
+
var textGuard = require("./text-guard");
|
|
69
70
|
|
|
70
71
|
// ---- constants ----------------------------------------------------------
|
|
71
72
|
|
|
@@ -81,7 +82,6 @@ var FORWARDING_STATUSES = Object.freeze([
|
|
|
81
82
|
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
82
83
|
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
83
84
|
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
84
|
-
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
85
85
|
|
|
86
86
|
var MAX_RETURN_POLICY_LEN = 4000;
|
|
87
87
|
var MAX_VENDOR_REF_LEN = 256;
|
|
@@ -183,9 +183,7 @@ function _returnPolicy(s) {
|
|
|
183
183
|
if (typeof s !== "string" || !s.length || s.length > MAX_RETURN_POLICY_LEN) {
|
|
184
184
|
throw new TypeError("dropship-forwarding: return_policy must be a non-empty string <= " + MAX_RETURN_POLICY_LEN + " chars");
|
|
185
185
|
}
|
|
186
|
-
|
|
187
|
-
throw new TypeError("dropship-forwarding: return_policy must not contain control bytes");
|
|
188
|
-
}
|
|
186
|
+
textGuard.freeText(s, "dropship-forwarding: return_policy", { singleLine: "reject", bidiMarks: "allow" });
|
|
189
187
|
}
|
|
190
188
|
function _positiveInt(n, label) {
|
|
191
189
|
if (!Number.isInteger(n) || n <= 0) {
|
|
@@ -214,7 +212,7 @@ function _shortText(s, label, max) {
|
|
|
214
212
|
if (typeof s !== "string" || !s.length || s.length > max) {
|
|
215
213
|
throw new TypeError("dropship-forwarding: " + label + " must be a non-empty string <= " + max + " chars");
|
|
216
214
|
}
|
|
217
|
-
if (
|
|
215
|
+
if (textGuard.hasCodepointThreat(s, { singleLine: "reject" })) {
|
|
218
216
|
throw new TypeError("dropship-forwarding: " + label + " must not contain control bytes");
|
|
219
217
|
}
|
|
220
218
|
}
|
package/lib/dunning.js
CHANGED
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
*/
|
|
106
106
|
|
|
107
107
|
var b = require("./vendor/blamejs");
|
|
108
|
+
var textGuard = require("./text-guard");
|
|
108
109
|
// Framework constants (C.TIME duration helpers). Safe at module-eval —
|
|
109
110
|
// the index entry point exposes `framework` before the require cascade.
|
|
110
111
|
var C = b.constants;
|
|
@@ -130,10 +131,6 @@ var DEFAULT_LIST_LIMIT = 100;
|
|
|
130
131
|
var MS_PER_HOUR = C.TIME.hours(1);
|
|
131
132
|
|
|
132
133
|
var SLUG_RE = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
133
|
-
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
134
|
-
var ZERO_WIDTH_RE = new RegExp(
|
|
135
|
-
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
136
|
-
);
|
|
137
134
|
|
|
138
135
|
// ---- validators ---------------------------------------------------------
|
|
139
136
|
|
|
@@ -205,9 +202,7 @@ function _reason(s) {
|
|
|
205
202
|
if (s.length > MAX_REASON_LEN) {
|
|
206
203
|
throw new TypeError("dunning: reason must be <= " + MAX_REASON_LEN + " characters");
|
|
207
204
|
}
|
|
208
|
-
|
|
209
|
-
throw new TypeError("dunning: reason contains control / zero-width bytes");
|
|
210
|
-
}
|
|
205
|
+
textGuard.freeText(s, "dunning: reason", { zeroWidth: "reject", bidiMarks: "allow" });
|
|
211
206
|
return s;
|
|
212
207
|
}
|
|
213
208
|
|
package/lib/email-ab-tests.js
CHANGED
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
*/
|
|
106
106
|
|
|
107
107
|
var b = require("./vendor/blamejs");
|
|
108
|
+
var textGuard = require("./text-guard");
|
|
108
109
|
|
|
109
110
|
// ---- constants ----------------------------------------------------------
|
|
110
111
|
|
|
@@ -133,7 +134,6 @@ var DEFAULT_LIST_LIMIT = 50;
|
|
|
133
134
|
// but 1.96 is the de-facto convention for shipped dashboards.
|
|
134
135
|
var WILSON_Z = 1.96;
|
|
135
136
|
|
|
136
|
-
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
137
137
|
|
|
138
138
|
// ---- monotonic clock ----------------------------------------------------
|
|
139
139
|
//
|
|
@@ -165,9 +165,7 @@ function _title(s) {
|
|
|
165
165
|
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
166
166
|
throw new TypeError("emailABTests: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
167
167
|
}
|
|
168
|
-
|
|
169
|
-
throw new TypeError("emailABTests: title must not contain control bytes");
|
|
170
|
-
}
|
|
168
|
+
textGuard.freeText(s, "emailABTests: title", { bidiMarks: "allow" });
|
|
171
169
|
return s;
|
|
172
170
|
}
|
|
173
171
|
|
|
@@ -184,7 +182,7 @@ function _label(s, idx) {
|
|
|
184
182
|
throw new TypeError("emailABTests: variants[" + idx + "].label must be a non-empty string <= " +
|
|
185
183
|
MAX_LABEL_LEN + " chars");
|
|
186
184
|
}
|
|
187
|
-
if (
|
|
185
|
+
if (textGuard.hasCodepointThreat(s)) {
|
|
188
186
|
throw new TypeError("emailABTests: variants[" + idx + "].label must not contain control bytes");
|
|
189
187
|
}
|
|
190
188
|
return s;
|
|
@@ -223,9 +221,7 @@ function _recipientId(s) {
|
|
|
223
221
|
throw new TypeError("emailABTests: recipient_id must be a non-empty string <= " +
|
|
224
222
|
MAX_RECIPIENT_ID_LEN + " chars");
|
|
225
223
|
}
|
|
226
|
-
|
|
227
|
-
throw new TypeError("emailABTests: recipient_id must not contain control bytes");
|
|
228
|
-
}
|
|
224
|
+
textGuard.freeText(s, "emailABTests: recipient_id");
|
|
229
225
|
return s;
|
|
230
226
|
}
|
|
231
227
|
|
package/lib/email-campaigns.js
CHANGED
|
@@ -146,6 +146,7 @@ var DEFAULT_BATCH_SIZE = 100;
|
|
|
146
146
|
var RESOLVE_PAGE_LIMIT = 500; // matches mailingAudiences MAX_LIST_LIMIT
|
|
147
147
|
|
|
148
148
|
var b = require("./vendor/blamejs");
|
|
149
|
+
var textGuard = require("./text-guard");
|
|
149
150
|
// Framework duration constants (C.TIME.minutes(n) etc.). The index entry
|
|
150
151
|
// point exposes `framework` before the require cascade, so resolving this
|
|
151
152
|
// at module-eval is safe — same pattern lib/admin.js / lib/cart.js use.
|
|
@@ -164,8 +165,29 @@ var SEND_RATE_WINDOW_MS = C.TIME.minutes(1);
|
|
|
164
165
|
// Per-recipient broadcast outcomes recorded in email_campaign_sends.
|
|
165
166
|
var SEND_OUTCOMES = ["sent", "failed", "skipped_unsubscribed", "skipped_suppressed"];
|
|
166
167
|
|
|
168
|
+
// A link URL is rendered on one line and, in the plain-text part, is shown
|
|
169
|
+
// to the recipient verbatim — so it is screened against the shared codepoint
|
|
170
|
+
// policy rather than a local class. The class this replaced covered the
|
|
171
|
+
// zero-width family but not the bidi overrides, which let a campaign body
|
|
172
|
+
// carry a URL that displays as one host and resolves to another.
|
|
173
|
+
// `bidiMarks` too: prose legitimately carries a direction mark (an Arabic
|
|
174
|
+
// street name, a Hebrew review) and the shared screen permits them by default,
|
|
175
|
+
// but a URL is machine-plain — a mark inside one is only ever there to make the
|
|
176
|
+
// host read as something it is not.
|
|
177
|
+
var LINK_URL_POLICY = Object.freeze({
|
|
178
|
+
singleLine: "reject",
|
|
179
|
+
zeroWidth: "reject",
|
|
180
|
+
bidiMarks: "reject",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Free-form text folded into one ledger line. Stripped rather than refused —
|
|
184
|
+
// this runs on an error path that must not itself fail.
|
|
167
185
|
var CONTROL_BYTE_LINE_RE = /[\u0000-\u001f\u007f]/;
|
|
168
|
-
|
|
186
|
+
|
|
187
|
+
// Global twin, derived so the class is written once. `.replace` with an
|
|
188
|
+
// unglobbed regex rewrites only the FIRST match, and a mailer's error message
|
|
189
|
+
// can carry several control bytes — the rest would reach the stored ledger row.
|
|
190
|
+
var CONTROL_BYTE_LINE_RE_G = new RegExp(CONTROL_BYTE_LINE_RE.source, "g");
|
|
169
191
|
|
|
170
192
|
var STATUSES = ["draft", "scheduled", "sending", "sent", "paused", "cancelled"];
|
|
171
193
|
var EVENT_TYPES = ["delivered", "opened", "clicked", "bounced", "unsubscribed"];
|
|
@@ -190,7 +212,7 @@ function _esc(s) {
|
|
|
190
212
|
|
|
191
213
|
function _mdSafeLinkUrl(url) {
|
|
192
214
|
if (typeof url !== "string" || !url.length || url.length > 2048) return null;
|
|
193
|
-
if (
|
|
215
|
+
if (textGuard.hasCodepointThreat(url, LINK_URL_POLICY)) return null;
|
|
194
216
|
if (url.charCodeAt(0) === 47 /* "/" */) {
|
|
195
217
|
if (url.length > 1 && url.charCodeAt(1) === 47) return null;
|
|
196
218
|
if (url.indexOf("..") !== -1) return null;
|
|
@@ -389,10 +411,13 @@ function _validateSubject(s) {
|
|
|
389
411
|
if (s.length > MAX_SUBJECT_LEN) {
|
|
390
412
|
throw new TypeError("emailCampaigns: subject must be <= " + MAX_SUBJECT_LEN + " characters");
|
|
391
413
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
414
|
+
// A subject becomes a `Subject:` header, so a CR or LF in it is a header
|
|
415
|
+
// injection — the bytes after the break are read as another header. NUL goes
|
|
416
|
+
// with them: it truncates the field in some agents, which hides whatever
|
|
417
|
+
// follows. b.safeBuffer.assertHeaderSafe is the framework's check for a
|
|
418
|
+
// value destined for a `Name: value` field, and it refuses exactly that set.
|
|
419
|
+
return b.safeBuffer.assertHeaderSafe(
|
|
420
|
+
s, "subject", TypeError, "emailCampaigns: subject must not contain CR / LF / NUL");
|
|
396
421
|
}
|
|
397
422
|
|
|
398
423
|
function _validateBody(s, label) {
|
|
@@ -433,10 +458,9 @@ function _validateFromName(s) {
|
|
|
433
458
|
if (s.length > MAX_FROM_NAME_LEN) {
|
|
434
459
|
throw new TypeError("emailCampaigns: from_name must be <= " + MAX_FROM_NAME_LEN + " characters");
|
|
435
460
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
return s;
|
|
461
|
+
// Lands in the `From:` header — same header-injection surface as the subject.
|
|
462
|
+
return b.safeBuffer.assertHeaderSafe(
|
|
463
|
+
s, "from_name", TypeError, "emailCampaigns: from_name must not contain CR / LF / NUL");
|
|
440
464
|
}
|
|
441
465
|
|
|
442
466
|
function _validateTs(ts, label) {
|
|
@@ -481,10 +505,10 @@ function _validateReason(s) {
|
|
|
481
505
|
if (s.length > MAX_REASON_LEN) {
|
|
482
506
|
throw new TypeError("emailCampaigns: reason must be <= " + MAX_REASON_LEN + " characters");
|
|
483
507
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
508
|
+
// Operator-authored and surfaced in logs and the console rather than a mail
|
|
509
|
+
// header, but held to the same rule: a newline here forges a log line.
|
|
510
|
+
return b.safeBuffer.assertHeaderSafe(
|
|
511
|
+
s, "reason", TypeError, "emailCampaigns: reason must not contain CR / LF / NUL");
|
|
488
512
|
}
|
|
489
513
|
|
|
490
514
|
function _validateStatus(s, label) {
|
|
@@ -971,7 +995,7 @@ function create(opts) {
|
|
|
971
995
|
// strip control bytes (no CRLF / NUL into the stored row) and
|
|
972
996
|
// cap length. The recipient address is never echoed into the
|
|
973
997
|
// reason — only the mailer's own message.
|
|
974
|
-
var reason = String(sendErr && sendErr.message || sendErr).replace(
|
|
998
|
+
var reason = String(sendErr && sendErr.message || sendErr).replace(CONTROL_BYTE_LINE_RE_G, " ").slice(0, 280);
|
|
975
999
|
await _finalizeSend(row.slug, consentHash || emailHash, "failed", reason);
|
|
976
1000
|
continue;
|
|
977
1001
|
}
|
package/lib/email-warmup.js
CHANGED
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
// ---- constants ----------------------------------------------------------
|
|
94
94
|
|
|
95
95
|
var b = require("./vendor/blamejs");
|
|
96
|
+
var textGuard = require("./text-guard");
|
|
96
97
|
|
|
97
98
|
var C = b.constants;
|
|
98
99
|
|
|
@@ -118,10 +119,6 @@ var IPV4_RE = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4
|
|
|
118
119
|
var IPV6_RE = /^[0-9a-f:]+$/;
|
|
119
120
|
var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
120
121
|
|
|
121
|
-
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
122
|
-
var ZERO_WIDTH_RE = new RegExp(
|
|
123
|
-
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
124
|
-
);
|
|
125
122
|
|
|
126
123
|
var ALLOWED_STATUSES = Object.freeze(["active", "paused", "archived"]);
|
|
127
124
|
|
|
@@ -234,9 +231,7 @@ function _reason(s) {
|
|
|
234
231
|
if (s.length > MAX_REASON_LEN) {
|
|
235
232
|
throw new TypeError("emailWarmup: reason must be <= " + MAX_REASON_LEN + " characters");
|
|
236
233
|
}
|
|
237
|
-
|
|
238
|
-
throw new TypeError("emailWarmup: reason contains control / zero-width bytes");
|
|
239
|
-
}
|
|
234
|
+
textGuard.freeText(s, "emailWarmup: reason", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
|
|
240
235
|
return s;
|
|
241
236
|
}
|
|
242
237
|
|
package/lib/experiments.js
CHANGED
|
@@ -116,15 +116,10 @@ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
|
116
116
|
// title + hypothesis fields land into the operator dashboard, not the
|
|
117
117
|
// storefront, but the discipline is the same — strings reach the
|
|
118
118
|
// operator UI as inert text, never as live markup.
|
|
119
|
-
var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
|
|
120
|
-
var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
121
119
|
|
|
122
120
|
// Zero-width / direction-override family — mirrors the promo-banners
|
|
123
121
|
// + gift-options catalogues. Spelled with \u-escapes so ESLint's
|
|
124
122
|
// no-irregular-whitespace stays happy.
|
|
125
|
-
var ZERO_WIDTH_RE = new RegExp(
|
|
126
|
-
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
127
|
-
);
|
|
128
123
|
|
|
129
124
|
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
130
125
|
"title",
|
|
@@ -134,6 +129,7 @@ var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
|
134
129
|
]);
|
|
135
130
|
|
|
136
131
|
var b = require("./vendor/blamejs");
|
|
132
|
+
var textGuard = require("./text-guard");
|
|
137
133
|
|
|
138
134
|
// ---- validators ---------------------------------------------------------
|
|
139
135
|
|
|
@@ -149,10 +145,10 @@ function _line(s, label, maxLen) {
|
|
|
149
145
|
if (typeof s !== "string" || !s.length || s.length > maxLen) {
|
|
150
146
|
throw new TypeError("experiments: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
|
|
151
147
|
}
|
|
152
|
-
if (
|
|
148
|
+
if (textGuard.hasCodepointThreat(s, { singleLine: "reject" })) {
|
|
153
149
|
throw new TypeError("experiments: " + label + " contains control bytes (incl. CR/LF)");
|
|
154
150
|
}
|
|
155
|
-
if (
|
|
151
|
+
if (textGuard.hasCodepointThreat(s, { zeroWidth: "reject" })) {
|
|
156
152
|
throw new TypeError("experiments: " + label + " contains zero-width / direction-override characters");
|
|
157
153
|
}
|
|
158
154
|
return s;
|
|
@@ -162,10 +158,10 @@ function _block(s, label, maxLen) {
|
|
|
162
158
|
if (typeof s !== "string" || !s.length || s.length > maxLen) {
|
|
163
159
|
throw new TypeError("experiments: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
|
|
164
160
|
}
|
|
165
|
-
if (
|
|
161
|
+
if (textGuard.hasCodepointThreat(s)) {
|
|
166
162
|
throw new TypeError("experiments: " + label + " contains control bytes");
|
|
167
163
|
}
|
|
168
|
-
if (
|
|
164
|
+
if (textGuard.hasCodepointThreat(s, { zeroWidth: "reject" })) {
|
|
169
165
|
throw new TypeError("experiments: " + label + " contains zero-width / direction-override characters");
|
|
170
166
|
}
|
|
171
167
|
return s;
|
|
@@ -437,7 +433,7 @@ function create(opts) {
|
|
|
437
433
|
if (typeof input.metric !== "string" || !input.metric.length || input.metric.length > MAX_METRIC_LEN) {
|
|
438
434
|
return { recorded: false };
|
|
439
435
|
}
|
|
440
|
-
if (
|
|
436
|
+
if (textGuard.hasCodepointThreat(input.metric, { singleLine: "reject", zeroWidth: "reject" })) {
|
|
441
437
|
return { recorded: false };
|
|
442
438
|
}
|
|
443
439
|
var value = 1;
|
package/lib/externaldb-d1.js
CHANGED
|
@@ -125,46 +125,62 @@ function _timeoutErr(ms, label) {
|
|
|
125
125
|
return e;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// The attempt loop is b.retry.withRetry — exponential backoff, jitter, and
|
|
129
|
+
// the final rethrow — with two things this adapter has to supply.
|
|
130
|
+
//
|
|
131
|
+
// The classifier is ours. The framework's default reads `err.code` and
|
|
132
|
+
// `err.statusCode` off the error itself, and undici does not put them there:
|
|
133
|
+
// a transport failure arrives as TypeError("fetch failed") carrying the real
|
|
134
|
+
// code on `.cause`. Its retryable set also omits EAI_AGAIN and UND_ERR_SOCKET,
|
|
135
|
+
// which are precisely the DNS and socket failures this adapter exists to ride
|
|
136
|
+
// out. Handing it the default classifier would leave the loop in place and
|
|
137
|
+
// silently stop retrying anything that matters.
|
|
138
|
+
//
|
|
139
|
+
// The HTTP arm has to throw to be seen at all. b.httpClient and fetch both
|
|
140
|
+
// resolve a 5xx as an ordinary response, so a retryable status is raised
|
|
141
|
+
// inside the attempt and the response handed back at the boundary once the
|
|
142
|
+
// retries are spent — the caller still inspects `res.status` itself.
|
|
128
143
|
async function _retryingFetch(fetchImpl, url, init, attempts, label) {
|
|
129
|
-
var
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
throw e;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
throw lastErr;
|
|
144
|
+
var received = null;
|
|
145
|
+
return b.retry.withRetry(async function () {
|
|
146
|
+
var res = await fetchImpl(url, init);
|
|
147
|
+
received = res;
|
|
148
|
+
// Retry only on 5xx / 429; a 4xx is the request's own fault.
|
|
149
|
+
if (res.status >= 500 || res.status === 429) throw _httpErr(res.status, label);
|
|
150
|
+
return res;
|
|
151
|
+
}, {
|
|
152
|
+
maxAttempts: attempts + 1, // `attempts` counts RETRIES, not tries
|
|
153
|
+
baseDelayMs: RETRY_BACKOFF_MS,
|
|
154
|
+
isRetryable: _isTransient,
|
|
155
|
+
}).catch(function (e) {
|
|
156
|
+
// Retries exhausted on a status we did receive: give the caller the
|
|
157
|
+
// response so it reports the upstream's own error, exactly as the previous
|
|
158
|
+
// loop did. A transport failure has no response and propagates.
|
|
159
|
+
if (received && e && e.status === received.status) return received;
|
|
160
|
+
throw e;
|
|
161
|
+
});
|
|
152
162
|
}
|
|
153
163
|
|
|
164
|
+
// Which failures are worth another attempt. Both arms matter and neither is
|
|
165
|
+
// what the framework's default classifier looks at.
|
|
166
|
+
//
|
|
167
|
+
// Transport: undici reports a network failure as TypeError("fetch failed")
|
|
168
|
+
// with the real code nested on `.cause`, so the code is read from either
|
|
169
|
+
// place. EAI_AGAIN (transient DNS) and UND_ERR_SOCKET (socket torn down
|
|
170
|
+
// mid-request) are the two this adapter most needs and the two the framework's
|
|
171
|
+
// set leaves out.
|
|
172
|
+
//
|
|
173
|
+
// HTTP: a 5xx or 429 is raised as an error inside the attempt so the loop can
|
|
174
|
+
// see it at all — fetch resolves those as ordinary responses. `_httpErr`
|
|
175
|
+
// records the status on `.status`, so that is what is read here. A 4xx is the
|
|
176
|
+
// request's own fault and is never retried.
|
|
154
177
|
function _isTransient(err) {
|
|
155
178
|
if (!err) return false;
|
|
179
|
+
if (typeof err.status === "number") return err.status >= 500 || err.status === 429;
|
|
156
180
|
var c = err.code || (err.cause && err.cause.code);
|
|
157
181
|
return c === "ECONNRESET" || c === "ETIMEDOUT" || c === "ENOTFOUND" || c === "EAI_AGAIN" || c === "UND_ERR_SOCKET";
|
|
158
182
|
}
|
|
159
183
|
|
|
160
|
-
function _backoff(attempt) {
|
|
161
|
-
// Jittered exponential — 200ms, 400ms, ... with ±25% jitter so
|
|
162
|
-
// retries from many container replicas don't synchronize.
|
|
163
|
-
var base = RETRY_BACKOFF_MS * Math.pow(2, attempt);
|
|
164
|
-
var jitter = base * (0.75 + (b.crypto.randomInt(0, 50) / 100));
|
|
165
|
-
return new Promise(function (r) { setTimeout(r, jitter); });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
184
|
function _httpErr(status, label) {
|
|
169
185
|
var e = new Error("externaldbD1: " + label + " failed with HTTP " + status);
|
|
170
186
|
e.code = "D1_HTTP_" + status;
|
package/lib/geolocation.js
CHANGED
|
@@ -254,34 +254,24 @@ function _now() { return Date.now(); }
|
|
|
254
254
|
// through to the country's default_locale.
|
|
255
255
|
function _parseAcceptLanguage(raw) {
|
|
256
256
|
if (typeof raw !== "string" || !raw.length) return [];
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
if (tag === "*") continue;
|
|
276
|
-
if (!LOCALE_RE.test(tag)) continue;
|
|
277
|
-
parsed.push({ tag: tag, q: q, order: i });
|
|
278
|
-
}
|
|
279
|
-
// Stable q-desc sort (preserve original order on ties).
|
|
280
|
-
parsed.sort(function (a, b) {
|
|
281
|
-
if (a.q !== b.q) return b.q - a.q;
|
|
282
|
-
return a.order - b.order;
|
|
283
|
-
});
|
|
284
|
-
return parsed;
|
|
257
|
+
// b.requestHelpers.parseQualityList is the RFC 9110 §12.5 parser, and the
|
|
258
|
+
// edge bypass check reads the same header through it — which is what keeps
|
|
259
|
+
// the two agreeing on which tag wins. They had drifted: a header spelling
|
|
260
|
+
// the parameter `Q=0.9` was read correctly here and ignored at the edge, so
|
|
261
|
+
// a visitor sending it got one locale decided at the edge and another here.
|
|
262
|
+
//
|
|
263
|
+
// `caseSensitive` because these are BCP 47 tags and the chosen one is
|
|
264
|
+
// returned to the caller — a browser asking for `de-AT` should get `de-AT`
|
|
265
|
+
// back, not `de-at`. Matching lowercases separately in _primarySubtag.
|
|
266
|
+
//
|
|
267
|
+
// A tag carrying `q=0` is dropped rather than ranked last: q=0 means "not
|
|
268
|
+
// acceptable", and keeping it meant a locale the visitor had explicitly
|
|
269
|
+
// ruled out could still be selected when nothing else matched.
|
|
270
|
+
return b.requestHelpers.parseQualityList(raw, { caseSensitive: true })
|
|
271
|
+
.filter(function (e) {
|
|
272
|
+
return e.value !== "*" && e.q > 0 && LOCALE_RE.test(e.value);
|
|
273
|
+
})
|
|
274
|
+
.map(function (e) { return { tag: e.value, q: e.q }; });
|
|
285
275
|
}
|
|
286
276
|
|
|
287
277
|
function _primarySubtag(tag) {
|