@blamejs/blamejs-shop 0.3.50 → 0.3.52
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 +10 -1
- package/SECURITY.md +58 -0
- package/lib/admin.js +159 -1
- package/lib/asset-manifest.json +17 -5
- package/lib/security-middleware.js +170 -7
- package/lib/storefront.js +277 -66
- package/lib/vendor/MANIFEST.json +2665 -2
- package/package.json +2 -1
package/lib/storefront.js
CHANGED
|
@@ -6287,8 +6287,14 @@ function renderCheckoutForm(opts) {
|
|
|
6287
6287
|
var grandTotal = pricing.format(
|
|
6288
6288
|
dTotals.grand_total_minor == null ? dTotals.subtotal_minor : dTotals.grand_total_minor,
|
|
6289
6289
|
dTotals.currency);
|
|
6290
|
+
// CAPTCHA widget block (no-op unless a provider is active for checkout).
|
|
6291
|
+
// Computed BEFORE the themed early-return so a themed checkout gets the
|
|
6292
|
+
// same challenge as the default render — otherwise a configured provider
|
|
6293
|
+
// would render a themed form with no widget yet have POST /checkout fail
|
|
6294
|
+
// closed on the missing token, breaking every themed-store checkout.
|
|
6295
|
+
var checkoutCaptcha = _captchaWidgetBlock(opts.captcha_kind, opts.captcha_public_key, true);
|
|
6290
6296
|
if (opts.theme) {
|
|
6291
|
-
|
|
6297
|
+
var themed = opts.theme.render("checkout", {
|
|
6292
6298
|
title: "Checkout",
|
|
6293
6299
|
shop_name: shopName,
|
|
6294
6300
|
cart_count: lines.length,
|
|
@@ -6296,6 +6302,8 @@ function renderCheckoutForm(opts) {
|
|
|
6296
6302
|
total: grandTotal,
|
|
6297
6303
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
6298
6304
|
});
|
|
6305
|
+
if (checkoutCaptcha) themed = themed.replace("</form>", checkoutCaptcha + "</form>");
|
|
6306
|
+
return themed;
|
|
6299
6307
|
}
|
|
6300
6308
|
// Order-summary line items in the sticky rail — same thumbnail + title
|
|
6301
6309
|
// pattern as the cart, compact. Formatted in the order's own currency
|
|
@@ -6322,6 +6330,15 @@ function renderCheckoutForm(opts) {
|
|
|
6322
6330
|
.replace("RAW_INLINE_ERROR", inlineError)
|
|
6323
6331
|
.replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill))
|
|
6324
6332
|
.replace("RAW_SUMMARY_LINES", summaryLines);
|
|
6333
|
+
// CAPTCHA challenge inside the form — when the operator has an active
|
|
6334
|
+
// provider (opts.captcha_kind + opts.captcha_public_key set), the widget
|
|
6335
|
+
// renders just above the submit button so its hidden token field rides the
|
|
6336
|
+
// form POST. Absent a provider this is "" — the checkout is byte-identical
|
|
6337
|
+
// to the unconfigured store. The route-scoped CSP that admits the provider
|
|
6338
|
+
// SDK host is set by the GET/POST /checkout handlers.
|
|
6339
|
+
if (checkoutCaptcha) {
|
|
6340
|
+
body = body.replace("</form>", checkoutCaptcha + "</form>");
|
|
6341
|
+
}
|
|
6325
6342
|
// Signed-in customer with a spendable points balance — surface a
|
|
6326
6343
|
// redeem-at-checkout field. The block is appended as raw HTML (the
|
|
6327
6344
|
// balance + value are numbers we control, the conversion ratio is the
|
|
@@ -6410,35 +6427,78 @@ function renderGiftCardBalance(opts) {
|
|
|
6410
6427
|
});
|
|
6411
6428
|
}
|
|
6412
6429
|
|
|
6430
|
+
// PayPal Smart Buttons block, appended to the checkout form when PayPal is
|
|
6431
|
+
// configured. The SDK `<script src>` is admitted by the checkout page's
|
|
6432
|
+
// route-scoped CSP (the handler adds the "paypal" host set when
|
|
6433
|
+
// deps.paypal_client_id is set); the create/capture glue lives in the
|
|
6434
|
+
// external same-origin `paypal-checkout.js` island — NO inline `<script>`.
|
|
6435
|
+
// client-id + currency ride attribute-escaped `data-*` attributes the island
|
|
6436
|
+
// reads, never interpolated into executable script.
|
|
6413
6437
|
function _paypalCheckoutBlock(clientId, currency) {
|
|
6414
6438
|
var esc = b.template.escapeHtml;
|
|
6415
6439
|
var cid = esc(String(clientId));
|
|
6416
6440
|
var cur = esc(String(currency || "USD"));
|
|
6417
|
-
return "\n<div class=\"checkout-paypal\" style=\"max-width:32rem;margin:1.5rem auto 0;\">" +
|
|
6441
|
+
return "\n<div class=\"checkout-paypal\" id=\"paypal-island\" data-paypal-client-id=\"" + cid + "\" data-currency=\"" + cur + "\" style=\"max-width:32rem;margin:1.5rem auto 0;\">" +
|
|
6418
6442
|
"<div class=\"pay-card__divider\"><span>or pay with PayPal</span></div>" +
|
|
6419
6443
|
"<div id=\"paypal-button-container\"></div>" +
|
|
6420
6444
|
"<p id=\"paypal-error\" class=\"auth-form__message auth-form__message--err\" hidden></p>" +
|
|
6421
6445
|
"</div>" +
|
|
6422
6446
|
"<script src=\"https://www.paypal.com/sdk/js?client-id=" + cid + "¤cy=" + cur + "&intent=capture\"></script>" +
|
|
6423
|
-
"
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
"
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
}
|
|
6437
|
-
|
|
6438
|
-
//
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6447
|
+
_islandScript("paypal-checkout.js") + "\n";
|
|
6448
|
+
}
|
|
6449
|
+
|
|
6450
|
+
// CAPTCHA provider host the provider's challenge SDK is loaded from. The
|
|
6451
|
+
// kind→URL map mirrors the well-known siteverify/api.js endpoints. Only the
|
|
6452
|
+
// `api.js` SDK is rendered here; the siteverify call is server-side egress
|
|
6453
|
+
// (server.js captchaVerify). All four kinds are covered so any operator-
|
|
6454
|
+
// registered provider renders without a code change.
|
|
6455
|
+
var _CAPTCHA_SDK = {
|
|
6456
|
+
turnstile: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
|
6457
|
+
hcaptcha: "https://js.hcaptcha.com/1/api.js",
|
|
6458
|
+
recaptcha_v2: "https://www.google.com/recaptcha/api.js?render=explicit",
|
|
6459
|
+
recaptcha_v3: "https://www.google.com/recaptcha/api.js?render=explicit",
|
|
6460
|
+
};
|
|
6461
|
+
|
|
6462
|
+
// Map a captcha kind to the CSP_HOSTS key that admits its SDK host.
|
|
6463
|
+
function _captchaCspKey(kind) {
|
|
6464
|
+
if (kind === "recaptcha_v2" || kind === "recaptcha_v3") return "recaptcha";
|
|
6465
|
+
return kind; // turnstile | hcaptcha
|
|
6466
|
+
}
|
|
6467
|
+
|
|
6468
|
+
// The CAPTCHA widget block: a mount div carrying the (escaped) kind +
|
|
6469
|
+
// sitekey, the provider SDK `<script src>` (admitted by the route-scoped
|
|
6470
|
+
// CSP the handler sets when a provider is active), the external `captcha.js`
|
|
6471
|
+
// island that renders the widget + exposes the token, and a hidden field so
|
|
6472
|
+
// the token rides a plain form POST (checkout). `withField` is false for the
|
|
6473
|
+
// JSON-ceremony pages (login/register), which read the token via
|
|
6474
|
+
// window.__captchaToken() instead. Returns "" when no provider is active —
|
|
6475
|
+
// the unconfigured-store no-op.
|
|
6476
|
+
function _captchaWidgetBlock(kind, sitekey, withField) {
|
|
6477
|
+
if (!kind || !sitekey) return "";
|
|
6478
|
+
var esc = b.template.escapeHtml;
|
|
6479
|
+
var sdk = _CAPTCHA_SDK[kind];
|
|
6480
|
+
if (!sdk) return "";
|
|
6481
|
+
var hidden = withField
|
|
6482
|
+
? "<input type=\"hidden\" name=\"captcha_token\" id=\"captcha-token-field\" value=\"\">"
|
|
6483
|
+
: "";
|
|
6484
|
+
return "\n<div class=\"captcha-block\">" +
|
|
6485
|
+
"<div id=\"captcha-widget\" data-captcha-kind=\"" + esc(String(kind)) + "\" data-sitekey=\"" + esc(String(sitekey)) + "\"></div>" +
|
|
6486
|
+
hidden +
|
|
6487
|
+
"</div>" +
|
|
6488
|
+
"<script src=\"" + esc(sdk) + "\" async defer></script>" +
|
|
6489
|
+
_islandScript("captcha.js") + "\n";
|
|
6490
|
+
}
|
|
6491
|
+
|
|
6492
|
+
// Stripe Elements payment page — loads Stripe.js (the only third-party
|
|
6493
|
+
// script, admitted by the route-scoped CSP set on GET /pay/:order_id) and
|
|
6494
|
+
// an external same-origin island (`pay.js`) that mounts the Payment Element.
|
|
6495
|
+
// No inline `<script>`: the strict default CSP's
|
|
6496
|
+
// `require-trusted-types-for 'script'` + the absence of `'unsafe-inline'`
|
|
6497
|
+
// would block one. The publishable key (operator-supplied env
|
|
6498
|
+
// `STRIPE_PUBLISHABLE_KEY`) and the per-order PaymentIntent client_secret
|
|
6499
|
+
// ride HTML-attribute-escaped `data-*` attributes on the mount div — read
|
|
6500
|
+
// by the island, never interpolated into executable script. The
|
|
6501
|
+
// client_secret is per-order; never logged, never persisted.
|
|
6442
6502
|
var PAY_PAGE =
|
|
6443
6503
|
"<section class=\"pay-page\">\n" +
|
|
6444
6504
|
" <header class=\"section-head\">\n" +
|
|
@@ -6446,7 +6506,7 @@ var PAY_PAGE =
|
|
|
6446
6506
|
" <h1 class=\"section-head__title\">Pay {{grand_total}}</h1>\n" +
|
|
6447
6507
|
" <p class=\"section-head__lede\">Order <code class=\"inline-code\">{{order_id}}</code> · the Stripe Payment Element is mounted below in a same-origin form.</p>\n" +
|
|
6448
6508
|
" </header>\n" +
|
|
6449
|
-
" <div class=\"pay-card\">\n" +
|
|
6509
|
+
" <div class=\"pay-card\" id=\"pay-island\" data-pk=\"{{pk}}\" data-cs=\"{{client_secret}}\" data-order=\"{{order_id}}\">\n" +
|
|
6450
6510
|
" <div id=\"express-checkout\" class=\"pay-card__express\" hidden>\n" +
|
|
6451
6511
|
" <div id=\"express-checkout-element\"></div>\n" +
|
|
6452
6512
|
" <div class=\"pay-card__divider\"><span>or pay with card</span></div>\n" +
|
|
@@ -6456,35 +6516,7 @@ var PAY_PAGE =
|
|
|
6456
6516
|
" <p id=\"payment-message\" class=\"pay-card__message\"></p>\n" +
|
|
6457
6517
|
" </div>\n" +
|
|
6458
6518
|
" <script src=\"https://js.stripe.com/v3/\"></script>\n" +
|
|
6459
|
-
"
|
|
6460
|
-
" (function () {\n" +
|
|
6461
|
-
" var stripe = Stripe({{pk_json}});\n" +
|
|
6462
|
-
" var elements = stripe.elements({ clientSecret: {{client_secret_json}}, appearance: { theme: \"stripe\" } });\n" +
|
|
6463
|
-
" var returnUrl = window.location.origin + \"/orders/{{order_id}}\";\n" +
|
|
6464
|
-
" var message = document.getElementById(\"payment-message\");\n" +
|
|
6465
|
-
" function confirm() {\n" +
|
|
6466
|
-
" message.textContent = \"Processing...\";\n" +
|
|
6467
|
-
" return stripe.confirmPayment({ elements: elements, confirmParams: { return_url: returnUrl } }).then(function (result) {\n" +
|
|
6468
|
-
" if (result.error) { message.textContent = result.error.message || \"Payment failed.\"; }\n" +
|
|
6469
|
-
" });\n" +
|
|
6470
|
-
" }\n" +
|
|
6471
|
-
" // Express Checkout Element — renders Apple Pay / Google Pay /\n" +
|
|
6472
|
-
" // Link wallet buttons when the device + the shop's registered\n" +
|
|
6473
|
-
" // payment-method domain make them available. It confirms the\n" +
|
|
6474
|
-
" // same PaymentIntent as the card form, so the webhook + order\n" +
|
|
6475
|
-
" // FSM are identical. Hidden until Stripe reports an available\n" +
|
|
6476
|
-
" // wallet so the divider never sits over an empty box.\n" +
|
|
6477
|
-
" var ece = elements.create(\"expressCheckout\");\n" +
|
|
6478
|
-
" ece.on(\"ready\", function (ev) {\n" +
|
|
6479
|
-
" if (ev && ev.availablePaymentMethods) { document.getElementById(\"express-checkout\").hidden = false; }\n" +
|
|
6480
|
-
" });\n" +
|
|
6481
|
-
" ece.on(\"confirm\", function () { confirm(); });\n" +
|
|
6482
|
-
" ece.mount(\"#express-checkout-element\");\n" +
|
|
6483
|
-
" var paymentElement = elements.create(\"payment\");\n" +
|
|
6484
|
-
" paymentElement.mount(\"#payment-element\");\n" +
|
|
6485
|
-
" document.getElementById(\"submit\").addEventListener(\"click\", function () { confirm(); });\n" +
|
|
6486
|
-
" })();\n" +
|
|
6487
|
-
" </script>\n" +
|
|
6519
|
+
" RAW_PAY_SCRIPT\n" +
|
|
6488
6520
|
"</section>\n";
|
|
6489
6521
|
|
|
6490
6522
|
function renderPayPage(opts) {
|
|
@@ -6494,13 +6526,10 @@ function renderPayPage(opts) {
|
|
|
6494
6526
|
var shopName = opts.shop_name || "blamejs.shop";
|
|
6495
6527
|
var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
|
|
6496
6528
|
var grandTotal = pricing.format(opts.order.grand_total_minor, opts.order.currency);
|
|
6497
|
-
//
|
|
6498
|
-
//
|
|
6499
|
-
//
|
|
6500
|
-
//
|
|
6501
|
-
// concatenation possible at this layer.
|
|
6502
|
-
var pkJson = JSON.stringify(opts.publishable_key);
|
|
6503
|
-
var secretJson = JSON.stringify(opts.client_secret);
|
|
6529
|
+
// The publishable key + per-order client_secret ride HTML-attribute-
|
|
6530
|
+
// escaped `data-*` attributes on the mount div, read by the external
|
|
6531
|
+
// `pay.js` island — no inline `<script>`, no JS-literal interpolation. The
|
|
6532
|
+
// route-scoped CSP set on GET /pay/:order_id admits js.stripe.com.
|
|
6504
6533
|
if (opts.theme) {
|
|
6505
6534
|
return opts.theme.render("pay", {
|
|
6506
6535
|
title: "Pay",
|
|
@@ -6508,18 +6537,21 @@ function renderPayPage(opts) {
|
|
|
6508
6537
|
cart_count: cartCount,
|
|
6509
6538
|
order_id: opts.order.id,
|
|
6510
6539
|
grand_total: grandTotal,
|
|
6511
|
-
|
|
6512
|
-
|
|
6540
|
+
pk: opts.publishable_key,
|
|
6541
|
+
client_secret: opts.client_secret,
|
|
6542
|
+
pay_script: opts.theme.assetUrl("js/pay.js"),
|
|
6513
6543
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
6514
6544
|
});
|
|
6515
6545
|
}
|
|
6546
|
+
// {{pk}} / {{client_secret}} / {{order_id}} go through _render's
|
|
6547
|
+
// escape-by-default path (HTML-attribute-escaped), NOT the old RAW_*
|
|
6548
|
+
// JS-literal splice — they're attribute values now, not script literals.
|
|
6516
6549
|
var body = _render(PAY_PAGE, {
|
|
6517
6550
|
order_id: opts.order.id,
|
|
6518
6551
|
grand_total: grandTotal,
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
}).replace("
|
|
6522
|
-
.replace("RAW_SECRET", secretJson);
|
|
6552
|
+
pk: opts.publishable_key,
|
|
6553
|
+
client_secret: opts.client_secret,
|
|
6554
|
+
}).replace("RAW_PAY_SCRIPT", _islandScript("pay.js"));
|
|
6523
6555
|
return _wrap({
|
|
6524
6556
|
title: "Pay",
|
|
6525
6557
|
shop_name: shopName,
|
|
@@ -8059,6 +8091,7 @@ var ACCOUNT_LOGIN_PAGE =
|
|
|
8059
8091
|
" RAW_LOGIN_ERROR\n" +
|
|
8060
8092
|
" <form id=\"login-form\" method=\"post\" class=\"form-stack auth-form\">\n" +
|
|
8061
8093
|
" <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span><input type=\"email\" name=\"email\" id=\"email\" required autocomplete=\"email\" autofocus></label></div>\n" +
|
|
8094
|
+
" RAW_LOGIN_CAPTCHA\n" +
|
|
8062
8095
|
" <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Sign in with passkey</button></div>\n" +
|
|
8063
8096
|
" <p id=\"login-message\" class=\"auth-form__message\"></p>\n" +
|
|
8064
8097
|
" </form>\n" +
|
|
@@ -8094,6 +8127,11 @@ function renderAccountLogin(opts) {
|
|
|
8094
8127
|
var body = ACCOUNT_LOGIN_PAGE
|
|
8095
8128
|
.replace("RAW_LOGIN_OAUTH", oauthHtml)
|
|
8096
8129
|
.replace("RAW_LOGIN_ERROR", errHtml)
|
|
8130
|
+
// Login captcha is gated separately (CAPTCHA_GATE_LOGIN): the widget
|
|
8131
|
+
// renders only when the operator has a provider active AND opted login
|
|
8132
|
+
// in (opts.captcha_kind set). For the JSON ceremony the token is read by
|
|
8133
|
+
// passkey-login.js via window.__captchaToken() — no hidden field.
|
|
8134
|
+
.replace("RAW_LOGIN_CAPTCHA", _captchaWidgetBlock(opts.captcha_kind, opts.captcha_public_key, false))
|
|
8097
8135
|
.replace("RAW_LOGIN_SCRIPT", _islandScript("passkey-login.js"));
|
|
8098
8136
|
return _wrap({
|
|
8099
8137
|
title: "Sign in",
|
|
@@ -8113,6 +8151,7 @@ var ACCOUNT_REGISTER_PAGE =
|
|
|
8113
8151
|
" <form id=\"reg-form\" method=\"post\" class=\"form-stack auth-form\">\n" +
|
|
8114
8152
|
" <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span><input type=\"email\" name=\"email\" id=\"email\" required autocomplete=\"email\" autofocus></label></div>\n" +
|
|
8115
8153
|
" <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Display name</span><input type=\"text\" name=\"display_name\" id=\"display_name\" maxlength=\"128\" required autocomplete=\"name\"></label></div>\n" +
|
|
8154
|
+
" RAW_REGISTER_CAPTCHA\n" +
|
|
8116
8155
|
" <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Create account & enroll passkey</button></div>\n" +
|
|
8117
8156
|
" <p id=\"reg-message\" class=\"auth-form__message\"></p>\n" +
|
|
8118
8157
|
" </form>\n" +
|
|
@@ -8123,12 +8162,19 @@ var ACCOUNT_REGISTER_PAGE =
|
|
|
8123
8162
|
|
|
8124
8163
|
function renderAccountRegister(opts) {
|
|
8125
8164
|
opts = opts || {};
|
|
8165
|
+
// Signup captcha renders whenever the operator has an active provider
|
|
8166
|
+
// (opts.captcha_kind + opts.captcha_public_key). The token is read by
|
|
8167
|
+
// passkey-register.js via window.__captchaToken() — no hidden field on
|
|
8168
|
+
// the JSON ceremony. Absent a provider this is "" (unconfigured no-op).
|
|
8169
|
+
var body = ACCOUNT_REGISTER_PAGE
|
|
8170
|
+
.replace("RAW_REGISTER_CAPTCHA", _captchaWidgetBlock(opts.captcha_kind, opts.captcha_public_key, false))
|
|
8171
|
+
.replace("RAW_REGISTER_SCRIPT", _islandScript("passkey-register.js"));
|
|
8126
8172
|
return _wrap({
|
|
8127
8173
|
title: "Create account",
|
|
8128
8174
|
shop_name: opts.shop_name || "blamejs.shop",
|
|
8129
8175
|
cart_count: opts.cart_count,
|
|
8130
8176
|
theme_css: opts.theme_css,
|
|
8131
|
-
body:
|
|
8177
|
+
body: body,
|
|
8132
8178
|
});
|
|
8133
8179
|
}
|
|
8134
8180
|
|
|
@@ -8880,6 +8926,89 @@ function mount(router, deps) {
|
|
|
8880
8926
|
// launch flow converts the reservation into a Stripe-gated order).
|
|
8881
8927
|
var preorder = deps.preorder || null;
|
|
8882
8928
|
|
|
8929
|
+
// CAPTCHA gate (bot challenge at signup / login / checkout). Active ONLY
|
|
8930
|
+
// when the operator has registered a provider AND set CAPTCHA_PROVIDER_SLUG
|
|
8931
|
+
// (server.js resolves the provider row at boot into captchaKind +
|
|
8932
|
+
// captchaPublicKey). Absent any of that, every helper below is a no-op:
|
|
8933
|
+
// no widget renders, no token is verified, and the flows behave EXACTLY as
|
|
8934
|
+
// an unconfigured store. Signup + checkout challenge whenever a provider is
|
|
8935
|
+
// active; login challenges only when the operator also opts in
|
|
8936
|
+
// (deps.captchaLoginEnabled, the CAPTCHA_GATE_LOGIN flag) — passkey login
|
|
8937
|
+
// is already phishing-resistant.
|
|
8938
|
+
var captchaGate = deps.captchaGate || null;
|
|
8939
|
+
var captchaSlug = deps.captchaProviderSlug || "";
|
|
8940
|
+
var captchaKind = deps.captchaKind || "";
|
|
8941
|
+
var captchaPubKey = deps.captchaPublicKey || "";
|
|
8942
|
+
var captchaVerify = (typeof deps.captchaVerify === "function") ? deps.captchaVerify : null;
|
|
8943
|
+
var captchaActive = !!(captchaGate && captchaSlug && captchaKind && captchaPubKey && captchaVerify);
|
|
8944
|
+
var captchaLoginOn = captchaActive && !!deps.captchaLoginEnabled;
|
|
8945
|
+
|
|
8946
|
+
// The CSP_HOSTS key (turnstile / hcaptcha / recaptcha) that admits the
|
|
8947
|
+
// active provider's SDK host on the scoped CSP, or null when inactive.
|
|
8948
|
+
var captchaCspKey = captchaActive ? _captchaCspKey(captchaKind) : null;
|
|
8949
|
+
|
|
8950
|
+
// Verify a submitted captcha token for a gate, recording the outcome.
|
|
8951
|
+
// Returns { ok, status, message } — ok=true means proceed; ok=false means
|
|
8952
|
+
// refuse with the given status + clean message. A no-op pass-through
|
|
8953
|
+
// (ok=true) when the gate is inactive for this flow, so callers wrap the
|
|
8954
|
+
// verify unconditionally and the unconfigured store never changes. The
|
|
8955
|
+
// session/ip hashing is the primitive's expectation (pre-hashed values);
|
|
8956
|
+
// we pass the raw session id (recordOutcome hashes it) + a hashed ip.
|
|
8957
|
+
async function _verifyCaptcha(req, gate, token) {
|
|
8958
|
+
if (!captchaActive) return { ok: true };
|
|
8959
|
+
// Inactive for login unless the operator opted in.
|
|
8960
|
+
if (gate === "other" && !captchaLoginOn) return { ok: true };
|
|
8961
|
+
var raw = (typeof token === "string") ? token.trim() : "";
|
|
8962
|
+
// Defensive request-shape reader: a missing / blank token fails CLOSED at
|
|
8963
|
+
// the caller — never hand a placeholder to the provider (whose acceptance
|
|
8964
|
+
// we don't control). Recorded as a failed outcome, refused with a clean
|
|
8965
|
+
// message. This is independent of provider behavior.
|
|
8966
|
+
var outcome;
|
|
8967
|
+
if (!raw) {
|
|
8968
|
+
outcome = { ok: false, score: null, reasons: ["missing_token"] };
|
|
8969
|
+
} else {
|
|
8970
|
+
try {
|
|
8971
|
+
outcome = await captchaGate.verifyToken({
|
|
8972
|
+
provider_slug: captchaSlug,
|
|
8973
|
+
token: raw,
|
|
8974
|
+
verify: captchaVerify,
|
|
8975
|
+
});
|
|
8976
|
+
} catch (_e) {
|
|
8977
|
+
// A bad provider-slug shape / config fault → "unavailable", same
|
|
8978
|
+
// posture as vault/not-initialized (503) rather than a raw leak.
|
|
8979
|
+
return { ok: false, status: 503, message: "Verification is temporarily unavailable. Please try again." };
|
|
8980
|
+
}
|
|
8981
|
+
}
|
|
8982
|
+
// Best-effort audit row; never blocks the request on a write failure.
|
|
8983
|
+
try {
|
|
8984
|
+
var ipHash = b.crypto.namespaceHash("captcha-ip", securityMiddleware.clientKey(req));
|
|
8985
|
+
await captchaGate.recordOutcome({
|
|
8986
|
+
provider_slug: captchaSlug,
|
|
8987
|
+
gate: gate,
|
|
8988
|
+
ok: !!outcome.ok,
|
|
8989
|
+
score: outcome.score == null ? undefined : outcome.score,
|
|
8990
|
+
ip_hash: ipHash,
|
|
8991
|
+
});
|
|
8992
|
+
} catch (_re) { /* drop-silent — audit write must never break the flow */ }
|
|
8993
|
+
if (!outcome.ok) {
|
|
8994
|
+
return { ok: false, status: 400, message: "Please complete the verification challenge and try again." };
|
|
8995
|
+
}
|
|
8996
|
+
return { ok: true };
|
|
8997
|
+
}
|
|
8998
|
+
|
|
8999
|
+
// Set the route-scoped CSP that admits the active captcha provider host on
|
|
9000
|
+
// the auth pages — ONLY when the gate is active for that flow. When
|
|
9001
|
+
// inactive the strict default app-level CSP stays (no setHeader), so the
|
|
9002
|
+
// unconfigured store's auth pages are unchanged. `flow` is "signup" |
|
|
9003
|
+
// "login": login only admits the host when login is opted in.
|
|
9004
|
+
function _setAuthCaptchaCsp(res, flow) {
|
|
9005
|
+
if (!captchaCspKey) return;
|
|
9006
|
+
if (flow === "login" && !captchaLoginOn) return;
|
|
9007
|
+
if (res && res.setHeader) {
|
|
9008
|
+
res.setHeader("content-security-policy", securityMiddleware.scopedCsp([captchaCspKey]));
|
|
9009
|
+
}
|
|
9010
|
+
}
|
|
9011
|
+
|
|
8883
9012
|
// Active cookie-consent policy version. `_liveConsentPolicy()` reads it
|
|
8884
9013
|
// from the consent primitive per request so a runtime `policyVersion`
|
|
8885
9014
|
// bump takes effect on the gate immediately, and refreshes the module
|
|
@@ -11049,9 +11178,27 @@ function mount(router, deps) {
|
|
|
11049
11178
|
loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
|
|
11050
11179
|
prefill: prefill,
|
|
11051
11180
|
inline_error: inlineError || null,
|
|
11181
|
+
// CAPTCHA widget props — set only when a provider is active, so the
|
|
11182
|
+
// unconfigured checkout renders no widget (renderCheckoutForm treats
|
|
11183
|
+
// an absent kind/key as no-op).
|
|
11184
|
+
captcha_kind: captchaActive ? captchaKind : null,
|
|
11185
|
+
captcha_public_key: captchaActive ? captchaPubKey : null,
|
|
11052
11186
|
};
|
|
11053
11187
|
}
|
|
11054
11188
|
|
|
11189
|
+
// Route-scoped CSP for the checkout pages: always admits Stripe; adds
|
|
11190
|
+
// PayPal when configured (the block only renders then) and the active
|
|
11191
|
+
// CAPTCHA provider host when the gate is active. Set per response so the
|
|
11192
|
+
// app-level strict CSP governs every other route untouched. When nothing
|
|
11193
|
+
// beyond Stripe applies it still scopes to ["stripe"] (the pay step needs
|
|
11194
|
+
// it). Computed from the SAME conditions that gate what the page renders.
|
|
11195
|
+
function _setCheckoutCsp(res) {
|
|
11196
|
+
var keys = ["stripe"];
|
|
11197
|
+
if (deps.paypal && deps.paypal_client_id) keys.push("paypal");
|
|
11198
|
+
if (captchaActive && captchaCspKey) keys.push(captchaCspKey);
|
|
11199
|
+
res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(keys));
|
|
11200
|
+
}
|
|
11201
|
+
|
|
11055
11202
|
router.get("/checkout", async function (req, res) {
|
|
11056
11203
|
var sid = _readSidCookie(req);
|
|
11057
11204
|
if (!sid) return _send(res, 303, "<a href=\"/cart\">Cart is empty</a>"), res.setHeader && res.setHeader("location", "/cart");
|
|
@@ -11069,6 +11216,7 @@ function mount(router, deps) {
|
|
|
11069
11216
|
// cart page) so the summary's per-line totals + subtotal match what
|
|
11070
11217
|
// the shopper saw on /cart.
|
|
11071
11218
|
var lines = await _repriceCartLines(rawLines);
|
|
11219
|
+
_setCheckoutCsp(res);
|
|
11072
11220
|
_send(res, 200, renderCheckoutForm(await _checkoutRenderOpts(req, c, lines, null)));
|
|
11073
11221
|
});
|
|
11074
11222
|
|
|
@@ -11097,6 +11245,27 @@ function mount(router, deps) {
|
|
|
11097
11245
|
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
11098
11246
|
return res.end ? res.end() : res.send("");
|
|
11099
11247
|
}
|
|
11248
|
+
// CAPTCHA gate (no-op unless a provider is active for checkout). Verify
|
|
11249
|
+
// BEFORE the cart is converted — a failed challenge re-renders the form
|
|
11250
|
+
// with an inline correction, never starts the order. The form field is
|
|
11251
|
+
// `captcha_token` (rendered inside the checkout form).
|
|
11252
|
+
var coCaptcha = await _verifyCaptcha(req, "checkout_coupon", body.captcha_token);
|
|
11253
|
+
if (!coCaptcha.ok) {
|
|
11254
|
+
try {
|
|
11255
|
+
var capLines = await _repriceCartLines(await deps.cart.listLines(c.id));
|
|
11256
|
+
if (capLines.length && coCaptcha.status === 400) {
|
|
11257
|
+
_setCheckoutCsp(res);
|
|
11258
|
+
return _send(res, 400, renderCheckoutForm(await _checkoutRenderOpts(req, c, capLines, coCaptcha.message)));
|
|
11259
|
+
}
|
|
11260
|
+
} catch (_ce) { /* fall through to the styled error page */ }
|
|
11261
|
+
return _send(res, coCaptcha.status || 400, renderCheckoutError({
|
|
11262
|
+
shop_name: shopName, theme: theme, eyebrow: "Checkout",
|
|
11263
|
+
title_text: "Couldn't verify your request",
|
|
11264
|
+
reason: coCaptcha.message,
|
|
11265
|
+
back_href: "/checkout", back_label: "Back to checkout",
|
|
11266
|
+
secondary_href: "/cart", secondary_label: "Back to cart",
|
|
11267
|
+
}));
|
|
11268
|
+
}
|
|
11100
11269
|
var shipTo = _shipToFromBody(body);
|
|
11101
11270
|
try {
|
|
11102
11271
|
// default_shipping_id may be a literal string or an
|
|
@@ -11155,6 +11324,7 @@ function mount(router, deps) {
|
|
|
11155
11324
|
// the summary against it (exact, not estimated) so the inline
|
|
11156
11325
|
// re-render shows the same total the confirm path computed.
|
|
11157
11326
|
var confirmedTo = (shipTo && /^[A-Z]{2}$/.test(shipTo.country)) ? shipTo : null;
|
|
11327
|
+
_setCheckoutCsp(res);
|
|
11158
11328
|
return _send(res, 400, renderCheckoutForm(await _checkoutRenderOpts(req, c, coLines, msg, confirmedTo)));
|
|
11159
11329
|
}
|
|
11160
11330
|
} catch (_re) { /* fall through to the styled error page */ }
|
|
@@ -11195,6 +11365,11 @@ function mount(router, deps) {
|
|
|
11195
11365
|
if (!sid) return _json(400, { error: "no-session" });
|
|
11196
11366
|
var c = await deps.cart.bySession(sid);
|
|
11197
11367
|
if (!c || c.status !== "active") return _json(409, { error: "no-active-cart" });
|
|
11368
|
+
// CAPTCHA gate — the same challenge POST /checkout runs (gate
|
|
11369
|
+
// "checkout_coupon"). Without it, a bot could bypass the card-checkout
|
|
11370
|
+
// challenge via the PayPal path. No-op when no provider is configured.
|
|
11371
|
+
var ppCaptcha = await _verifyCaptcha(req, "checkout_coupon", body.captcha_token);
|
|
11372
|
+
if (!ppCaptcha.ok) return _json(ppCaptcha.status || 400, { error: ppCaptcha.message || "captcha-required" });
|
|
11198
11373
|
var shipTo = _shipToFromBody(body);
|
|
11199
11374
|
try {
|
|
11200
11375
|
var defaultShipId = typeof deps.default_shipping_id === "function"
|
|
@@ -11272,6 +11447,12 @@ function mount(router, deps) {
|
|
|
11272
11447
|
res.status(503);
|
|
11273
11448
|
return res.end ? res.end("Stripe publishable key not configured") : res.send("Stripe publishable key not configured");
|
|
11274
11449
|
}
|
|
11450
|
+
// Route-scoped CSP that admits js.stripe.com on script/connect/frame-src
|
|
11451
|
+
// (and Trusted Types stays on) so the Stripe SDK + the same-origin
|
|
11452
|
+
// pay.js island load — without relaxing the app-level strict CSP that
|
|
11453
|
+
// governs every OTHER route. setHeader OVERWRITES the app-level header
|
|
11454
|
+
// for this response only.
|
|
11455
|
+
res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(["stripe"]));
|
|
11275
11456
|
_send(res, 200, renderPayPage({
|
|
11276
11457
|
order: o,
|
|
11277
11458
|
client_secret: clientSecret,
|
|
@@ -11569,23 +11750,44 @@ function mount(router, deps) {
|
|
|
11569
11750
|
router.get("/account/login", async function (req, res) {
|
|
11570
11751
|
var cartCount = await _cartCountForReq(req);
|
|
11571
11752
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
11753
|
+
// Login captcha is opt-in (CAPTCHA_GATE_LOGIN). The widget + the scoped
|
|
11754
|
+
// CSP that admits the provider host render only when login is opted in.
|
|
11755
|
+
_setAuthCaptchaCsp(res, "login");
|
|
11572
11756
|
_send(res, 200, renderAccountLogin({
|
|
11573
11757
|
shop_name: shopName,
|
|
11574
11758
|
cart_count: cartCount,
|
|
11575
11759
|
google_enabled: !!deps.oauthGoogle,
|
|
11576
11760
|
apple_enabled: !!deps.oauthApple,
|
|
11577
11761
|
error: url && url.searchParams.get("error"),
|
|
11762
|
+
captcha_kind: captchaLoginOn ? captchaKind : null,
|
|
11763
|
+
captcha_public_key: captchaLoginOn ? captchaPubKey : null,
|
|
11578
11764
|
}));
|
|
11579
11765
|
});
|
|
11580
11766
|
|
|
11581
11767
|
router.get("/account/register", async function (req, res) {
|
|
11582
11768
|
var cartCount = await _cartCountForReq(req);
|
|
11583
|
-
|
|
11769
|
+
// Signup captcha renders whenever a provider is active; the scoped CSP
|
|
11770
|
+
// admits the provider host only then (no setHeader otherwise).
|
|
11771
|
+
_setAuthCaptchaCsp(res, "signup");
|
|
11772
|
+
_send(res, 200, renderAccountRegister({
|
|
11773
|
+
shop_name: shopName,
|
|
11774
|
+
cart_count: cartCount,
|
|
11775
|
+
captcha_kind: captchaActive ? captchaKind : null,
|
|
11776
|
+
captcha_public_key: captchaActive ? captchaPubKey : null,
|
|
11777
|
+
}));
|
|
11584
11778
|
});
|
|
11585
11779
|
|
|
11586
11780
|
router.post("/account/passkey/register-begin", async function (req, res) {
|
|
11587
11781
|
try {
|
|
11588
11782
|
var body = _readJsonBody(req);
|
|
11783
|
+
// CAPTCHA gate (no-op unless a provider is active). Verify the token
|
|
11784
|
+
// BEFORE creating the customer / minting a challenge — a failed
|
|
11785
|
+
// challenge refuses with a clean message and writes no customer row.
|
|
11786
|
+
var regCaptcha = await _verifyCaptcha(req, "signup", body.captcha_token);
|
|
11787
|
+
if (!regCaptcha.ok) {
|
|
11788
|
+
res.status(regCaptcha.status || 400);
|
|
11789
|
+
return res.end ? res.end(regCaptcha.message) : res.send(regCaptcha.message);
|
|
11790
|
+
}
|
|
11589
11791
|
// Persist the customer row up-front. The address is the
|
|
11590
11792
|
// registration's natural identifier — if enrollment fails
|
|
11591
11793
|
// the customer can re-attempt with the same email; the
|
|
@@ -11701,6 +11903,15 @@ function mount(router, deps) {
|
|
|
11701
11903
|
router.post("/account/passkey/login-begin", async function (req, res) {
|
|
11702
11904
|
try {
|
|
11703
11905
|
var body = _readJsonBody(req);
|
|
11906
|
+
// CAPTCHA gate (no-op unless a provider is active AND login is opted
|
|
11907
|
+
// in via CAPTCHA_GATE_LOGIN — passkey login is already phishing-
|
|
11908
|
+
// resistant). Recorded under gate "other" (the GATES enum has no
|
|
11909
|
+
// dedicated login member). Verified before any challenge is minted.
|
|
11910
|
+
var loginCaptcha = await _verifyCaptcha(req, "other", body.captcha_token);
|
|
11911
|
+
if (!loginCaptcha.ok) {
|
|
11912
|
+
res.status(loginCaptcha.status || 400);
|
|
11913
|
+
return res.end ? res.end(loginCaptcha.message) : res.send(loginCaptcha.message);
|
|
11914
|
+
}
|
|
11704
11915
|
var hash = deps.customers.hashEmail(body.email);
|
|
11705
11916
|
var customer = await deps.customers.byEmailHash(hash);
|
|
11706
11917
|
// Even when the customer doesn't exist, return a valid-shaped
|