fix security issues
This commit is contained in:
@@ -25,17 +25,23 @@ import { signGiroCodeUrl } from "../services/invoice/signedUrl";
|
|||||||
* - the shop has an IBAN configured.
|
* - the shop has an IBAN configured.
|
||||||
*/
|
*/
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
let sessionToken: { dest?: string } | null = null;
|
type AuthSource = "customerAccount" | "checkout";
|
||||||
let cors: <T>(res: T) => T = (r) => r;
|
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 {
|
try {
|
||||||
const auth = await authenticate.public.customerAccount(request);
|
const auth = await authenticate.public.customerAccount(request);
|
||||||
sessionToken = auth.sessionToken as { dest?: string };
|
sessionToken = auth.sessionToken as SessionTokenLike;
|
||||||
cors = auth.cors;
|
cors = auth.cors as CorsFn;
|
||||||
|
authSource = "customerAccount";
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
const auth = await authenticate.public.checkout(request);
|
const auth = await authenticate.public.checkout(request);
|
||||||
sessionToken = auth.sessionToken as { dest?: string };
|
sessionToken = auth.sessionToken as SessionTokenLike;
|
||||||
cors = auth.cors;
|
cors = auth.cors as CorsFn;
|
||||||
|
authSource = "checkout";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw 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 language = pickLanguage(orderInfo.customerLocale ?? settings.defaultLanguage);
|
||||||
const t = getStrings(language);
|
const t = getStrings(language);
|
||||||
|
|
||||||
@@ -161,6 +205,8 @@ interface OrderInfo {
|
|||||||
currency: string;
|
currency: string;
|
||||||
orderName: string;
|
orderName: string;
|
||||||
customerLocale?: string;
|
customerLocale?: string;
|
||||||
|
customerId?: string;
|
||||||
|
processedAtMs?: number;
|
||||||
txCount: number;
|
txCount: number;
|
||||||
manualFlags: Array<{ status?: string; manual?: boolean }>;
|
manualFlags: Array<{ status?: string; manual?: boolean }>;
|
||||||
}
|
}
|
||||||
@@ -176,6 +222,9 @@ async function fetchOrderInfo(
|
|||||||
name
|
name
|
||||||
currencyCode
|
currencyCode
|
||||||
customerLocale
|
customerLocale
|
||||||
|
processedAt
|
||||||
|
createdAt
|
||||||
|
customer { id }
|
||||||
totalPriceSet { shopMoney { amount } }
|
totalPriceSet { shopMoney { amount } }
|
||||||
totalOutstandingSet { shopMoney { amount } }
|
totalOutstandingSet { shopMoney { amount } }
|
||||||
transactions(first: 20) {
|
transactions(first: 20) {
|
||||||
@@ -192,6 +241,9 @@ async function fetchOrderInfo(
|
|||||||
name?: string;
|
name?: string;
|
||||||
currencyCode?: string;
|
currencyCode?: string;
|
||||||
customerLocale?: string | null;
|
customerLocale?: string | null;
|
||||||
|
processedAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
customer?: { id?: string } | null;
|
||||||
totalPriceSet?: { shopMoney: { amount: string } };
|
totalPriceSet?: { shopMoney: { amount: string } };
|
||||||
totalOutstandingSet?: { shopMoney: { amount: string } };
|
totalOutstandingSet?: { shopMoney: { amount: string } };
|
||||||
transactions?: Array<{ status?: string; manualPaymentGateway?: boolean }>;
|
transactions?: Array<{ status?: string; manualPaymentGateway?: boolean }>;
|
||||||
@@ -211,6 +263,13 @@ async function fetchOrderInfo(
|
|||||||
currency: o.currencyCode ?? "EUR",
|
currency: o.currencyCode ?? "EUR",
|
||||||
orderName: o.name ?? "",
|
orderName: o.name ?? "",
|
||||||
customerLocale: o.customerLocale ?? undefined,
|
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,
|
txCount: txs.length,
|
||||||
manualFlags: txs.map((t) => ({ status: t.status, manual: t.manualPaymentGateway })),
|
manualFlags: txs.map((t) => ({ status: t.status, manual: t.manualPaymentGateway })),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ interface SettingsFieldErrors {
|
|||||||
logo?: string;
|
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) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const { session } = await authenticate.admin(request);
|
const { session } = await authenticate.admin(request);
|
||||||
const settings = await db.shopSettings.upsert({
|
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.
|
// External HTTPS URL — fine to display directly in the editor.
|
||||||
logoPreviewDataUrl = settings.logoUrl;
|
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) => {
|
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 };
|
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 = {
|
const data = {
|
||||||
companyName: str("companyName"),
|
companyName: str("companyName"),
|
||||||
legalForm: str("legalForm"),
|
legalForm: str("legalForm"),
|
||||||
@@ -167,7 +196,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
smtpPort: smtpPort ?? 587,
|
smtpPort: smtpPort ?? 587,
|
||||||
smtpSecure: bool("smtpSecure"),
|
smtpSecure: bool("smtpSecure"),
|
||||||
smtpUser: str("smtpUser"),
|
smtpUser: str("smtpUser"),
|
||||||
smtpPassword: str("smtpPassword"),
|
smtpPassword: nextSmtpPassword,
|
||||||
smtpFromName: str("smtpFromName"),
|
smtpFromName: str("smtpFromName"),
|
||||||
smtpFromEmail: str("smtpFromEmail"),
|
smtpFromEmail: str("smtpFromEmail"),
|
||||||
smtpReplyTo: str("smtpReplyTo"),
|
smtpReplyTo: str("smtpReplyTo"),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DEFAULT_EMAIL_SUBJECT_EN,
|
DEFAULT_EMAIL_SUBJECT_EN,
|
||||||
} 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";
|
||||||
|
|
||||||
export interface SendInvoiceEmailArgs {
|
export interface SendInvoiceEmailArgs {
|
||||||
shopDomain: string;
|
shopDomain: string;
|
||||||
@@ -100,9 +101,15 @@ export async function sendInvoiceEmail(
|
|||||||
// Download the PDF (Shopify Files URLs are public CDN URLs).
|
// Download the PDF (Shopify Files URLs are public CDN URLs).
|
||||||
let pdfBytes: Uint8Array;
|
let pdfBytes: Uint8Array;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(invoice.pdfUrl);
|
const res = await safeFetch(invoice.pdfUrl, {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
maxBytes: 25 * 1024 * 1024, // 25 MB — generous; emails impose their own limit later
|
||||||
pdfBytes = new Uint8Array(await res.arrayBuffer());
|
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) {
|
} catch (err) {
|
||||||
const m = err instanceof Error ? err.message : String(err);
|
const m = err instanceof Error ? err.message : String(err);
|
||||||
return failLog(args, `Failed to download invoice PDF: ${m}`, invoice.id);
|
return failLog(args, `Failed to download invoice PDF: ${m}`, invoice.id);
|
||||||
@@ -348,11 +355,17 @@ async function loadInlineLogo(
|
|||||||
if (!cached) return null;
|
if (!cached) return null;
|
||||||
return { bytes: new Uint8Array(cached.bytes), contentType: cached.contentType };
|
return { bytes: new Uint8Array(cached.bytes), contentType: cached.contentType };
|
||||||
}
|
}
|
||||||
const res = await fetch(settings.logoUrl);
|
const res = await safeFetch(settings.logoUrl, {
|
||||||
if (!res.ok) return null;
|
maxBytes: 5 * 1024 * 1024,
|
||||||
const ct = res.headers.get("content-type") ?? "image/png";
|
accept: "image/*",
|
||||||
return { bytes: new Uint8Array(await res.arrayBuffer()), contentType: ct };
|
});
|
||||||
} catch {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import db from "../../db.server";
|
import db from "../../db.server";
|
||||||
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
|
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
|
||||||
|
import { safeFetch, SafeFetchError } from "./safeFetch.server";
|
||||||
|
|
||||||
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB cap
|
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB cap
|
||||||
const STALE_AFTER_MS = 24 * 60 * 60 * 1000; // re-fetch once a day at most
|
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);
|
return toDataUrl(cached.bytes, cached.contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: Response;
|
let response: Awaited<ReturnType<typeof safeFetch>>;
|
||||||
try {
|
try {
|
||||||
response = await fetch(logoUrl);
|
response = await safeFetch(logoUrl, {
|
||||||
|
maxBytes: MAX_BYTES,
|
||||||
|
accept: "image/*",
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (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);
|
console.warn(`Logo fetch failed for ${shopDomain}:`, err);
|
||||||
|
}
|
||||||
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
|
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}`);
|
console.warn(`Logo fetch HTTP ${response.status} for ${shopDomain}`);
|
||||||
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
|
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrayBuf = await response.arrayBuffer();
|
const bytes = Buffer.from(response.bytes);
|
||||||
if (arrayBuf.byteLength > MAX_BYTES) {
|
const contentType = response.contentType || guessContentType(logoUrl);
|
||||||
console.warn(`Logo too large (${arrayBuf.byteLength} bytes) — skipping cache.`);
|
const etag = "";
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const bytes = Buffer.from(arrayBuf);
|
|
||||||
const contentType = response.headers.get("content-type") || guessContentType(logoUrl);
|
|
||||||
const etag = response.headers.get("etag") || "";
|
|
||||||
|
|
||||||
await db.logoCache.upsert({
|
await db.logoCache.upsert({
|
||||||
where: { shopDomain },
|
where: { shopDomain },
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
* served from Shopify's CDN so re-fetching is cheap, but caching avoids
|
* served from Shopify's CDN so re-fetching is cheap, but caching avoids
|
||||||
* 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";
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -40,25 +42,34 @@ export async function fetchProductImageDataUrl(url: string): Promise<string | un
|
|||||||
? `${url}${url.includes("?") ? "&" : "?"}width=128`
|
? `${url}${url.includes("?") ? "&" : "?"}width=128`
|
||||||
: url;
|
: url;
|
||||||
|
|
||||||
let res: Response;
|
let res: Awaited<ReturnType<typeof safeFetch>>;
|
||||||
try {
|
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) {
|
} catch (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);
|
console.warn(`Product image fetch failed for ${url}:`, err);
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (res.status < 200 || res.status >= 300) {
|
||||||
console.warn(`Product image HTTP ${res.status} for ${url}`);
|
console.warn(`Product image HTTP ${res.status} for ${url}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const buf = await res.arrayBuffer();
|
if (res.bytesRead === 0) return undefined;
|
||||||
if (buf.byteLength === 0 || buf.byteLength > MAX_BYTES) 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.
|
// @react-pdf supports png/jpeg natively; webp/gif are unreliable. Skip those.
|
||||||
if (contentType !== "image/png" && contentType !== "image/jpeg") return undefined;
|
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}`;
|
const dataUrl = `data:${contentType};base64,${b64}`;
|
||||||
rememberInCache(url, dataUrl);
|
rememberInCache(url, dataUrl);
|
||||||
return dataUrl;
|
return dataUrl;
|
||||||
|
|||||||
@@ -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"];
|
||||||
Reference in New Issue
Block a user