security hardening
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user