security hardening
This commit is contained in:
@@ -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.
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user