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
+6 -1
View File
@@ -89,7 +89,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
headers: {
"Content-Type": "image/png",
"Cache-Control": "private, max-age=300",
"Access-Control-Allow-Origin": "*",
// No CORS header: the PNG is rendered via an <s-image> tag in the
// checkout/customer-account extensions (see extensions/*/src/*.tsx),
// i.e. a plain image load, which is not subject to CORS. Dropping the
// previous `Access-Control-Allow-Origin: *` removes the ability for any
// origin to fetch() these bytes cross-origin while keeping the
// legitimate <img>-style loads working.
},
});
};
+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(
+14 -1
View File
@@ -9,10 +9,12 @@ import {
normaliseIban,
} from "../services/invoice/validation";
import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants";
import { validateMerchantHttpsUrl } from "../services/invoice/safeFetch.server";
import {
deleteStoredLogo,
storeUploadedLogo,
} from "../services/invoice/logoCache.server";
import { encryptField } from "../services/crypto/fieldCrypto.server";
import { RichTextEditor } from "../components/RichTextEditor";
import {
DEFAULT_EMAIL_BODY_DE,
@@ -122,6 +124,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
select: { logoUrl: true },
});
const submittedLogoUrl = str("logoUrl");
// Validate any merchant-supplied external logo URL at the trust boundary:
// require a syntactically valid https URL whose host is a domain name, not
// an IP literal (SSRF defence-in-depth; safeFetch is the runtime backstop).
if (submittedLogoUrl && submittedLogoUrl !== STORED_LOGO_SENTINEL) {
const urlError = validateMerchantHttpsUrl(submittedLogoUrl);
if (urlError) errors.logo = urlError;
}
const removeLogo = bool("removeLogo");
const logoFile = form.get("logoFile");
const hasUpload =
@@ -154,13 +163,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const submittedSmtpPassword = str("smtpPassword");
let nextSmtpPassword: string;
if (submittedSmtpPassword === SMTP_PASSWORD_SENTINEL) {
// Unchanged: keep the stored value as-is (already encrypted at rest).
const current = await db.shopSettings.findUnique({
where: { shopDomain: session.shop },
select: { smtpPassword: true },
});
nextSmtpPassword = current?.smtpPassword ?? "";
} else {
nextSmtpPassword = submittedSmtpPassword;
// New password (including "" to clear). Encrypt non-empty values at rest.
nextSmtpPassword = submittedSmtpPassword
? encryptField(submittedSmtpPassword)
: "";
}
const data = {
+42 -27
View File
@@ -5,7 +5,7 @@ import {
generateAndEmailInvoice,
isManualPaymentOrder,
} from "../services/invoice/automations.server";
import { isDuplicateWebhook } from "../services/webhooks/dedupe.server";
import { reserveWebhook } from "../services/webhooks/dedupe.server";
import { runWebhookInBackground } from "../services/webhooks/background.server";
/**
@@ -18,42 +18,57 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// Drop Shopify retries we've already processed (prevents the duplicate
// invoice email we saw when the first delivery exceeded Shopify's 5s ack
// timeout — the work still completed, but Shopify resent the webhook).
if (await isDuplicateWebhook(request, shop, topic)) return new Response();
// Reserve this delivery (status="processing"). `null` => already
// done/in-flight, so short-circuit. The reservation is committed only after
// the background work succeeds, and released on failure so Shopify's retry
// re-runs it (prevents the silent invoice loss we'd get if we recorded the
// id as processed before the slow PDF/email work).
const reservation = await reserveWebhook(request, shop, topic);
if (!reservation) return new Response();
if (!session || !admin) return new Response();
if (!session || !admin) {
// App uninstalled before the webhook drained — nothing to do.
await reservation.commit();
return new Response();
}
const orderId = payload?.id;
if (orderId == null) return new Response();
if (orderId == null) {
await reservation.commit();
return new Response();
}
const customerLocale =
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
// Respond 200 immediately and run the (slow) PDF + email work in the
// background — keeps us well under Shopify's ~5s ack timeout. Dedupe
// above guarantees we don't double-process a retry while the first
// delivery is still working.
runWebhookInBackground(`${topic} order=${orderId} shop=${shop}`, async () => {
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.autoEmailOnWireTransferPlaced) return;
// background — keeps us well under Shopify's ~5s ack timeout. The queue
// commits the reservation on success and releases it on failure.
runWebhookInBackground(
`${topic} order=${orderId} shop=${shop}`,
async () => {
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.autoEmailOnWireTransferPlaced) return;
const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
if (!(await isManualPaymentOrder(admin, orderGid))) return;
const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
if (!(await isManualPaymentOrder(admin, orderGid))) return;
const result = await generateAndEmailInvoice({
shopDomain: shop,
admin,
orderId,
customerLocale,
});
if (!result.ok) {
console.warn(
`auto-email (wire-transfer placed) failed for order ${orderId} on ${shop}: ${result.reason}`,
);
}
});
const result = await generateAndEmailInvoice({
shopDomain: shop,
admin,
orderId,
customerLocale,
});
if (!result.ok) {
// Throw so the reservation is released and Shopify retries — don't
// swallow the failure (which would leave the invoice unsent forever).
throw new Error(
`auto-email (wire-transfer placed) failed for order ${orderId} on ${shop}: ${result.reason}`,
);
}
},
reservation,
);
return new Response();
};
+36 -22
View File
@@ -5,7 +5,7 @@ import {
generateAndEmailInvoice,
isManualPaymentOrder,
} from "../services/invoice/automations.server";
import { isDuplicateWebhook } from "../services/webhooks/dedupe.server";
import { reserveWebhook } from "../services/webhooks/dedupe.server";
import { runWebhookInBackground } from "../services/webhooks/background.server";
/**
@@ -18,42 +18,56 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// Idempotency against Shopify retries — see webhooks/dedupe.server.ts.
if (await isDuplicateWebhook(request, shop, topic)) return new Response();
// Reserve/commit dedupe — see webhooks/dedupe.server.ts. `null` => already
// done/in-flight; commit on success / release on failure happen in the
// background queue so a failed send is retried by Shopify, not dropped.
const reservation = await reserveWebhook(request, shop, topic);
if (!reservation) return new Response();
if (!session || !admin) {
// App was uninstalled before the webhook drained — nothing to do.
await reservation.commit();
return new Response();
}
const orderId = payload?.id;
if (orderId == null) return new Response();
if (orderId == null) {
await reservation.commit();
return new Response();
}
const customerLocale =
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
// Respond fast; do the heavy lifting after the response (see notes in
// webhooks.orders.create.tsx for the rationale).
runWebhookInBackground(`${topic} order=${orderId} shop=${shop}`, async () => {
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.autoEmailOnFulfilledNonWireTransfer) return;
runWebhookInBackground(
`${topic} order=${orderId} shop=${shop}`,
async () => {
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.autoEmailOnFulfilledNonWireTransfer) return;
const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
if (await isManualPaymentOrder(admin, orderGid)) {
// Manual / wire-transfer order — handled by Automation 1, skip here.
return;
}
const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
if (await isManualPaymentOrder(admin, orderGid)) {
// Manual / wire-transfer order — handled by Automation 1, skip here.
return;
}
const result = await generateAndEmailInvoice({
shopDomain: shop,
admin,
orderId,
customerLocale,
});
if (!result.ok) {
console.warn(`auto-email (fulfilled) failed for order ${orderId} on ${shop}: ${result.reason}`);
}
});
const result = await generateAndEmailInvoice({
shopDomain: shop,
admin,
orderId,
customerLocale,
});
if (!result.ok) {
// Throw so the reservation is released and Shopify retries.
throw new Error(
`auto-email (fulfilled) failed for order ${orderId} on ${shop}: ${result.reason}`,
);
}
},
reservation,
);
return new Response();
};
+5 -2
View File
@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import { isDuplicateWebhook } from "../services/webhooks/dedupe.server";
import { reserveWebhook } from "../services/webhooks/dedupe.server";
// Acknowledged but not yet acted on. Future: invalidate cached invoice
// snapshots when a relevant field on the order changes.
@@ -8,6 +8,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// Idempotency against Shopify retries — see webhooks/dedupe.server.ts.
if (await isDuplicateWebhook(request, shop, topic)) return new Response();
const reservation = await reserveWebhook(request, shop, topic);
if (!reservation) return new Response();
// No side-effect work yet, so the delivery is immediately complete.
await reservation.commit();
return new Response();
};