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
+49 -8
View File
@@ -12,6 +12,8 @@ import {
} from "./emailTemplates";
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
import { decryptField } from "../crypto/fieldCrypto.server";
import { optionalEnv } from "../config/env.server";
export interface SendInvoiceEmailArgs {
shopDomain: string;
@@ -86,7 +88,7 @@ export async function sendInvoiceEmail(
const customSubject =
(language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe) ||
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
const subject = renderTemplate(customSubject, vars);
const subject = renderTemplate(customSubject, vars, { html: false });
const customBodyHtml =
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
@@ -124,10 +126,13 @@ export async function sendInvoiceEmail(
}
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({
from: `"${fromName}" <${fromEmail}>`,
to,
bcc: "shop@linumiq.com",
...(bcc ? { bcc } : {}),
replyTo: settings.smtpReplyTo || undefined,
subject,
text: body.text,
@@ -180,7 +185,7 @@ function buildTransport(settings: ShopSettings): Transporter {
port: settings.smtpPort,
secure: settings.smtpSecure,
auth: settings.smtpUser
? { user: settings.smtpUser, pass: settings.smtpPassword }
? { user: settings.smtpUser, pass: decryptField(settings.smtpPassword) }
: undefined,
});
}
@@ -286,13 +291,49 @@ function buildTemplateVars(args: {
/**
* Substitutes {{token}} placeholders in `template`. Unknown tokens are left
* in place so the user notices typos instead of silent blanks. Values are
* inserted verbatim — callers are responsible for HTML-escaping if needed.
* in place so the user notices typos instead of silent blanks.
*
* 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) => {
const v = (vars as unknown as Record<string, string | undefined>)[key];
return v === undefined ? full : v;
const raw = (vars as unknown as Record<string, string | undefined>)[key];
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;
}
/**
* 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 {
const currency = input.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.`);
}
// Beneficiary name max 70 chars per spec.
const name = input.beneficiaryName.slice(0, 70);
// Beneficiary name max 70 chars per spec. Strip CR/LF first so injected
// 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 bic = (input.bic || "").replace(/\s+/g, "").toUpperCase();
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.
// Service tag SCT = SEPA Credit Transfer.
+16 -1
View File
@@ -22,6 +22,21 @@ const TEXT_DARK = "#1F2933";
const TEXT_MUTED = "#6B7280";
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({
page: {
paddingTop: 40,
@@ -348,7 +363,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
{t.trackingLabel}
{tr.company ? ` (${tr.company})` : ""}
</Text>
{tr.url ? (
{tr.url && isHttpUrl(tr.url) ? (
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
) : (
<Text style={styles.metaValue}>{tr.number}</Text>
@@ -7,9 +7,14 @@
* hammering the network when regenerating an invoice multiple times.
*/
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 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
@@ -76,17 +81,28 @@ export async function fetchProductImageDataUrl(url: string): Promise<string | un
}
/**
* Resolves images for every line in parallel, mutating `imageDataUrl` in place.
* Failures are swallowed (the row simply renders without an icon).
* Resolves images for every line, mutating `imageDataUrl` in place. Fetches
* 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(
lines: { imageUrl?: string; imageDataUrl?: string }[],
): Promise<void> {
await Promise.all(
lines.map(async (line) => {
if (!line.imageUrl) return;
const dataUrl = await fetchProductImageDataUrl(line.imageUrl);
if (dataUrl) line.imageDataUrl = dataUrl;
}),
);
const limit = pLimit(IMAGE_FETCH_CONCURRENCY);
let budget = MAX_IMAGES_PER_INVOICE;
const tasks: Promise<void>[] = [];
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;
}),
);
}
await Promise.all(tasks);
}
+83 -36
View File
@@ -25,6 +25,7 @@ import { Agent as HttpAgent } from "node:http";
import { Agent as HttpsAgent } from "node:https";
import http from "node:http";
import https from "node:https";
import ipaddr from "ipaddr.js";
export interface SafeFetchOptions {
/** 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_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;
/**
* Default-deny address classifier backed by the well-vetted `ipaddr.js`
* library. An address is considered safe to connect to ONLY if it is a
* clearly public, globally-routable unicast address. Everything else —
* loopback, private (RFC1918), link-local, unique-local, multicast,
* reserved, unspecified, broadcast, carrier-grade NAT, plus the various
* IPv4-in-IPv6 tunnelling/transition forms — is rejected.
*
* This closes IPv6 bypasses that string-prefix checks miss, e.g.:
* - `::ffff:7f00:1` (IPv4-mapped HEX form of 127.0.0.1)
* - `::7f00:1` (deprecated IPv4-compatible ::127.0.0.1)
* - `fe90::` / `fea0::` / `feb0::` (link-local is fe80::/10, not just fe80:)
*/
function isSafePublicAddress(ip: string): boolean {
let addr: ipaddr.IPv4 | ipaddr.IPv6;
try {
addr = ipaddr.parse(ip);
} catch {
// Unparseable => treat as unsafe.
return false;
}
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;
if (addr.kind() === "ipv4") {
// Only globally-routable unicast IPv4 is allowed. `range()` returns
// 'unicast' exclusively for public space; private/loopback/linkLocal/
// carrierGradeNat/reserved/broadcast/multicast/unspecified are all denied.
return (addr as ipaddr.IPv4).range() === "unicast";
}
const v6 = addr as ipaddr.IPv6;
// Unwrap IPv4-mapped (::ffff:a.b.c.d, incl. hex form ::ffff:7f00:1) and
// 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 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 isPrivateAddress(ip: string): boolean {
return !isSafePublicAddress(ip);
}
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 (net.isIP(hostname)) {
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}`);
}
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}`);
}
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}`);
}
}
@@ -269,3 +289,30 @@ export async function safeFetch(rawUrl: string, opts: SafeFetchOptions = {}): Pr
/** Common allowlist for Shopify-served assets (CDN + Files). */
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";
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 {
return crypto.createHmac("sha256", SECRET).update(payload).digest("hex");
return crypto.createHmac("sha256", getSigningKey()).update(payload).digest("hex");
}
export interface GiroCodeUrlParams {