security hardening
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user