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
+15
View File
@@ -8,9 +8,24 @@ node_modules
!.env.production.example !.env.production.example
prisma/dev.sqlite prisma/dev.sqlite
prisma/dev.sqlite-journal prisma/dev.sqlite-journal
# Any local SQLite DB / journal / WAL must never enter the image.
prisma/*.sqlite*
prisma/dev.sqlite*
data/
.shopify .shopify
.git .git
.github .github
*.log *.log
extensions/*/dist extensions/*/dist
# Dev-only tooling / docs — not needed at build or runtime.
# NOTE: prisma/schema.prisma and prisma/migrations are intentionally NOT
# excluded (required by `prisma generate` and `prisma migrate deploy`).
tests/
scripts/
.cursor/
.gemini/
.vscode/
*.md
**/*.md
+70 -5
View File
@@ -1,18 +1,83 @@
FROM node:20-alpine # syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Base image pin
# ---------------------------------------------------------------------------
# Pinned to a specific minor (20.19) so rebuilds are reproducible and satisfy
# the package.json `engines` constraint (">=20.19 <22 || >=22.12").
# A digest pin is PREFERRED for full immutability, e.g.:
# FROM node:20.19-alpine@sha256:<real-digest>
# Add the real sha256 (from `docker buildx imagetools inspect node:20.19-alpine`)
# when you have network access. We do NOT invent a fake digest here.
# ===========================================================================
# Stage 1 — builder: install ALL deps, generate Prisma client, build the app
# ===========================================================================
FROM node:20.19-alpine AS builder
# openssl is required by Prisma's engines.
RUN apk add --no-cache openssl RUN apk add --no-cache openssl
EXPOSE 3000 WORKDIR /app
# Install the full dependency tree (incl. devDependencies needed by the
# Vite / React Router build toolchain). NODE_ENV is intentionally left unset
# here so `npm ci` does not prune devDependencies.
COPY package.json package-lock.json* ./
RUN npm ci
# Copy the rest of the source and produce the production build + Prisma client.
COPY . .
RUN npx prisma generate \
&& npm run build
# ===========================================================================
# Stage 2 — runtime: pruned prod deps + only the artifacts needed to run
# ===========================================================================
FROM node:20.19-alpine AS runtime
# openssl for Prisma engines at runtime (migrate deploy / query engine).
RUN apk add --no-cache openssl
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# Keep npm + Prisma incidental writes off the (dev) read-only root filesystem:
# both are redirected to the tmpfs-backed /tmp mount.
ENV NPM_CONFIG_CACHE=/tmp/.npm
ENV CHECKPOINT_DISABLE=1
# Install ONLY production dependencies.
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
COPY . . # Prisma schema + migrations are needed for `prisma generate` (below) and for
# `prisma migrate deploy` on container start.
COPY --from=builder /app/prisma ./prisma
# Bake the generated Prisma client into the image so the container start
# command only has to run migrations (no generate at runtime → compatible with
# a read-only root filesystem).
RUN npx prisma generate
RUN npm run build # Application artifacts required at runtime.
COPY --from=builder /app/build ./build
COPY --from=builder /app/public ./public
COPY --from=builder /app/server.js ./server.js
# Run as the unprivileged, pre-existing `node` user (uid/gid 1000). Ensure the
# app tree is owned by it. NOTE: the SQLite DB is written to the /data bind
# mount — the HOST directory mounted at /data MUST be chown'd to uid 1000
# (see deploy/README.md), otherwise migrations/writes will fail as non-root.
RUN chown -R node:node /app
USER node
EXPOSE 3000
# Container-level health probe hitting the unauthenticated /healthz route.
# busybox wget ships with node:alpine.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
# Functionally equivalent to today's start: runs DB migrations then the server.
# (`docker-start` no longer needs `prisma generate` — the client is baked above.)
CMD ["npm", "run", "docker-start"] CMD ["npm", "run", "docker-start"]
+6 -1
View File
@@ -89,7 +89,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
headers: { headers: {
"Content-Type": "image/png", "Content-Type": "image/png",
"Cache-Control": "private, max-age=300", "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 type { LoaderFunctionArgs } from "react-router";
import { authenticate, unauthenticated } from "../shopify.server"; import { authenticate, unauthenticated } from "../shopify.server";
import db from "../db.server"; import db from "../db.server";
@@ -88,11 +89,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
} }
if (!orderInfo && lastErr) throw lastErr; if (!orderInfo && lastErr) throw lastErr;
} catch (err) { } catch (err) {
const msg = (err as Error)?.message ?? String(err); // Log the upstream detail server-side only — never echo internal error
console.warn(`payment-info: failed to load order ${orderGid} for ${shop}:`, err); // 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( return cors(
Response.json( Response.json(
{ showPaymentInstructions: false, error: "order-load-failed", detail: msg.slice(0, 500) }, { showPaymentInstructions: false, error: "order-load-failed" },
{ status: 502 }, { status: 502 },
), ),
); );
@@ -110,12 +113,28 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
// Without this, any authenticated buyer of the shop could enumerate // Without this, any authenticated buyer of the shop could enumerate
// arbitrary orderIds and harvest the shop's bank details / amounts. // 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 // - customerAccount tokens always carry a customer GID in `sub`. We require
// that the order's customer matches. // that the order's customer matches (strong binding — preferred path).
// - Checkout (thank-you page) tokens are issued at the end of checkout and // - Checkout (thank-you page) tokens for logged-in buyers also carry the
// are short-lived. For logged-in buyers we still bind to the customer // customer GID in `sub`; we bind to it identically.
// GID; for guest checkouts (no customer on the order) we fall back to a // - For guest checkouts (no customer on the order, checkout source only) we
// recency window (the order must have been placed in the last hour). // 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 tokenSub = (sessionToken?.sub ?? "").toString();
const tokenCustomerNumeric = tokenSub.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, ""); const tokenCustomerNumeric = tokenSub.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
const orderCustomerNumeric = (orderInfo.customerId ?? "").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) { if (orderCustomerNumeric && tokenCustomerNumeric) {
ownershipOk = orderCustomerNumeric === tokenCustomerNumeric; ownershipOk = orderCustomerNumeric === tokenCustomerNumeric;
} else if (authSource === "checkout" && !orderCustomerNumeric) { } else if (authSource === "checkout" && !orderCustomerNumeric) {
// Guest checkout: no customer to bind against. Accept only if the order // Guest checkout: no customer to bind against. Accept only if the order is
// is fresh (i.e. the buyer has just completed checkout for it). // 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 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; ownershipOk = placedAtMs > 0 && Date.now() - placedAtMs <= RECENT_WINDOW_MS;
} }
if (!ownershipOk) { 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( console.warn(
`payment-info: ownership check failed for shop=${shop} order=${orderGid} ` + `payment-info: ownership check failed for shop=${shop} order=${orderGid} ` +
`authSource=${authSource} tokenSub=${tokenSub || "-"}`, `authSource=${authSource} subHash=${subHash}`,
); );
return cors( return cors(
Response.json( Response.json(
+14 -1
View File
@@ -9,10 +9,12 @@ import {
normaliseIban, normaliseIban,
} from "../services/invoice/validation"; } from "../services/invoice/validation";
import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants"; import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants";
import { validateMerchantHttpsUrl } from "../services/invoice/safeFetch.server";
import { import {
deleteStoredLogo, deleteStoredLogo,
storeUploadedLogo, storeUploadedLogo,
} from "../services/invoice/logoCache.server"; } from "../services/invoice/logoCache.server";
import { encryptField } from "../services/crypto/fieldCrypto.server";
import { RichTextEditor } from "../components/RichTextEditor"; import { RichTextEditor } from "../components/RichTextEditor";
import { import {
DEFAULT_EMAIL_BODY_DE, DEFAULT_EMAIL_BODY_DE,
@@ -122,6 +124,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
select: { logoUrl: true }, select: { logoUrl: true },
}); });
const submittedLogoUrl = str("logoUrl"); 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 removeLogo = bool("removeLogo");
const logoFile = form.get("logoFile"); const logoFile = form.get("logoFile");
const hasUpload = const hasUpload =
@@ -154,13 +163,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const submittedSmtpPassword = str("smtpPassword"); const submittedSmtpPassword = str("smtpPassword");
let nextSmtpPassword: string; let nextSmtpPassword: string;
if (submittedSmtpPassword === SMTP_PASSWORD_SENTINEL) { if (submittedSmtpPassword === SMTP_PASSWORD_SENTINEL) {
// Unchanged: keep the stored value as-is (already encrypted at rest).
const current = await db.shopSettings.findUnique({ const current = await db.shopSettings.findUnique({
where: { shopDomain: session.shop }, where: { shopDomain: session.shop },
select: { smtpPassword: true }, select: { smtpPassword: true },
}); });
nextSmtpPassword = current?.smtpPassword ?? ""; nextSmtpPassword = current?.smtpPassword ?? "";
} else { } else {
nextSmtpPassword = submittedSmtpPassword; // New password (including "" to clear). Encrypt non-empty values at rest.
nextSmtpPassword = submittedSmtpPassword
? encryptField(submittedSmtpPassword)
: "";
} }
const data = { const data = {
+28 -13
View File
@@ -5,7 +5,7 @@ import {
generateAndEmailInvoice, generateAndEmailInvoice,
isManualPaymentOrder, isManualPaymentOrder,
} from "../services/invoice/automations.server"; } 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"; import { runWebhookInBackground } from "../services/webhooks/background.server";
/** /**
@@ -18,24 +18,35 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, session, admin } = await authenticate.webhook(request); const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`); console.log(`Received ${topic} webhook for ${shop}`);
// Drop Shopify retries we've already processed (prevents the duplicate // Reserve this delivery (status="processing"). `null` => already
// invoice email we saw when the first delivery exceeded Shopify's 5s ack // done/in-flight, so short-circuit. The reservation is committed only after
// timeout — the work still completed, but Shopify resent the webhook). // the background work succeeds, and released on failure so Shopify's retry
if (await isDuplicateWebhook(request, shop, topic)) return new Response(); // 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; const orderId = payload?.id;
if (orderId == null) return new Response(); if (orderId == null) {
await reservation.commit();
return new Response();
}
const customerLocale = const customerLocale =
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined; typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
// Respond 200 immediately and run the (slow) PDF + email work in the // Respond 200 immediately and run the (slow) PDF + email work in the
// background — keeps us well under Shopify's ~5s ack timeout. Dedupe // background — keeps us well under Shopify's ~5s ack timeout. The queue
// above guarantees we don't double-process a retry while the first // commits the reservation on success and releases it on failure.
// delivery is still working. runWebhookInBackground(
runWebhookInBackground(`${topic} order=${orderId} shop=${shop}`, async () => { `${topic} order=${orderId} shop=${shop}`,
async () => {
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } }); const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.autoEmailOnWireTransferPlaced) return; if (!settings?.autoEmailOnWireTransferPlaced) return;
@@ -49,11 +60,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
customerLocale, customerLocale,
}); });
if (!result.ok) { if (!result.ok) {
console.warn( // 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}`, `auto-email (wire-transfer placed) failed for order ${orderId} on ${shop}: ${result.reason}`,
); );
} }
}); },
reservation,
);
return new Response(); return new Response();
}; };
+21 -7
View File
@@ -5,7 +5,7 @@ import {
generateAndEmailInvoice, generateAndEmailInvoice,
isManualPaymentOrder, isManualPaymentOrder,
} from "../services/invoice/automations.server"; } 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"; import { runWebhookInBackground } from "../services/webhooks/background.server";
/** /**
@@ -18,23 +18,32 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, session, admin } = await authenticate.webhook(request); const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`); console.log(`Received ${topic} webhook for ${shop}`);
// Idempotency against Shopify retries — see webhooks/dedupe.server.ts. // Reserve/commit dedupe — see webhooks/dedupe.server.ts. `null` => already
if (await isDuplicateWebhook(request, shop, topic)) return new Response(); // 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) { if (!session || !admin) {
// App was uninstalled before the webhook drained — nothing to do. // App was uninstalled before the webhook drained — nothing to do.
await reservation.commit();
return new Response(); return new Response();
} }
const orderId = payload?.id; const orderId = payload?.id;
if (orderId == null) return new Response(); if (orderId == null) {
await reservation.commit();
return new Response();
}
const customerLocale = const customerLocale =
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined; typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
// Respond fast; do the heavy lifting after the response (see notes in // Respond fast; do the heavy lifting after the response (see notes in
// webhooks.orders.create.tsx for the rationale). // webhooks.orders.create.tsx for the rationale).
runWebhookInBackground(`${topic} order=${orderId} shop=${shop}`, async () => { runWebhookInBackground(
`${topic} order=${orderId} shop=${shop}`,
async () => {
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } }); const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
if (!settings?.autoEmailOnFulfilledNonWireTransfer) return; if (!settings?.autoEmailOnFulfilledNonWireTransfer) return;
@@ -51,9 +60,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
customerLocale, customerLocale,
}); });
if (!result.ok) { if (!result.ok) {
console.warn(`auto-email (fulfilled) failed for order ${orderId} on ${shop}: ${result.reason}`); // 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(); return new Response();
}; };
+5 -2
View File
@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server"; 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 // Acknowledged but not yet acted on. Future: invalidate cached invoice
// snapshots when a relevant field on the order changes. // 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); const { shop, topic } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`); console.log(`Received ${topic} webhook for ${shop}`);
// Idempotency against Shopify retries — see webhooks/dedupe.server.ts. // 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(); return new Response();
}; };
+33
View File
@@ -0,0 +1,33 @@
/**
* Small env-access helpers that fail closed.
*
* `requireEnv` throws a clear error (without ever printing the secret value)
* when a required environment variable is missing or empty. `optionalEnv`
* returns the trimmed value or undefined.
*/
/**
* Returns the value of `name` from `process.env`, throwing if it is unset or
* empty (after trimming). The secret value itself is never included in the
* error message.
*/
export function requireEnv(name: string): string {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "") {
throw new Error(
`Missing required environment variable "${name}". ` +
`Set it before starting the app (see deploy/.env.dev.example).`,
);
}
return raw;
}
/**
* Returns the value of `name` from `process.env`, or `undefined` if it is
* unset or empty (after trimming).
*/
export function optionalEnv(name: string): string | undefined {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "") return undefined;
return raw;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Field-level encryption at rest using AES-256-GCM.
*
* Output format: `enc:v1:<base64(iv)>:<base64(tag)>:<base64(ciphertext)>`
*
* `decryptField` is backward-compatible: values that do not carry the
* `enc:v1:` prefix are assumed to be legacy plaintext and returned unchanged,
* so an existing (dev) database keeps working without a data migration.
*/
import crypto from "node:crypto";
import { requireEnv } from "../config/env.server";
const PREFIX = "enc:v1:";
const IV_BYTES = 12;
const KEY_BYTES = 32;
let cachedKey: Buffer | null = null;
/**
* Loads and validates the 32-byte AES key from `DATA_ENCRYPTION_KEY`
* (base64-encoded). Cached after first use. Throws if unset or wrong length.
*/
function getKey(): Buffer {
if (cachedKey) return cachedKey;
const b64 = requireEnv("DATA_ENCRYPTION_KEY");
let key: Buffer;
try {
key = Buffer.from(b64, "base64");
} catch {
throw new Error('DATA_ENCRYPTION_KEY must be valid base64 of 32 bytes.');
}
if (key.length !== KEY_BYTES) {
throw new Error(
`DATA_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes (got ${key.length}).`,
);
}
cachedKey = key;
return key;
}
/** Encrypts `plaintext` and returns the `enc:v1:...` envelope. */
export function encryptField(plaintext: string): string {
const key = getKey();
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return (
PREFIX +
iv.toString("base64") +
":" +
tag.toString("base64") +
":" +
ciphertext.toString("base64")
);
}
/**
* Decrypts an `enc:v1:...` envelope. If `value` is not in that format it is
* assumed to be legacy plaintext and returned unchanged.
*/
export function decryptField(value: string): string {
if (!value.startsWith(PREFIX)) return value;
const parts = value.slice(PREFIX.length).split(":");
if (parts.length !== 3) {
throw new Error("Malformed encrypted field (expected iv:tag:ciphertext).");
}
const [ivB64, tagB64, dataB64] = parts;
const key = getKey();
const iv = Buffer.from(ivB64, "base64");
const tag = Buffer.from(tagB64, "base64");
const ciphertext = Buffer.from(dataB64, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return plaintext.toString("utf8");
}
+49 -8
View File
@@ -12,6 +12,8 @@ import {
} from "./emailTemplates"; } from "./emailTemplates";
import { STORED_LOGO_SENTINEL } from "./logoCache.constants"; import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server"; import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
import { decryptField } from "../crypto/fieldCrypto.server";
import { optionalEnv } from "../config/env.server";
export interface SendInvoiceEmailArgs { export interface SendInvoiceEmailArgs {
shopDomain: string; shopDomain: string;
@@ -86,7 +88,7 @@ export async function sendInvoiceEmail(
const customSubject = const customSubject =
(language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe) || (language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe) ||
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE); (language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
const subject = renderTemplate(customSubject, vars); const subject = renderTemplate(customSubject, vars, { html: false });
const customBodyHtml = const customBodyHtml =
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) || (language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
@@ -124,10 +126,13 @@ export async function sendInvoiceEmail(
} }
try { try {
// Optional archival BCC. Off by default for privacy/GDPR; set INVOICE_BCC
// to a comma-separated address list to opt in.
const bcc = optionalEnv("INVOICE_BCC");
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`, from: `"${fromName}" <${fromEmail}>`,
to, to,
bcc: "shop@linumiq.com", ...(bcc ? { bcc } : {}),
replyTo: settings.smtpReplyTo || undefined, replyTo: settings.smtpReplyTo || undefined,
subject, subject,
text: body.text, text: body.text,
@@ -180,7 +185,7 @@ function buildTransport(settings: ShopSettings): Transporter {
port: settings.smtpPort, port: settings.smtpPort,
secure: settings.smtpSecure, secure: settings.smtpSecure,
auth: settings.smtpUser auth: settings.smtpUser
? { user: settings.smtpUser, pass: settings.smtpPassword } ? { user: settings.smtpUser, pass: decryptField(settings.smtpPassword) }
: undefined, : undefined,
}); });
} }
@@ -286,13 +291,49 @@ function buildTemplateVars(args: {
/** /**
* Substitutes {{token}} placeholders in `template`. Unknown tokens are left * Substitutes {{token}} placeholders in `template`. Unknown tokens are left
* in place so the user notices typos instead of silent blanks. Values are * in place so the user notices typos instead of silent blanks.
* inserted verbatim — callers are responsible for HTML-escaping if needed. *
* For HTML output (the default), every interpolated value is HTML-escaped to
* prevent stored-XSS from merchant- or customer-derived data bleeding into the
* email body. URL-valued tokens that land inside `href` attributes are scheme-
* validated first: `shopWebsite` must be an `https:` URL and `shopEmail` must
* look like a bare email address (rendered after a `mailto:` prefix); anything
* else renders empty so a hostile `javascript:`/`data:` value can't be planted.
*
* Pass `{ html: false }` for plain-text contexts (e.g. the subject line), where
* the raw value is substituted without HTML entity encoding.
*/ */
function renderTemplate(template: string, vars: TemplateVars): string { const URL_TOKENS = new Set(["shopWebsite"]);
const EMAIL_TOKENS = new Set(["shopEmail"]);
function safeHttpsUrl(value: string): string {
const v = value.trim();
try {
return new URL(v).protocol === "https:" ? v : "";
} catch {
return "";
}
}
function safeEmailAddress(value: string): string {
const v = value.trim();
return /^[^\s@<>"'/\\]+@[^\s@<>"'/\\]+\.[^\s@<>"'/\\]+$/.test(v) ? v : "";
}
function renderTemplate(
template: string,
vars: TemplateVars,
opts: { html?: boolean } = {},
): string {
const html = opts.html !== false; // HTML-escape by default
return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => { return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => {
const v = (vars as unknown as Record<string, string | undefined>)[key]; const raw = (vars as unknown as Record<string, string | undefined>)[key];
return v === undefined ? full : v; if (raw === undefined) return full;
if (!html) return raw;
let value = raw;
if (URL_TOKENS.has(key)) value = safeHttpsUrl(raw);
else if (EMAIL_TOKENS.has(key)) value = safeEmailAddress(raw);
return escapeHtml(value);
}); });
} }
+14 -3
View File
@@ -18,6 +18,15 @@ export interface GiroCodeInput {
remittance: string; remittance: string;
} }
/**
* Replaces CR/LF in a free-text EPC field with a single space and collapses
* runs of whitespace, so the line-delimited payload can't be tampered with by
* smuggling newlines into user-supplied text (beneficiary name / remittance).
*/
function sanitizeEpcField(value: string): string {
return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
}
export function buildGiroCodePayload(input: GiroCodeInput): string { export function buildGiroCodePayload(input: GiroCodeInput): string {
const currency = input.currency || "EUR"; const currency = input.currency || "EUR";
if (currency !== "EUR") { if (currency !== "EUR") {
@@ -25,12 +34,14 @@ export function buildGiroCodePayload(input: GiroCodeInput): string {
console.warn(`GiroCode: non-EUR currency ${currency} is non-standard.`); console.warn(`GiroCode: non-EUR currency ${currency} is non-standard.`);
} }
// Beneficiary name max 70 chars per spec. // Beneficiary name max 70 chars per spec. Strip CR/LF first so injected
const name = input.beneficiaryName.slice(0, 70); // newlines can't forge/add EPC fields (the payload is line-delimited).
const name = sanitizeEpcField(input.beneficiaryName).slice(0, 70);
const iban = input.iban.replace(/\s+/g, "").toUpperCase(); const iban = input.iban.replace(/\s+/g, "").toUpperCase();
const bic = (input.bic || "").replace(/\s+/g, "").toUpperCase(); const bic = (input.bic || "").replace(/\s+/g, "").toUpperCase();
const amount = input.amount.toFixed(2); const amount = input.amount.toFixed(2);
const remittance = input.remittance.slice(0, 140); // Unstructured remittance max 140 chars; strip CR/LF for the same reason.
const remittance = sanitizeEpcField(input.remittance).slice(0, 140);
// Field order is fixed; trailing fields can be empty. // Field order is fixed; trailing fields can be empty.
// Service tag SCT = SEPA Credit Transfer. // Service tag SCT = SEPA Credit Transfer.
+16 -1
View File
@@ -22,6 +22,21 @@ const TEXT_DARK = "#1F2933";
const TEXT_MUTED = "#6B7280"; const TEXT_MUTED = "#6B7280";
const TABLE_BORDER = "#E5E7EB"; const TABLE_BORDER = "#E5E7EB";
/**
* Returns true only for syntactically valid http(s) URLs. Used to gate
* carrier/fulfillment-supplied tracking URLs before embedding them as PDF
* link annotations, so non-http schemes (javascript:, file:, data:, …) can't
* be smuggled into the document.
*/
function isHttpUrl(value: string): boolean {
try {
const u = new URL(value);
return u.protocol === "https:" || u.protocol === "http:";
} catch {
return false;
}
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
paddingTop: 40, paddingTop: 40,
@@ -348,7 +363,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
{t.trackingLabel} {t.trackingLabel}
{tr.company ? ` (${tr.company})` : ""} {tr.company ? ` (${tr.company})` : ""}
</Text> </Text>
{tr.url ? ( {tr.url && isHttpUrl(tr.url) ? (
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link> <Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
) : ( ) : (
<Text style={styles.metaValue}>{tr.number}</Text> <Text style={styles.metaValue}>{tr.number}</Text>
@@ -7,9 +7,14 @@
* hammering the network when regenerating an invoice multiple times. * hammering the network when regenerating an invoice multiple times.
*/ */
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server"; import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
import pLimit from "p-limit";
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image
const CACHE_MAX_ENTRIES = 200; const CACHE_MAX_ENTRIES = 200;
/** Max images fetched/embedded per invoice (DoS bound for large carts). */
const MAX_IMAGES_PER_INVOICE = 100;
/** Max concurrent image fetches per invoice. */
const IMAGE_FETCH_CONCURRENCY = 6;
const cache = new Map<string, string>(); // url -> data URL const cache = new Map<string, string>(); // url -> data URL
@@ -76,17 +81,28 @@ export async function fetchProductImageDataUrl(url: string): Promise<string | un
} }
/** /**
* Resolves images for every line in parallel, mutating `imageDataUrl` in place. * Resolves images for every line, mutating `imageDataUrl` in place. Fetches
* Failures are swallowed (the row simply renders without an icon). * run with bounded concurrency and a hard cap on the number of images
* embedded per invoice, so a large cart (Shopify allows hundreds of line
* items) can't trigger an unbounded fan-out of network requests. Failures are
* swallowed (the row simply renders without an icon).
*/ */
export async function attachLineItemImages( export async function attachLineItemImages(
lines: { imageUrl?: string; imageDataUrl?: string }[], lines: { imageUrl?: string; imageDataUrl?: string }[],
): Promise<void> { ): Promise<void> {
await Promise.all( const limit = pLimit(IMAGE_FETCH_CONCURRENCY);
lines.map(async (line) => { let budget = MAX_IMAGES_PER_INVOICE;
if (!line.imageUrl) return; const tasks: Promise<void>[] = [];
const dataUrl = await fetchProductImageDataUrl(line.imageUrl); for (const line of lines) {
if (!line.imageUrl) continue;
if (budget <= 0) break; // cap reached — remaining rows render iconless
budget -= 1;
tasks.push(
limit(async () => {
const dataUrl = await fetchProductImageDataUrl(line.imageUrl!);
if (dataUrl) line.imageDataUrl = dataUrl; if (dataUrl) line.imageDataUrl = dataUrl;
}), }),
); );
} }
await Promise.all(tasks);
}
+81 -34
View File
@@ -25,6 +25,7 @@ import { Agent as HttpAgent } from "node:http";
import { Agent as HttpsAgent } from "node:https"; import { Agent as HttpsAgent } from "node:https";
import http from "node:http"; import http from "node:http";
import https from "node:https"; import https from "node:https";
import ipaddr from "ipaddr.js";
export interface SafeFetchOptions { export interface SafeFetchOptions {
/** Hard cap in bytes; the read aborts as soon as this is exceeded. */ /** Hard cap in bytes; the read aborts as soon as this is exceeded. */
@@ -60,43 +61,62 @@ export class SafeFetchError extends Error {
const DEFAULT_TIMEOUT_MS = 5_000; const DEFAULT_TIMEOUT_MS = 5_000;
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MB const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
function isPrivateIpv4(ip: string): boolean { /**
const parts = ip.split(".").map((n) => parseInt(n, 10)); * Default-deny address classifier backed by the well-vetted `ipaddr.js`
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) { * library. An address is considered safe to connect to ONLY if it is a
// Treat malformed addresses as unsafe. * clearly public, globally-routable unicast address. Everything else —
return true; * loopback, private (RFC1918), link-local, unique-local, multicast,
} * reserved, unspecified, broadcast, carrier-grade NAT, plus the various
const [a, b] = parts; * IPv4-in-IPv6 tunnelling/transition forms — is rejected.
if (a === 10) return true; *
if (a === 127) return true; * This closes IPv6 bypasses that string-prefix checks miss, e.g.:
if (a === 0) return true; * - `::ffff:7f00:1` (IPv4-mapped HEX form of 127.0.0.1)
if (a === 169 && b === 254) return true; // link-local + AWS metadata * - `::7f00:1` (deprecated IPv4-compatible ::127.0.0.1)
if (a === 172 && b >= 16 && b <= 31) return true; * - `fe90::` / `fea0::` / `feb0::` (link-local is fe80::/10, not just fe80:)
if (a === 192 && b === 168) return true; */
if (a === 192 && b === 0) return true; // 192.0.0.0/24, 192.0.2.0/24 function isSafePublicAddress(ip: string): boolean {
if (a === 198 && (b === 18 || b === 19)) return true; let addr: ipaddr.IPv4 | ipaddr.IPv6;
if (a >= 224) return true; // multicast / reserved try {
addr = ipaddr.parse(ip);
} catch {
// Unparseable => treat as unsafe.
return false; return false;
} }
function isPrivateIpv6(ip: string): boolean { if (addr.kind() === "ipv4") {
const lower = ip.toLowerCase(); // Only globally-routable unicast IPv4 is allowed. `range()` returns
if (lower === "::1" || lower === "::") return true; // 'unicast' exclusively for public space; private/loopback/linkLocal/
if (lower.startsWith("fe80:")) return true; // link-local // carrierGradeNat/reserved/broadcast/multicast/unspecified are all denied.
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA return (addr as ipaddr.IPv4).range() === "unicast";
if (lower.startsWith("ff")) return true; // multicast
// IPv4-mapped: ::ffff:a.b.c.d — apply IPv4 rules
if (lower.startsWith("::ffff:")) {
const v4 = lower.slice(7);
if (v4.includes(".")) return isPrivateIpv4(v4);
}
return false;
} }
function isPrivateAddress(ip: string, family: number): boolean { const v6 = addr as ipaddr.IPv6;
if (family === 4) return isPrivateIpv4(ip);
if (family === 6) return isPrivateIpv6(ip); // Unwrap IPv4-mapped (::ffff:a.b.c.d, incl. hex form ::ffff:7f00:1) and
return true; // validate the embedded IPv4 against the v4 policy.
if (v6.isIPv4MappedAddress()) {
return v6.toIPv4Address().range() === "unicast";
}
// Deprecated IPv4-compatible addresses live in ::/96 (first 96 bits zero,
// e.g. ::7f00:1 == ::127.0.0.1). ipaddr.js classifies these as plain
// 'unicast', so unwrap the trailing 32 bits and validate as IPv4. This
// also covers :: (unspecified) and ::1 (loopback), which map to
// 0.0.0.0 / 0.0.0.1 and are denied by the IPv4 policy.
const p = v6.parts;
if (p[0] === 0 && p[1] === 0 && p[2] === 0 && p[3] === 0 && p[4] === 0 && p[5] === 0) {
const v4 = new ipaddr.IPv4([(p[6] >> 8) & 0xff, p[6] & 0xff, (p[7] >> 8) & 0xff, p[7] & 0xff]);
return v4.range() === "unicast";
}
// Everything else: only true global unicast is allowed. This rejects
// loopback, linkLocal (fe80::/10), uniqueLocal (fc00::/7), multicast,
// reserved, 6to4, teredo, rfc6145/rfc6052 transition ranges, etc.
return v6.range() === "unicast";
}
function isPrivateAddress(ip: string): boolean {
return !isSafePublicAddress(ip);
} }
function hostMatchesAllowlist(hostname: string, allowed: string[] | undefined): boolean { function hostMatchesAllowlist(hostname: string, allowed: string[] | undefined): boolean {
@@ -117,7 +137,7 @@ async function resolveSafeAddress(hostname: string): Promise<{ address: string;
// If the hostname is already an IP literal, validate it directly. // If the hostname is already an IP literal, validate it directly.
if (net.isIP(hostname)) { if (net.isIP(hostname)) {
const family = net.isIPv6(hostname) ? 6 : 4; const family = net.isIPv6(hostname) ? 6 : 4;
if (isPrivateAddress(hostname, family)) { if (isPrivateAddress(hostname)) {
throw new SafeFetchError("blocked-address", `Refusing to connect to private address ${hostname}`); throw new SafeFetchError("blocked-address", `Refusing to connect to private address ${hostname}`);
} }
return { address: hostname, family }; return { address: hostname, family };
@@ -129,7 +149,7 @@ async function resolveSafeAddress(hostname: string): Promise<{ address: string;
throw new SafeFetchError("dns-failed", `DNS lookup failed for ${hostname}: ${(err as Error).message}`); throw new SafeFetchError("dns-failed", `DNS lookup failed for ${hostname}: ${(err as Error).message}`);
} }
for (const r of results) { for (const r of results) {
if (isPrivateAddress(r.address, r.family)) { if (isPrivateAddress(r.address)) {
throw new SafeFetchError("blocked-address", `${hostname} resolves to private address ${r.address}`); throw new SafeFetchError("blocked-address", `${hostname} resolves to private address ${r.address}`);
} }
} }
@@ -269,3 +289,30 @@ export async function safeFetch(rawUrl: string, opts: SafeFetchOptions = {}): Pr
/** Common allowlist for Shopify-served assets (CDN + Files). */ /** Common allowlist for Shopify-served assets (CDN + Files). */
export const SHOPIFY_CDN_HOSTS = ["cdn.shopify.com", "shopifycdn.com", "shopify.com"]; export const SHOPIFY_CDN_HOSTS = ["cdn.shopify.com", "shopifycdn.com", "shopify.com"];
/**
* Boundary validation for merchant-supplied URLs (e.g. the logo URL saved in
* settings). Requires a syntactically valid `https:` URL whose host is a DNS
* name rather than an IP literal (v4 or v6). Returns a user-facing error
* string when the URL is unacceptable, or `null` when it is fine to store.
*
* This is a defence-in-depth boundary check; `safeFetch` remains the runtime
* backstop that re-validates the resolved address at fetch time.
*/
export function validateMerchantHttpsUrl(raw: string): string | null {
let url: URL;
try {
url = new URL(raw);
} catch {
return "Enter a valid URL including the https:// prefix.";
}
if (url.protocol !== "https:") {
return "Logo URL must use https://.";
}
// URL.hostname wraps IPv6 literals in brackets; strip them before checking.
const host = url.hostname.replace(/^\[/, "").replace(/\]$/, "");
if (net.isIP(host) !== 0) {
return "Logo URL must point to a domain name, not an IP address.";
}
return null;
}
+22 -2
View File
@@ -1,9 +1,29 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
const SECRET = process.env.SHOPIFY_API_SECRET || ""; import { optionalEnv } from "../config/env.server";
/**
* Resolves the GiroCode URL signing key lazily (per call, not at module load)
* so the process can boot even when only the fallback secret is present.
*
* Prefers the dedicated `GIROCODE_SIGNING_KEY`; falls back to
* `SHOPIFY_API_SECRET` ONLY when the dedicated key is unset, so existing
* signed URLs and deployments keep working. Throws if neither is set
* (fail closed) — an empty key would make signatures forgeable.
*/
function getSigningKey(): string {
const key = optionalEnv("GIROCODE_SIGNING_KEY") ?? optionalEnv("SHOPIFY_API_SECRET");
if (!key) {
throw new Error(
"GiroCode signing key missing: set GIROCODE_SIGNING_KEY (preferred) " +
"or SHOPIFY_API_SECRET.",
);
}
return key;
}
function hmac(payload: string): string { function hmac(payload: string): string {
return crypto.createHmac("sha256", SECRET).update(payload).digest("hex"); return crypto.createHmac("sha256", getSigningKey()).update(payload).digest("hex");
} }
export interface GiroCodeUrlParams { export interface GiroCodeUrlParams {
@@ -0,0 +1,60 @@
/**
* Thin wrapper around `@shopify/shopify-app-session-storage-prisma` that
* encrypts `accessToken` / `refreshToken` at rest using field-level AES-256-GCM.
*
* Tokens are encrypted before being handed to the underlying storage and
* decrypted after they are loaded back out. `decryptField` is backward
* compatible, so any legacy plaintext tokens already in the DB keep working.
*/
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import type { Session } from "@shopify/shopify-api";
import type { PrismaClient } from "@prisma/client";
import { decryptField, encryptField } from "../crypto/fieldCrypto.server";
type SessionTokenFields = {
accessToken?: string;
refreshToken?: string;
};
function encryptTokens(session: Session): Session {
const s = session as Session & SessionTokenFields;
if (s.accessToken) s.accessToken = encryptField(s.accessToken);
if (s.refreshToken) s.refreshToken = encryptField(s.refreshToken);
return session;
}
function decryptTokens(session: Session): Session {
const s = session as Session & SessionTokenFields;
if (s.accessToken) s.accessToken = decryptField(s.accessToken);
if (s.refreshToken) s.refreshToken = decryptField(s.refreshToken);
return session;
}
/**
* Clones a Session so we never mutate the caller's instance when encrypting
* for storage. The Prisma session storage only reads plain properties, so a
* shallow structured copy via the Session class is sufficient.
*/
function cloneSession(session: Session): Session {
return Object.assign(
Object.create(Object.getPrototypeOf(session)),
session,
) as Session;
}
export class EncryptedPrismaSessionStorage extends PrismaSessionStorage<PrismaClient> {
async storeSession(session: Session): Promise<boolean> {
return super.storeSession(encryptTokens(cloneSession(session)));
}
async loadSession(id: string): Promise<Session | undefined> {
const session = await super.loadSession(id);
return session ? decryptTokens(session) : session;
}
async findSessionsByShop(shop: string): Promise<Session[]> {
const sessions = await super.findSessionsByShop(shop);
return sessions.map((s) => decryptTokens(s));
}
}
+102 -11
View File
@@ -1,27 +1,118 @@
import pLimit from "p-limit";
import type { WebhookReservation } from "./dedupe.server";
/** /**
* Fire-and-forget runner for webhook side-effects. * Background runner for webhook side-effects.
* *
* Shopify expects a 200 response within ~5 seconds, otherwise it considers * Shopify expects a 200 response within ~5 seconds, otherwise it considers
* the delivery failed and retries it. Heavy automation work (PDF render, * the delivery failed and retries it. Heavy automation work (PDF render,
* Shopify Files upload, SMTP send) routinely exceeded that budget, which * Shopify Files upload, SMTP send) routinely exceeded that budget, which
* caused duplicate invoice emails before we added the dedupe table. * caused duplicate invoice emails before we added the dedupe table.
* *
* Returning the response immediately and letting the work finish in the * Returning the response immediately and finishing the work afterwards keeps
* background keeps Shopify happy. Combined with the dedupe table this is * Shopify happy. Two problems with a naive `void work()`:
* defence-in-depth: dedupe ensures *correctness* even if a retry sneaks
* through, while async processing makes retries unlikely in the first
* place.
* *
* Errors are caught and logged \u2014 they cannot reach a dispatcher because * 1. DoS / resource exhaustion — an order burst would spawn unbounded
* the HTTP response is already gone. * concurrent PDF renders + SMTP sends. We cap concurrency with a small
* in-process queue (`p-limit`); excess tasks queue instead of piling up.
* 2. Data loss on restart — `void work()` is invisible to shutdown, so a
* container stop (SIGTERM) killed in-flight invoice work mid-send. We
* track in-flight tasks and drain them (bounded) on SIGTERM/SIGINT.
*
* Reserve/commit dedupe (see dedupe.server.ts) is integrated here: on success
* we `commit()` the reservation (permanently deduped); on failure we
* `release()` it so Shopify's retry re-runs the work instead of being dropped
* as a duplicate.
*/ */
const CONCURRENCY = Math.max(1, Number(process.env.WEBHOOK_CONCURRENCY) || 4);
const DRAIN_TIMEOUT_MS = Math.max(
1000,
Number(process.env.WEBHOOK_DRAIN_TIMEOUT_MS) || 25_000,
);
const limit = pLimit(CONCURRENCY);
const inFlight = new Set<Promise<unknown>>();
let draining = false;
export function runWebhookInBackground( export function runWebhookInBackground(
description: string, description: string,
work: () => Promise<unknown>, work: () => Promise<unknown>,
reservation?: WebhookReservation | null,
): void { ): void {
// `void` so we don't accidentally `await` the floating promise; the if (draining) {
// node event loop keeps the task alive until it settles. // The process is shutting down. We still enqueue so the drain awaits this
void work().catch((err) => { // task — the server has already stopped listening, so this is at most the
// tail end of the last accepted request.
console.warn(`[webhook-queue] enqueuing task during shutdown drain: ${description}`);
}
const task = limit(async () => {
try {
await work();
await reservation?.commit();
} catch (err) {
console.error(`background webhook task '${description}' failed:`, err); console.error(`background webhook task '${description}' failed:`, err);
// Drop the dedupe reservation so Shopify's retry re-runs the work.
try {
await reservation?.release();
} catch (releaseErr) {
console.error(
`background webhook task '${description}': failed to release dedupe reservation:`,
releaseErr,
);
}
}
});
inFlight.add(task);
void task.finally(() => inFlight.delete(task));
}
/**
* Stop accepting new work (best-effort) and await in-flight + queued tasks,
* bounded by `timeoutMs`, so a container stop drains invoice work instead of
* killing it mid-send. Idempotent.
*/
export async function drainWebhookQueue(timeoutMs = DRAIN_TIMEOUT_MS): Promise<void> {
draining = true;
if (inFlight.size === 0) return;
console.log(
`[webhook-queue] draining ${inFlight.size} in-flight webhook task(s) (timeout ${timeoutMs}ms)...`,
);
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
if (typeof timer.unref === "function") timer.unref();
});
await Promise.race([Promise.allSettled([...inFlight]), timeout]);
if (timer) clearTimeout(timer);
if (inFlight.size > 0) {
console.warn(
`[webhook-queue] drain timed out with ${inFlight.size} task(s) still running`,
);
} else {
console.log("[webhook-queue] drain complete");
}
}
// Bridge for the custom server (server.js), which loads only the bundled
// build and cannot import this module directly. It awaits this drain before
// calling process.exit during graceful shutdown.
type DrainGlobal = typeof globalThis & {
__linumiqWebhookDrain?: typeof drainWebhookQueue;
};
(globalThis as DrainGlobal).__linumiqWebhookDrain = drainWebhookQueue;
// Safety net for runtimes that don't go through server.js (e.g. `shopify app
// dev`): stop accepting work and best-effort drain. The custom server awaits
// the same (idempotent) drain before exiting.
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.once(signal, () => {
void drainWebhookQueue();
}); });
} }
+63
View File
@@ -0,0 +1,63 @@
import db from "../../db.server";
/**
* Periodic TTL cleanup for the `ProcessedWebhook` idempotency table.
*
* The table grows by one row per Shopify webhook delivery and is never read
* after the retry window closes, so without pruning it grows unbounded —
* eventually a disk/space DoS. We only need rows for as long as Shopify might
* retry a delivery (hours), so a generous retention window of a few days is
* ample while keeping the table small.
*/
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const INTERVAL_MS = 60 * 60 * 1000; // hourly
export interface CleanupDeps {
db: {
processedWebhook: {
deleteMany: (args: {
where: { receivedAt: { lt: Date } };
}) => Promise<{ count: number }>;
};
};
}
let scheduled = false;
async function runCleanup(deps: CleanupDeps): Promise<void> {
try {
const cutoff = new Date(Date.now() - RETENTION_MS);
const { count } = await deps.db.processedWebhook.deleteMany({
where: { receivedAt: { lt: cutoff } },
});
if (count > 0) {
console.log(`webhook-cleanup: removed ${count} ProcessedWebhook row(s) older than 7d`);
}
} catch (err) {
// Best-effort housekeeping — never throw into the caller.
console.warn("webhook-cleanup: prune failed:", err);
}
}
/**
* Idempotently schedule the hourly cleanup. Safe to call on every webhook —
* the first call starts a single unref'd interval and runs an immediate
* sweep; subsequent calls are no-ops.
*
* Because this is only ever invoked while handling a live webhook request, it
* never runs during `prisma generate` / `react-router build` or other CLI
* contexts. The interval is `unref`'d so it can never keep the process alive.
*/
export function ensureWebhookCleanupScheduled(deps: CleanupDeps = { db }): void {
if (scheduled) return;
scheduled = true;
const timer = setInterval(() => {
void runCleanup(deps);
}, INTERVAL_MS);
// Don't let the housekeeping interval keep the event loop alive on shutdown.
if (typeof timer.unref === "function") timer.unref();
// Kick off an immediate sweep so a long-lived process prunes promptly.
void runCleanup(deps);
}
+171 -34
View File
@@ -1,67 +1,204 @@
import db from "../../db.server"; import db from "../../db.server";
import { ensureWebhookCleanupScheduled } from "./cleanup.server";
/** /**
* Minimal shape of the Prisma client surface we use — declared inline so * How long a `status="processing"` reservation is considered "live" before we
* the helper can be unit-tested with a tiny stub instead of pulling in a * assume the worker that claimed it crashed mid-process. After this window a
* real database. * stale reservation may be reclaimed and the work retried.
*/
const STALE_LEASE_MS = 5 * 60 * 1000; // 5 minutes
interface ProcessedRow {
webhookId: string;
status: string;
receivedAt: Date;
}
/**
* Minimal shape of the Prisma client surface we use — declared inline so the
* helper can be unit-tested with a tiny stub instead of a real database.
*/ */
export interface DedupeDeps { export interface DedupeDeps {
db: { db: {
processedWebhook: { processedWebhook: {
create: (args: { create: (args: {
data: { webhookId: string; topic: string; shopDomain: string }; data: { webhookId: string; topic: string; shopDomain: string; status: string };
}) => Promise<unknown>; }) => Promise<unknown>;
findUnique: (args: { where: { webhookId: string } }) => Promise<ProcessedRow | null>;
update: (args: {
where: { webhookId: string };
data: { status?: string; receivedAt?: Date };
}) => Promise<unknown>;
delete: (args: { where: { webhookId: string } }) => Promise<unknown>;
}; };
}; };
} }
/** /**
* Returns `true` when this Shopify webhook delivery has already been * A claim on a single Shopify webhook delivery. Obtained from
* processed and the caller should short-circuit without doing the work. * {@link reserveWebhook}. The caller MUST eventually `commit()` (work
* succeeded — the delivery is permanently deduped) or `release()` (work
* failed — drop the reservation so Shopify's retry re-runs the work).
* *
* Shopify retries webhook deliveries when it doesn't receive a 200 within * `commit`/`release` are no-ops for reservations without a webhook id (unit
* its (~5s) timeout window. Without dedupe this caused us to email an * tests / non-Shopify callers) and for the fail-open path.
* invoice twice for the same order: the first slow delivery completed its
* work but Shopify timed out and re-sent the webhook, which then ran the
* automation a second time.
*
* We key on the `X-Shopify-Webhook-Id` header — Shopify guarantees the same
* value for retries of the same delivery, but a new value for genuinely
* new events. The insert is the lock: a unique-constraint violation
* (Prisma error code `P2002`) means another delivery already claimed this
* id.
*/ */
export async function isDuplicateWebhook( export interface WebhookReservation {
webhookId: string | null;
commit: () => Promise<void>;
release: () => Promise<void>;
}
function noopReservation(webhookId: string | null): WebhookReservation {
return {
webhookId,
commit: async () => {},
release: async () => {},
};
}
function isP2002(err: unknown): boolean {
// Duck-typed so callers can stub the db without pulling in the real
// `Prisma` namespace. P2002 = unique-constraint violation.
return (err as { code?: string } | null)?.code === "P2002";
}
function makeReservation(
webhookId: string,
shop: string,
topic: string,
deps: DedupeDeps,
): WebhookReservation {
return {
webhookId,
commit: async () => {
try {
await deps.db.processedWebhook.update({
where: { webhookId },
data: { status: "done" },
});
} catch (err) {
// The work already succeeded; a failed commit just risks a later
// duplicate (which the side-effect code is expected to tolerate).
console.warn(`dedupe: failed to commit webhook ${webhookId} (${topic}/${shop}):`, err);
}
},
release: async () => {
try {
await deps.db.processedWebhook.delete({ where: { webhookId } });
} catch (err) {
console.warn(`dedupe: failed to release webhook ${webhookId} (${topic}/${shop}):`, err);
}
},
};
}
/**
* Reserve this Shopify webhook delivery for processing.
*
* Shopify retries a delivery (re-using the same `X-Shopify-Webhook-Id`) when
* it doesn't receive a 200 within its ~5s timeout. Naively recording the id as
* "processed" *before* doing the work meant that if the heavy background work
* later failed (SMTP/GraphQL/PDF error), Shopify's retry was dropped as a
* duplicate and the invoice was never sent.
*
* This uses a two-phase reserve/commit keyed on the webhook id, with the
* unique `webhookId` primary key as the concurrency lock:
*
* - RESERVE: insert a `status="processing"` row. A unique-constraint
* violation (`P2002`) means the id is already claimed; we then inspect the
* existing row:
* - `done` → genuine duplicate → return `null` (skip).
* - `processing`, fresh → another delivery is in flight → `null`.
* - `processing`, stale → previous worker crashed → reclaim & retry.
* - COMMIT (caller, on success) → flip the row to `status="done"`.
* - RELEASE (caller, on failure) → delete the row so a retry reprocesses.
*
* Returns a {@link WebhookReservation} when the caller should process the
* delivery, or `null` when it must short-circuit (duplicate / concurrent).
*
* Fail-open: a dedupe-table error (other than P2002) never silently drops a
* webhook — we return a no-op reservation and let the work run.
*/
export async function reserveWebhook(
request: Request, request: Request,
shop: string, shop: string,
topic: string, topic: string,
deps: DedupeDeps = { db }, deps: DedupeDeps = { db },
): Promise<boolean> { ): Promise<WebhookReservation | null> {
// Opportunistically schedule TTL cleanup (runtime-only; never in build/CLI
// since this is reached only while handling a live webhook request).
ensureWebhookCleanupScheduled();
const webhookId = request.headers.get("x-shopify-webhook-id"); const webhookId = request.headers.get("x-shopify-webhook-id");
if (!webhookId) { if (!webhookId) {
// Defensive: in unit tests / non-Shopify callers there is no id. // No id (unit tests / non-Shopify callers): process without dedupe.
// Don't dedupe — that would silently drop legitimate calls. return noopReservation(null);
return false;
} }
const reservation = makeReservation(webhookId, shop, topic, deps);
try { try {
await deps.db.processedWebhook.create({ await deps.db.processedWebhook.create({
data: { webhookId, topic, shopDomain: shop }, data: { webhookId, topic, shopDomain: shop, status: "processing" },
}); });
return false; return reservation;
} catch (err) { } catch (err) {
// Duck-typed P2002 check so callers can stub the db without pulling if (!isP2002(err)) {
// in the real `Prisma` namespace. // Don't fail (or silently drop) a webhook on a logging-table issue.
if ((err as { code?: string } | null)?.code === "P2002") { console.warn(`dedupe: failed to reserve webhook ${webhookId} (${topic}/${shop}):`, err);
return noopReservation(webhookId);
}
}
// A row already exists. Classify it.
let existing: ProcessedRow | null = null;
try {
existing = await deps.db.processedWebhook.findUnique({ where: { webhookId } });
} catch (err) {
console.warn(`dedupe: failed to load existing webhook ${webhookId} (${topic}/${shop}):`, err);
// Another worker owns the row and we can't classify it — be safe and skip.
return null;
}
if (!existing) {
// Raced with a release/delete between create() and findUnique(); reclaim.
return reservation;
}
if (existing.status === "done") {
console.log( console.log(
`dedupe: skipping duplicate ${topic} delivery for ${shop} (webhookId=${webhookId})`, `dedupe: skipping already-processed ${topic} for ${shop} (webhookId=${webhookId})`,
); );
return true; return null;
} }
// Don't fail the webhook on a logging-table issue; just process it.
console.warn( const age = Date.now() - new Date(existing.receivedAt).getTime();
`dedupe: failed to record webhook ${webhookId} (${topic}/${shop}):`, if (age > STALE_LEASE_MS) {
err, // The worker that reserved this crashed mid-process (or left a stale row).
// Renew the lease and retry the work.
try {
await deps.db.processedWebhook.update({
where: { webhookId },
data: { status: "processing", receivedAt: new Date() },
});
} catch (err) {
console.warn(`dedupe: failed to reclaim stale webhook ${webhookId}:`, err);
return null;
}
console.log(
`dedupe: reclaiming stale ${topic} reservation for ${shop} ` +
`(webhookId=${webhookId}, age=${Math.round(age / 1000)}s)`,
); );
return false; return reservation;
} }
// A fresh "processing" row: another delivery is actively working on it.
// Skip this concurrent delivery. Shopify will retry; if the active worker
// fails it releases the reservation so a later retry reprocesses.
console.log(
`dedupe: ${topic} for ${shop} already in-flight (webhookId=${webhookId}); ` +
`skipping concurrent delivery`,
);
return null;
} }
+6 -5
View File
@@ -4,17 +4,18 @@ import {
AppDistribution, AppDistribution,
shopifyApp, shopifyApp,
} from "@shopify/shopify-app-react-router/server"; } from "@shopify/shopify-app-react-router/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server"; import prisma from "./db.server";
import { requireEnv } from "./services/config/env.server";
import { EncryptedPrismaSessionStorage } from "./services/session/encryptedSessionStorage.server";
const shopify = shopifyApp({ const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY, apiKey: requireEnv("SHOPIFY_API_KEY"),
apiSecretKey: process.env.SHOPIFY_API_SECRET || "", apiSecretKey: requireEnv("SHOPIFY_API_SECRET"),
apiVersion: ApiVersion.October25, apiVersion: ApiVersion.October25,
scopes: process.env.SCOPES?.split(","), scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL || "", appUrl: requireEnv("SHOPIFY_APP_URL"),
authPathPrefix: "/auth", authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma), sessionStorage: new EncryptedPrismaSessionStorage(prisma),
distribution: AppDistribution.SingleMerchant, distribution: AppDistribution.SingleMerchant,
future: { future: {
expiringOfflineAccessTokens: true, expiringOfflineAccessTokens: true,
+16
View File
@@ -16,8 +16,24 @@ ALLOWED_SHOP=linumiq-dev.myshopify.com
# Must match `scopes` in shopify.app.dev.toml. # Must match `scopes` in shopify.app.dev.toml.
SCOPES=read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files SCOPES=read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files
# --- Secrets at rest ---
# Field-level encryption key for secrets stored in the DB (SMTP password,
# Shopify session access/refresh tokens). Must be base64 of exactly 32 bytes.
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
DATA_ENCRYPTION_KEY=REPLACE_ME_BASE64_32_BYTES
# Dedicated HMAC key for signing public GiroCode URLs. base64 of 32 bytes.
# If unset, the app falls back to SHOPIFY_API_SECRET (kept for backward compat).
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
GIROCODE_SIGNING_KEY=REPLACE_ME_BASE64_32_BYTES
# --- Runtime --- # --- Runtime ---
NODE_ENV=production NODE_ENV=production
PORT=3000 PORT=3000
# DATABASE_URL is set in docker-compose.dev.yml (file:/data/prod.sqlite on the bind mount). # DATABASE_URL is set in docker-compose.dev.yml (file:/data/prod.sqlite on the bind mount).
# --- Email (optional) ---
# Archival BCC for every invoice email. Off by default for privacy/GDPR.
# Set to a single address or a comma-separated list to opt in.
# INVOICE_BCC=archive@example.com
+9
View File
@@ -8,6 +8,15 @@
# DEV — installed on linumiq-dev.myshopify.com # DEV — installed on linumiq-dev.myshopify.com
invoice-app-dev.linumiq.com { invoice-app-dev.linumiq.com {
encode zstd gzip encode zstd gzip
# Security response headers. NOTE: deliberately no X-Frame-Options here —
# this is an embedded Shopify app, and framing is governed by the
# Content-Security-Policy `frame-ancestors` directive that the Shopify
# library injects via addDocumentResponseHeaders (see app/entry.server.tsx).
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
reverse_proxy linumiq-invoice-dev:3000 reverse_proxy linumiq-invoice-dev:3000
} }
+19
View File
@@ -48,6 +48,25 @@ docker compose up -d --build
Append `Caddyfile.snippet` to your Caddy config and `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`. Append `Caddyfile.snippet` to your Caddy config and `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
## Container runs as a non-root user (uid 1000)
The image runs as the unprivileged `node` user (uid/gid **1000**), not root. The
SQLite database is written to the `/data` bind mount, so the **host** directory
mounted at `/data` (e.g. `/docker/linumiq-invoice/dev/data` and
`…/prod/data`) must be writable by uid 1000, otherwise `prisma migrate deploy`
and DB writes fail on startup:
```bash
sudo chown -R 1000:1000 /docker/linumiq-invoice/dev/data
sudo chown -R 1000:1000 /docker/linumiq-invoice/prod/data
```
The dev container additionally runs with a **read-only root filesystem**
(`read_only: true` + `tmpfs: /tmp`), `no-new-privileges`, all Linux capabilities
dropped, and memory/pids/cpu limits. The app only writes to the `/data` bind
mount and the tmpfs `/tmp`, so this is safe. (The prod compose is intentionally
left unchanged.)
## Day-to-day redeploy ## Day-to-day redeploy
```bash ```bash
+18
View File
@@ -6,6 +6,24 @@ services:
image: linumiq-invoice:dev image: linumiq-invoice:dev
container_name: linumiq-invoice-dev container_name: linumiq-invoice-dev
restart: unless-stopped restart: unless-stopped
# --- Container hardening (DEV) ---------------------------------------
# Prevent privilege escalation and drop all Linux capabilities (the app
# is a plain Node HTTP server — it needs none).
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
# Read-only root filesystem: the app never writes to the image at runtime
# (Prisma client is baked at build; the SQLite DB lives on the /data bind
# mount; logo/image caches live in the DB or in-memory). npm/Prisma
# incidental writes are redirected to the tmpfs /tmp (see Dockerfile env).
read_only: true
tmpfs:
- /tmp
# Resource limits (Compose v2 / docker compose, non-swarm).
mem_limit: 512m
pids_limit: 256
cpus: 1.5
env_file: env_file:
- .env.dev - .env.dev
environment: environment:
+58 -36
View File
@@ -29,9 +29,12 @@
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"compression": "^1.8.1", "compression": "^1.8.1",
"express": "^4.22.1", "express": "^4.22.1",
"express-rate-limit": "^8.5.2",
"ipaddr.js": "^2.4.0",
"isbot": "^5.1.31", "isbot": "^5.1.31",
"morgan": "^1.10.1", "morgan": "^1.10.1",
"nodemailer": "^8.0.7", "nodemailer": "^8.0.7",
"p-limit": "^3.1.0",
"prisma": "^6.16.3", "prisma": "^6.16.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
@@ -5939,21 +5942,6 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser/node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
@@ -7864,14 +7852,14 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "~1.20.3", "body-parser": "~1.20.5",
"content-disposition": "~0.5.4", "content-disposition": "~0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "~0.7.1", "cookie": "~0.7.1",
@@ -7890,7 +7878,7 @@
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12", "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "~6.14.0", "qs": "~6.15.1",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "~0.19.0", "send": "~0.19.0",
@@ -7909,6 +7897,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/debug": { "node_modules/express/node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -8842,9 +8848,9 @@
} }
}, },
"node_modules/graphql-config/node_modules/brace-expansion": { "node_modules/graphql-config/node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9300,13 +9306,22 @@
"resolved": "extensions/invoice-thank-you-payment", "resolved": "extensions/invoice-thank-you-payment",
"link": true "link": true
}, },
"node_modules/ipaddr.js": { "node_modules/ip-address": {
"version": "1.9.1", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
} }
}, },
"node_modules/is-absolute": { "node_modules/is-absolute": {
@@ -10983,7 +10998,6 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"yocto-queue": "^0.1.0" "yocto-queue": "^0.1.0"
@@ -11540,6 +11554,15 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-addr/node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -11688,9 +11711,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -14225,9 +14248,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -14318,7 +14341,6 @@
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
+8 -5
View File
@@ -10,7 +10,7 @@
"config:use": "shopify app config use", "config:use": "shopify app config use",
"env": "shopify app env", "env": "shopify app env",
"start": "node ./server.js", "start": "node ./server.js",
"docker-start": "npm run setup && npm run start", "docker-start": "prisma migrate deploy && npm run start",
"setup": "prisma generate && prisma migrate deploy", "setup": "prisma generate && prisma migrate deploy",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"shopify": "shopify", "shopify": "shopify",
@@ -28,13 +28,10 @@
"@prisma/client": "^6.16.3", "@prisma/client": "^6.16.3",
"@react-pdf/renderer": "^4.5.1", "@react-pdf/renderer": "^4.5.1",
"@react-router/dev": "^7.12.0", "@react-router/dev": "^7.12.0",
"@react-router/express": "^7.14.2",
"@react-router/fs-routes": "^7.12.0", "@react-router/fs-routes": "^7.12.0",
"@react-router/node": "^7.12.0", "@react-router/node": "^7.12.0",
"@react-router/express": "^7.14.2",
"@react-router/serve": "^7.12.0", "@react-router/serve": "^7.12.0",
"compression": "^1.8.1",
"express": "^4.22.1",
"morgan": "^1.10.1",
"@shopify/app-bridge-react": "^4.2.4", "@shopify/app-bridge-react": "^4.2.4",
"@shopify/shopify-app-react-router": "^1.1.0", "@shopify/shopify-app-react-router": "^1.1.0",
"@shopify/shopify-app-session-storage-prisma": "^8.0.0", "@shopify/shopify-app-session-storage-prisma": "^8.0.0",
@@ -46,8 +43,14 @@
"@tiptap/react": "^3.23.1", "@tiptap/react": "^3.23.1",
"@tiptap/starter-kit": "^3.23.1", "@tiptap/starter-kit": "^3.23.1",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"compression": "^1.8.1",
"express": "^4.22.1",
"express-rate-limit": "^8.5.2",
"ipaddr.js": "^2.4.0",
"isbot": "^5.1.31", "isbot": "^5.1.31",
"morgan": "^1.10.1",
"nodemailer": "^8.0.7", "nodemailer": "^8.0.7",
"p-limit": "^3.1.0",
"prisma": "^6.16.3", "prisma": "^6.16.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
@@ -0,0 +1,16 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ProcessedWebhook" (
"webhookId" TEXT NOT NULL PRIMARY KEY,
"topic" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'done',
"receivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO "new_ProcessedWebhook" ("receivedAt", "shopDomain", "topic", "webhookId") SELECT "receivedAt", "shopDomain", "topic", "webhookId" FROM "ProcessedWebhook";
DROP TABLE "ProcessedWebhook";
ALTER TABLE "new_ProcessedWebhook" RENAME TO "ProcessedWebhook";
CREATE INDEX "ProcessedWebhook_shopDomain_topic_idx" ON "ProcessedWebhook"("shopDomain", "topic");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+7
View File
@@ -188,6 +188,13 @@ model ProcessedWebhook {
webhookId String @id webhookId String @id
topic String topic String
shopDomain String shopDomain String
// Reserve/commit lifecycle for at-least-once side-effect processing:
// "processing" — reserved, side-effect work in flight (acts as the lock)
// "done" — work completed successfully; future retries are dropped
// A "processing" row older than the stale lease (see dedupe.server.ts) is
// treated as a crashed reservation and may be reclaimed. Existing rows
// migrated from the old (record-before-work) design default to "done".
status String @default("done")
receivedAt DateTime @default(now()) receivedAt DateTime @default(now())
@@index([shopDomain, topic]) @@index([shopDomain, topic])
+48 -3
View File
@@ -15,6 +15,7 @@ import { createRequestHandler } from "@react-router/express";
import compression from "compression"; import compression from "compression";
import express from "express"; import express from "express";
import morgan from "morgan"; import morgan from "morgan";
import rateLimit from "express-rate-limit";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Console timestamps — patch BEFORE anything else logs. // Console timestamps — patch BEFORE anything else logs.
@@ -33,6 +34,11 @@ const build = buildModule.default ?? buildModule;
const app = express(); const app = express();
app.disable("x-powered-by"); app.disable("x-powered-by");
// The app runs behind a single reverse proxy (Caddy) that sets
// X-Forwarded-For. Trust ONLY the first proxy hop so req.ip reflects the real
// client IP for rate limiting, without trusting arbitrary client-supplied
// forwarding headers (which `trust proxy: true` would).
app.set("trust proxy", 1);
app.use(compression()); app.use(compression());
// Static assets emitted by the React Router build. // Static assets emitted by the React Router build.
@@ -46,15 +52,43 @@ app.use(express.static("public", { maxAge: "1h" }));
// Access log: ISO timestamp + standard request info; suppress healthy // Access log: ISO timestamp + standard request info; suppress healthy
// /healthz polls so real traffic stays visible. // /healthz polls so real traffic stays visible.
morgan.token("isotime", () => new Date().toISOString()); morgan.token("isotime", () => new Date().toISOString());
// Redacted URL: strip sensitive query parameters (the GiroCode HMAC `sig`
// and any `token`) from the logged URL so signed-URL secrets / bearer tokens
// never land in access logs. Other query params are preserved.
morgan.token("safeurl", (req) => {
const raw = req.originalUrl || req.url || "";
const qIdx = raw.indexOf("?");
if (qIdx === -1) return raw;
const path = raw.slice(0, qIdx);
const params = new URLSearchParams(raw.slice(qIdx + 1));
for (const key of ["sig", "token"]) {
if (params.has(key)) params.set(key, "REDACTED");
}
const qs = params.toString();
return qs ? `${path}?${qs}` : path;
});
app.use( app.use(
morgan( morgan(
":isotime :method :url :status :res[content-length] - :response-time ms", ":isotime :method :safeurl :status :res[content-length] - :response-time ms",
{ {
skip: (req, res) => req.url === "/healthz" && res.statusCode < 400, skip: (req, res) => req.url === "/healthz" && res.statusCode < 400,
}, },
), ),
); );
// Per-IP rate limiting for the public, unauthenticated-at-the-edge API
// surface only (/api/public/*). Shopify webhooks (/webhooks/*) and the
// embedded admin are intentionally NOT rate limited here — webhooks can burst
// legitimately and are already HMAC-verified.
const publicApiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
limit: 60, // 60 requests / minute / IP
standardHeaders: true, // RateLimit-* headers
legacyHeaders: false,
message: { error: "rate-limited" },
});
app.use("/api/public", publicApiLimiter);
app.all( app.all(
"*", "*",
createRequestHandler({ build, mode: process.env.NODE_ENV }), createRequestHandler({ build, mode: process.env.NODE_ENV }),
@@ -71,11 +105,22 @@ const server = HOST
: app.listen(PORT, onListen); : app.listen(PORT, onListen);
for (const signal of ["SIGTERM", "SIGINT"]) { for (const signal of ["SIGTERM", "SIGINT"]) {
process.once(signal, () => { process.once(signal, async () => {
console.log(`[server] received ${signal}, shutting down`); console.log(`[server] received ${signal}, shutting down`);
// Stop accepting new connections.
server.close((err) => { server.close((err) => {
if (err) console.error("[server] close error:", err); if (err) console.error("[server] close error:", err);
});
// Drain in-flight background webhook work (PDF render / SMTP send) before
// exiting so a container stop doesn't lose invoice work mid-send. The
// background queue exposes this bridge because server.js loads only the
// bundled build and can't import the module directly.
try {
const drain = globalThis.__linumiqWebhookDrain;
if (typeof drain === "function") await drain();
} catch (err) {
console.error("[server] webhook drain error:", err);
}
process.exit(0); process.exit(0);
}); });
});
} }
+77 -11
View File
@@ -2,7 +2,7 @@ import { strict as assert } from "node:assert";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { pickLanguage } from "../app/services/invoice/i18n"; import { pickLanguage } from "../app/services/invoice/i18n";
import { isDuplicateWebhook, type DedupeDeps } from "../app/services/webhooks/dedupe.server"; import { reserveWebhook, type DedupeDeps } from "../app/services/webhooks/dedupe.server";
describe("pickLanguage", () => { describe("pickLanguage", () => {
it("returns 'de' only for explicit German locales", () => { it("returns 'de' only for explicit German locales", () => {
@@ -38,8 +38,21 @@ function makeRequest(headers: Record<string, string> = {}): Request {
}); });
} }
function makeDeps(behaviour: "ok" | "p2002" | "boom"): DedupeDeps { type ExistingRow = { webhookId: string; status: string; receivedAt: Date } | null;
/**
* Build a DedupeDeps stub.
* - "ok" : create() succeeds (fresh reservation).
* - "p2002" : create() conflicts; findUnique() returns `existing`.
* - "boom" : create() throws a non-P2002 error (fail-open).
*/
function makeDeps(
behaviour: "ok" | "p2002" | "boom",
existing: ExistingRow = null,
): DedupeDeps & { calls: { commit: number; release: number; update: number } } {
const calls = { commit: 0, release: 0, update: 0 };
return { return {
calls,
db: { db: {
processedWebhook: { processedWebhook: {
create: async () => { create: async () => {
@@ -51,29 +64,82 @@ function makeDeps(behaviour: "ok" | "p2002" | "boom"): DedupeDeps {
} }
throw new Error("DB unavailable"); throw new Error("DB unavailable");
}, },
findUnique: async () => existing,
update: async () => {
calls.update += 1;
calls.commit += 1;
return {};
},
delete: async () => {
calls.release += 1;
return {};
},
}, },
}, },
}; };
} }
describe("isDuplicateWebhook", () => { describe("reserveWebhook", () => {
it("returns false on first delivery (insert succeeds)", async () => { it("returns a reservation on first delivery (insert succeeds)", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" }); const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
assert.equal(await isDuplicateWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok")), false); const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok"));
assert.ok(res, "expected a reservation");
assert.equal(res!.webhookId, "abc-123");
}); });
it("returns true on a retried delivery (P2002)", async () => { it("returns null for an already-processed (done) delivery", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" }); const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
assert.equal(await isDuplicateWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("p2002")), true); const deps = makeDeps("p2002", {
webhookId: "abc-123",
status: "done",
receivedAt: new Date(),
});
assert.equal(await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps), null);
}); });
it("returns false (and proceeds) when the dedupe table itself errors \u2014 fail-open, never drop a webhook silently", async () => { it("returns null for a fresh in-flight (processing) delivery", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" }); const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
assert.equal(await isDuplicateWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("boom")), false); const deps = makeDeps("p2002", {
webhookId: "abc-123",
status: "processing",
receivedAt: new Date(), // fresh lease
});
assert.equal(await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps), null);
}); });
it("returns false when the X-Shopify-Webhook-Id header is missing (test harness / non-Shopify caller)", async () => { it("reclaims a stale (crashed) processing reservation", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
const deps = makeDeps("p2002", {
webhookId: "abc-123",
status: "processing",
receivedAt: new Date(Date.now() - 10 * 60 * 1000), // 10 min ago > 5 min lease
});
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps);
assert.ok(res, "expected to reclaim the stale reservation");
assert.equal(deps.calls.update, 1, "stale reclaim should renew the lease via update()");
});
it("commit() flips the row to done; release() deletes it", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
const deps = makeDeps("ok");
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps);
await res!.commit();
assert.equal(deps.calls.commit, 1);
await res!.release();
assert.equal(deps.calls.release, 1);
});
it("fails open (returns a no-op reservation) when the dedupe table errors", async () => {
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("boom"));
assert.ok(res, "fail-open must still process — never silently drop a webhook");
assert.equal(res!.webhookId, "abc-123");
});
it("returns a no-op reservation when the X-Shopify-Webhook-Id header is missing", async () => {
const req = makeRequest(); const req = makeRequest();
assert.equal(await isDuplicateWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok")), false); const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok"));
assert.ok(res, "missing id => process without dedupe");
assert.equal(res!.webhookId, null);
}); });
}); });