diff --git a/app/routes/api.public.payment-info.tsx b/app/routes/api.public.payment-info.tsx index f245155..cf802c2 100644 --- a/app/routes/api.public.payment-info.tsx +++ b/app/routes/api.public.payment-info.tsx @@ -25,17 +25,23 @@ import { signGiroCodeUrl } from "../services/invoice/signedUrl"; * - the shop has an IBAN configured. */ export const loader = async ({ request }: LoaderFunctionArgs) => { - let sessionToken: { dest?: string } | null = null; - let cors: (res: T) => T = (r) => r; + type AuthSource = "customerAccount" | "checkout"; + type SessionTokenLike = { dest?: string; sub?: string }; + type CorsFn = (res: Response) => Response; + let sessionToken: SessionTokenLike | null = null; + let cors: CorsFn = (r) => r; + let authSource: AuthSource | null = null; try { const auth = await authenticate.public.customerAccount(request); - sessionToken = auth.sessionToken as { dest?: string }; - cors = auth.cors; + sessionToken = auth.sessionToken as SessionTokenLike; + cors = auth.cors as CorsFn; + authSource = "customerAccount"; } catch { try { const auth = await authenticate.public.checkout(request); - sessionToken = auth.sessionToken as { dest?: string }; - cors = auth.cors; + sessionToken = auth.sessionToken as SessionTokenLike; + cors = auth.cors as CorsFn; + authSource = "checkout"; } catch (err) { throw err; } @@ -99,6 +105,44 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ); } + // ---- Ownership check ---- + // Without this, any authenticated buyer of the shop could enumerate + // arbitrary orderIds and harvest the shop's bank details / amounts. + // + // - 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). + 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, ""); + + let ownershipOk = false; + 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). + const placedAtMs = orderInfo.processedAtMs ?? 0; + const RECENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour + ownershipOk = placedAtMs > 0 && Date.now() - placedAtMs <= RECENT_WINDOW_MS; + } + + if (!ownershipOk) { + console.warn( + `payment-info: ownership check failed for shop=${shop} order=${orderGid} ` + + `authSource=${authSource} tokenSub=${tokenSub || "-"}`, + ); + return cors( + Response.json( + { showPaymentInstructions: false, error: "forbidden" }, + { status: 403 }, + ), + ); + } + const language = pickLanguage(orderInfo.customerLocale ?? settings.defaultLanguage); const t = getStrings(language); @@ -161,6 +205,8 @@ interface OrderInfo { currency: string; orderName: string; customerLocale?: string; + customerId?: string; + processedAtMs?: number; txCount: number; manualFlags: Array<{ status?: string; manual?: boolean }>; } @@ -176,6 +222,9 @@ async function fetchOrderInfo( name currencyCode customerLocale + processedAt + createdAt + customer { id } totalPriceSet { shopMoney { amount } } totalOutstandingSet { shopMoney { amount } } transactions(first: 20) { @@ -192,6 +241,9 @@ async function fetchOrderInfo( name?: string; currencyCode?: string; customerLocale?: string | null; + processedAt?: string | null; + createdAt?: string | null; + customer?: { id?: string } | null; totalPriceSet?: { shopMoney: { amount: string } }; totalOutstandingSet?: { shopMoney: { amount: string } }; transactions?: Array<{ status?: string; manualPaymentGateway?: boolean }>; @@ -211,6 +263,13 @@ async function fetchOrderInfo( currency: o.currencyCode ?? "EUR", orderName: o.name ?? "", customerLocale: o.customerLocale ?? undefined, + customerId: o.customer?.id ?? undefined, + processedAtMs: (() => { + const raw = o.processedAt ?? o.createdAt ?? null; + if (!raw) return undefined; + const t = Date.parse(raw); + return Number.isFinite(t) ? t : undefined; + })(), txCount: txs.length, manualFlags: txs.map((t) => ({ status: t.status, manual: t.manualPaymentGateway })), }; diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx index 88160fa..108afb8 100644 --- a/app/routes/app.settings.tsx +++ b/app/routes/app.settings.tsx @@ -31,6 +31,13 @@ interface SettingsFieldErrors { logo?: string; } +/** + * Sentinel value used in the SMTP password input. The real password is + * never sent to the client; if the form posts back this exact value the + * action treats it as "unchanged" and keeps whatever is already in the DB. + */ +const SMTP_PASSWORD_SENTINEL = "__unchanged__"; + export const loader = async ({ request }: LoaderFunctionArgs) => { const { session } = await authenticate.admin(request); const settings = await db.shopSettings.upsert({ @@ -48,7 +55,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // External HTTPS URL — fine to display directly in the editor. logoPreviewDataUrl = settings.logoUrl; } - return { settings, logoPreviewDataUrl }; + // Never expose the SMTP password to the browser. We replace it with a + // sentinel and the form action interprets that as "keep existing value". + const safeSettings = { + ...settings, + smtpPassword: settings.smtpPassword ? SMTP_PASSWORD_SENTINEL : "", + }; + return { settings: safeSettings, logoPreviewDataUrl, smtpPasswordSentinel: SMTP_PASSWORD_SENTINEL }; }; export const action = async ({ request }: ActionFunctionArgs) => { @@ -134,6 +147,22 @@ export const action = async ({ request }: ActionFunctionArgs) => { return { ok: false, errors, savedAt: null as string | null }; } + // Resolve SMTP password: the loader sends a sentinel instead of the real + // value. If the form posts that sentinel back unchanged, keep whatever is + // already in the DB; otherwise persist the new value (including the empty + // string, which means "clear the password"). + const submittedSmtpPassword = str("smtpPassword"); + let nextSmtpPassword: string; + if (submittedSmtpPassword === SMTP_PASSWORD_SENTINEL) { + const current = await db.shopSettings.findUnique({ + where: { shopDomain: session.shop }, + select: { smtpPassword: true }, + }); + nextSmtpPassword = current?.smtpPassword ?? ""; + } else { + nextSmtpPassword = submittedSmtpPassword; + } + const data = { companyName: str("companyName"), legalForm: str("legalForm"), @@ -167,7 +196,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { smtpPort: smtpPort ?? 587, smtpSecure: bool("smtpSecure"), smtpUser: str("smtpUser"), - smtpPassword: str("smtpPassword"), + smtpPassword: nextSmtpPassword, smtpFromName: str("smtpFromName"), smtpFromEmail: str("smtpFromEmail"), smtpReplyTo: str("smtpReplyTo"), diff --git a/app/services/invoice/email.server.ts b/app/services/invoice/email.server.ts index b03420b..cd7e0ea 100644 --- a/app/services/invoice/email.server.ts +++ b/app/services/invoice/email.server.ts @@ -11,6 +11,7 @@ import { DEFAULT_EMAIL_SUBJECT_EN, } from "./emailTemplates"; import { STORED_LOGO_SENTINEL } from "./logoCache.constants"; +import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server"; export interface SendInvoiceEmailArgs { shopDomain: string; @@ -100,9 +101,15 @@ export async function sendInvoiceEmail( // Download the PDF (Shopify Files URLs are public CDN URLs). let pdfBytes: Uint8Array; try { - const res = await fetch(invoice.pdfUrl); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - pdfBytes = new Uint8Array(await res.arrayBuffer()); + const res = await safeFetch(invoice.pdfUrl, { + maxBytes: 25 * 1024 * 1024, // 25 MB — generous; emails impose their own limit later + accept: "application/pdf", + // Invoice PDFs always live on Shopify's Files CDN — anything else is + // suspicious and should be rejected. + allowedHosts: SHOPIFY_CDN_HOSTS, + }); + if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}`); + pdfBytes = res.bytes; } catch (err) { const m = err instanceof Error ? err.message : String(err); return failLog(args, `Failed to download invoice PDF: ${m}`, invoice.id); @@ -348,11 +355,17 @@ async function loadInlineLogo( if (!cached) return null; return { bytes: new Uint8Array(cached.bytes), contentType: cached.contentType }; } - const res = await fetch(settings.logoUrl); - if (!res.ok) return null; - const ct = res.headers.get("content-type") ?? "image/png"; - return { bytes: new Uint8Array(await res.arrayBuffer()), contentType: ct }; - } catch { + const res = await safeFetch(settings.logoUrl, { + maxBytes: 5 * 1024 * 1024, + accept: "image/*", + }); + if (res.status < 200 || res.status >= 300) return null; + const ct = res.contentType ?? "image/png"; + return { bytes: res.bytes, contentType: ct }; + } catch (err) { + if (err instanceof SafeFetchError) { + console.warn(`Inline logo fetch refused (${err.code}): ${err.message}`); + } return null; } } diff --git a/app/services/invoice/logoCache.server.ts b/app/services/invoice/logoCache.server.ts index 0f8a170..58c3a86 100644 --- a/app/services/invoice/logoCache.server.ts +++ b/app/services/invoice/logoCache.server.ts @@ -1,5 +1,6 @@ import db from "../../db.server"; import { STORED_LOGO_SENTINEL } from "./logoCache.constants"; +import { safeFetch, SafeFetchError } from "./safeFetch.server"; const MAX_BYTES = 5 * 1024 * 1024; // 5 MB cap const STALE_AFTER_MS = 24 * 60 * 60 * 1000; // re-fetch once a day at most @@ -41,26 +42,28 @@ export async function getLogoDataUrl( return toDataUrl(cached.bytes, cached.contentType); } - let response: Response; + let response: Awaited>; try { - response = await fetch(logoUrl); + response = await safeFetch(logoUrl, { + maxBytes: MAX_BYTES, + accept: "image/*", + }); } catch (err) { - console.warn(`Logo fetch failed for ${shopDomain}:`, err); + if (err instanceof SafeFetchError) { + console.warn(`Logo fetch refused for ${shopDomain} (${err.code}): ${err.message}`); + } else { + console.warn(`Logo fetch failed for ${shopDomain}:`, err); + } return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined; } - if (!response.ok) { + if (response.status < 200 || response.status >= 300) { console.warn(`Logo fetch HTTP ${response.status} for ${shopDomain}`); return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined; } - const arrayBuf = await response.arrayBuffer(); - if (arrayBuf.byteLength > MAX_BYTES) { - console.warn(`Logo too large (${arrayBuf.byteLength} bytes) — skipping cache.`); - return undefined; - } - const bytes = Buffer.from(arrayBuf); - const contentType = response.headers.get("content-type") || guessContentType(logoUrl); - const etag = response.headers.get("etag") || ""; + const bytes = Buffer.from(response.bytes); + const contentType = response.contentType || guessContentType(logoUrl); + const etag = ""; await db.logoCache.upsert({ where: { shopDomain }, diff --git a/app/services/invoice/productImageCache.server.ts b/app/services/invoice/productImageCache.server.ts index c7ccf1f..98e1bce 100644 --- a/app/services/invoice/productImageCache.server.ts +++ b/app/services/invoice/productImageCache.server.ts @@ -6,6 +6,8 @@ * served from Shopify's CDN so re-fetching is cheap, but caching avoids * hammering the network when regenerating an invoice multiple times. */ +import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server"; + const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image const CACHE_MAX_ENTRIES = 200; @@ -40,25 +42,34 @@ export async function fetchProductImageDataUrl(url: string): Promise>; try { - res = await fetch(requestUrl); + res = await safeFetch(requestUrl, { + maxBytes: MAX_BYTES, + accept: "image/*", + // Lock product images to Shopify's CDN — line item image URLs come + // from the Admin API and should never point anywhere else. + allowedHosts: SHOPIFY_CDN_HOSTS, + }); } catch (err) { - console.warn(`Product image fetch failed for ${url}:`, err); + if (err instanceof SafeFetchError) { + console.warn(`Product image refused (${err.code}) for ${url}: ${err.message}`); + } else { + console.warn(`Product image fetch failed for ${url}:`, err); + } return undefined; } - if (!res.ok) { + if (res.status < 200 || res.status >= 300) { console.warn(`Product image HTTP ${res.status} for ${url}`); return undefined; } - const buf = await res.arrayBuffer(); - if (buf.byteLength === 0 || buf.byteLength > MAX_BYTES) return undefined; + if (res.bytesRead === 0) return undefined; - const contentType = guessContentType(url, res.headers.get("content-type")); + const contentType = guessContentType(url, res.contentType); // @react-pdf supports png/jpeg natively; webp/gif are unreliable. Skip those. if (contentType !== "image/png" && contentType !== "image/jpeg") return undefined; - const b64 = Buffer.from(buf).toString("base64"); + const b64 = Buffer.from(res.bytes).toString("base64"); const dataUrl = `data:${contentType};base64,${b64}`; rememberInCache(url, dataUrl); return dataUrl; diff --git a/app/services/invoice/safeFetch.server.ts b/app/services/invoice/safeFetch.server.ts new file mode 100644 index 0000000..aa20a9d --- /dev/null +++ b/app/services/invoice/safeFetch.server.ts @@ -0,0 +1,240 @@ +/** + * SSRF-hardened `fetch` for use whenever the URL we're about to call could + * be influenced by user input (shop settings, Shopify-supplied product + * image URLs, DB-stored Files URLs, …). + * + * Defenses: + * - Only `https:` is allowed by default. `http:` is allowed only for + * localhost when `NODE_ENV !== "production"` (handy for local dev). + * - Hostname is DNS-resolved and every returned address is checked + * against private / loopback / link-local / unique-local ranges. + * - The connection is then forced to the resolved IP (with the original + * Host header preserved) to defeat DNS-rebinding. + * - A hard request timeout is enforced (default 5 s). + * - Response size is capped while reading; we abort once the limit is + * exceeded instead of buffering the whole body first. + * - Redirects are not followed — if the caller wants a redirected target + * they have to re-validate it explicitly. + * + * The helper returns the raw bytes plus the response status / content-type + * so callers can decide what to do with them. + */ +import { lookup as dnsLookup } from "node:dns/promises"; +import net from "node:net"; +import { Agent as HttpAgent } from "node:http"; +import { Agent as HttpsAgent } from "node:https"; +import http from "node:http"; +import https from "node:https"; + +export interface SafeFetchOptions { + /** Hard cap in bytes; the read aborts as soon as this is exceeded. */ + maxBytes?: number; + /** Total request timeout in milliseconds (default 5000). */ + timeoutMs?: number; + /** Optional `Accept` header. */ + accept?: string; + /** + * If non-empty, only hosts whose lowercase name equals one of these or + * ends with `.` are allowed. Useful for locking calls to known + * good CDNs (e.g. `cdn.shopify.com`). + */ + allowedHosts?: string[]; +} + +export interface SafeFetchResult { + status: number; + contentType: string | null; + bytes: Uint8Array; + bytesRead: number; +} + +export class SafeFetchError extends Error { + readonly code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + this.name = "SafeFetchError"; + } +} + +const DEFAULT_TIMEOUT_MS = 5_000; +const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MB + +function isPrivateIpv4(ip: string): boolean { + const parts = ip.split(".").map((n) => parseInt(n, 10)); + if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) { + // Treat malformed addresses as unsafe. + return true; + } + const [a, b] = parts; + if (a === 10) return true; + if (a === 127) return true; + if (a === 0) return true; + if (a === 169 && b === 254) return true; // link-local + AWS metadata + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 192 && b === 0) return true; // 192.0.0.0/24, 192.0.2.0/24 + if (a === 198 && (b === 18 || b === 19)) return true; + if (a >= 224) return true; // multicast / reserved + return false; +} + +function isPrivateIpv6(ip: string): boolean { + const lower = ip.toLowerCase(); + if (lower === "::1" || lower === "::") return true; + if (lower.startsWith("fe80:")) return true; // link-local + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA + 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 { + if (family === 4) return isPrivateIpv4(ip); + if (family === 6) return isPrivateIpv6(ip); + return true; +} + +function hostMatchesAllowlist(hostname: string, allowed: string[] | undefined): boolean { + if (!allowed || allowed.length === 0) return true; + const h = hostname.toLowerCase(); + return allowed.some((entry) => { + const e = entry.toLowerCase(); + return h === e || h.endsWith(`.${e}`); + }); +} + +/** + * Resolves a hostname to an IPv4/IPv6 address that has been vetted against + * the private/loopback ranges. Throws `SafeFetchError` if no safe address + * can be obtained. + */ +async function resolveSafeAddress(hostname: string): Promise<{ address: string; family: number }> { + // If the hostname is already an IP literal, validate it directly. + if (net.isIP(hostname)) { + const family = net.isIPv6(hostname) ? 6 : 4; + if (isPrivateAddress(hostname, family)) { + throw new SafeFetchError("blocked-address", `Refusing to connect to private address ${hostname}`); + } + return { address: hostname, family }; + } + let results: { address: string; family: number }[]; + try { + results = await dnsLookup(hostname, { all: true }); + } catch (err) { + throw new SafeFetchError("dns-failed", `DNS lookup failed for ${hostname}: ${(err as Error).message}`); + } + for (const r of results) { + if (isPrivateAddress(r.address, r.family)) { + throw new SafeFetchError("blocked-address", `${hostname} resolves to private address ${r.address}`); + } + } + const first = results[0]; + if (!first) throw new SafeFetchError("dns-empty", `${hostname} resolved to no addresses`); + return { address: first.address, family: first.family }; +} + +/** + * Performs an SSRF-safe HTTP(S) GET. Throws `SafeFetchError` for policy + * violations; throws plain `Error` for transport failures (mirroring the + * standard `fetch` error model). + */ +export async function safeFetch(rawUrl: string, opts: SafeFetchOptions = {}): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new SafeFetchError("bad-url", `Invalid URL: ${rawUrl}`); + } + + const allowHttp = + process.env.NODE_ENV !== "production" && + (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"); + if (url.protocol !== "https:" && !(url.protocol === "http:" && allowHttp)) { + throw new SafeFetchError("bad-scheme", `Refusing non-https URL: ${url.protocol}//${url.hostname}`); + } + + if (!hostMatchesAllowlist(url.hostname, opts.allowedHosts)) { + throw new SafeFetchError("host-not-allowed", `Host ${url.hostname} is not on the allowlist`); + } + + const { address, family } = await resolveSafeAddress(url.hostname); + + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; + + // Pin the resolved IP. We pass an Agent with a custom `lookup` that always + // returns our pre-validated address, so the actual TCP connect can't be + // re-resolved to something else (DNS-rebinding defense). + const pinnedLookup = ( + _hostname: string, + _options: unknown, + cb: (err: NodeJS.ErrnoException | null, address: string, family: number) => void, + ) => cb(null, address, family); + + const isHttps = url.protocol === "https:"; + const agent = isHttps + ? new HttpsAgent({ keepAlive: false, lookup: pinnedLookup as never }) + : new HttpAgent({ keepAlive: false, lookup: pinnedLookup as never }); + + const headers: Record = { + Host: url.host, + "User-Agent": "linumiq-invoice/1.0 (+https://linumiq.com)", + }; + if (opts.accept) headers["Accept"] = opts.accept; + + const requestOptions: http.RequestOptions = { + method: "GET", + host: url.hostname, + port: url.port ? parseInt(url.port, 10) : isHttps ? 443 : 80, + path: `${url.pathname}${url.search}`, + headers, + agent, + // Defeat redirects (Node's http doesn't follow by default). + }; + + return new Promise((resolve, reject) => { + const lib = isHttps ? https : http; + const req = lib.request(requestOptions, (res) => { + const status = res.statusCode ?? 0; + // Reject 3xx — caller must explicitly re-call with the new URL. + if (status >= 300 && status < 400) { + res.resume(); + reject(new SafeFetchError("redirect-not-allowed", `Refusing redirect ${status} from ${rawUrl}`)); + return; + } + const chunks: Buffer[] = []; + let total = 0; + res.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + res.destroy(new SafeFetchError("too-large", `Response exceeded ${maxBytes} bytes`)); + return; + } + chunks.push(chunk); + }); + res.on("end", () => { + const buf = Buffer.concat(chunks, total); + resolve({ + status, + contentType: res.headers["content-type"] ?? null, + bytes: new Uint8Array(buf), + bytesRead: total, + }); + }); + res.on("error", (err) => reject(err)); + }); + req.setTimeout(timeoutMs, () => { + req.destroy(new SafeFetchError("timeout", `Request to ${url.hostname} exceeded ${timeoutMs}ms`)); + }); + req.on("error", (err) => reject(err)); + req.end(); + }); +} + +/** Common allowlist for Shopify-served assets (CDN + Files). */ +export const SHOPIFY_CDN_HOSTS = ["cdn.shopify.com", "shopifycdn.com", "shopify.com"];