fix security issues

This commit is contained in:
Gerhard Scheikl
2026-05-09 22:19:25 +02:00
parent c45648832a
commit 3a77bed716
6 changed files with 391 additions and 36 deletions
+21 -8
View File
@@ -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;
}
}
+15 -12
View File
@@ -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<ReturnType<typeof safeFetch>>;
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 },
@@ -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<string | un
? `${url}${url.includes("?") ? "&" : "?"}width=128`
: url;
let res: Response;
let res: Awaited<ReturnType<typeof safeFetch>>;
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;
+240
View File
@@ -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 `.<entry>` 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<SafeFetchResult> {
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<string, string> = {
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<SafeFetchResult>((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"];