security hardening

This commit is contained in:
Gerhard Scheikl
2026-05-31 09:35:31 +02:00
parent d7d437a871
commit 01b4734477
31 changed files with 1234 additions and 238 deletions
+38 -12
View File
@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import type { LoaderFunctionArgs } from "react-router";
import { authenticate, unauthenticated } from "../shopify.server";
import db from "../db.server";
@@ -88,11 +89,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
}
if (!orderInfo && lastErr) throw lastErr;
} catch (err) {
const msg = (err as Error)?.message ?? String(err);
console.warn(`payment-info: failed to load order ${orderGid} for ${shop}:`, err);
// Log the upstream detail server-side only — never echo internal error
// messages (which may contain Admin API internals / order data) to the
// public client.
console.error(`payment-info: failed to load order ${orderGid} for ${shop}:`, err);
return cors(
Response.json(
{ showPaymentInstructions: false, error: "order-load-failed", detail: msg.slice(0, 500) },
{ showPaymentInstructions: false, error: "order-load-failed" },
{ status: 502 },
),
);
@@ -110,12 +113,28 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
// Without this, any authenticated buyer of the shop could enumerate
// arbitrary orderIds and harvest the shop's bank details / amounts.
//
// Token claims available (see @shopify/shopify-api `JwtPayload`): only the
// standard JWT fields — iss, dest, aud, sub, exp, nbf, iat, jti, sid. There
// is NO order- or checkout-scoped claim in either the checkout or the
// customer-account session token, so we cannot bind the token to the
// requested orderId directly. We therefore bind by customer identity where
// possible and fall back to a tightened recency window for true guests.
//
// - customerAccount tokens always carry a customer GID in `sub`. We require
// that the order's customer matches.
// - Checkout (thank-you page) tokens are issued at the end of checkout and
// are short-lived. For logged-in buyers we still bind to the customer
// GID; for guest checkouts (no customer on the order) we fall back to a
// recency window (the order must have been placed in the last hour).
// that the order's customer matches (strong binding — preferred path).
// - Checkout (thank-you page) tokens for logged-in buyers also carry the
// customer GID in `sub`; we bind to it identically.
// - For guest checkouts (no customer on the order, checkout source only) we
// have nothing in the token to bind against. We accept only when the order
// was placed within a SHORT window — the thank-you page is rendered
// immediately after checkout, so a few minutes is ample for the legitimate
// flow. Residual risk: within this small window an attacker holding ANY
// valid checkout token for this shop could enumerate the numeric ids of
// very-recently-placed guest orders and read their amount/reference. This
// is mitigated (not eliminated) by (a) the short window, (b) per-IP rate
// limiting on /api/public/* (see server.js), and (c) numeric order ids
// being unguessable in the short window. The customer-account path remains
// the preferred, fully-bound surface.
const tokenSub = (sessionToken?.sub ?? "").toString();
const tokenCustomerNumeric = tokenSub.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
const orderCustomerNumeric = (orderInfo.customerId ?? "").replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
@@ -124,17 +143,24 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
if (orderCustomerNumeric && tokenCustomerNumeric) {
ownershipOk = orderCustomerNumeric === tokenCustomerNumeric;
} else if (authSource === "checkout" && !orderCustomerNumeric) {
// Guest checkout: no customer to bind against. Accept only if the order
// is fresh (i.e. the buyer has just completed checkout for it).
// Guest checkout: no customer to bind against. Accept only if the order is
// very fresh (the buyer has just completed checkout for it). Kept short to
// shrink the enumeration window — see residual-risk note above.
const placedAtMs = orderInfo.processedAtMs ?? 0;
const RECENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const RECENT_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
ownershipOk = placedAtMs > 0 && Date.now() - placedAtMs <= RECENT_WINDOW_MS;
}
if (!ownershipOk) {
// Minimal correlation log: never log raw customer identifiers (PII). Hash
// the token subject (sha256, truncated) so repeated abuse from the same
// principal is still correlatable without storing the GID itself.
const subHash = tokenSub
? crypto.createHash("sha256").update(tokenSub).digest("hex").slice(0, 12)
: "-";
console.warn(
`payment-info: ownership check failed for shop=${shop} order=${orderGid} ` +
`authSource=${authSource} tokenSub=${tokenSub || "-"}`,
`authSource=${authSource} subHash=${subHash}`,
);
return cors(
Response.json(