@vendure/core
Version:
A modern, headless ecommerce framework
170 lines • 8.34 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.assertPublicUrl = assertPublicUrl;
const promises_1 = require("dns/promises");
const net_1 = require("net");
const errors_1 = require("../../common/error/errors");
const vendure_logger_1 = require("../logger/vendure-logger");
const loggerCtx = 'DefaultAssetImportStrategy';
/**
* BlockList covering private, loopback, link-local, IPv4-mapped IPv6, 6to4,
* Teredo, NAT64, discard and unspecified ranges. Used by {@link assertPublicUrl}
* to prevent the `DefaultAssetImportStrategy` from issuing requests that could
* be abused for server-side request forgery against the host's internal network
* or cloud metadata services (e.g. `169.254.169.254`).
*/
const privateBlockList = (() => {
const bl = new net_1.BlockList();
// IPv4
bl.addSubnet('0.0.0.0', 8); // current network
bl.addSubnet('10.0.0.0', 8); // RFC 1918
bl.addSubnet('100.64.0.0', 10); // CGNAT (RFC 6598)
bl.addSubnet('127.0.0.0', 8); // loopback
bl.addSubnet('169.254.0.0', 16); // link-local + cloud metadata
bl.addSubnet('172.16.0.0', 12); // RFC 1918
bl.addSubnet('192.0.0.0', 24); // IETF protocol assignments
bl.addSubnet('192.168.0.0', 16); // RFC 1918
bl.addSubnet('198.18.0.0', 15); // network benchmarking
// IPv6
bl.addAddress('::', 'ipv6'); // unspecified
bl.addAddress('::1', 'ipv6'); // loopback
bl.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 (could tunnel to private v4)
bl.addSubnet('100::', 64, 'ipv6'); // discard prefix
bl.addSubnet('2001::', 32, 'ipv6'); // Teredo
bl.addSubnet('2002::', 16, 'ipv6'); // 6to4
bl.addSubnet('fc00::', 7, 'ipv6'); // unique local
bl.addSubnet('fe80::', 10, 'ipv6'); // link-local
// IPv4-mapped IPv6 (`::ffff:a.b.c.d`) is handled separately in
// `isBlocked()` below — adding it as a BlockList subnet would create false
// positives for plain IPv4 lookups due to a Node-internal v6→v4 projection.
return bl;
})();
/**
* Matches `::ffff:` prefix in any casing. The trailing segment is checked with
* `isIP` rather than parsed by us, so this regex only gates the lookup.
*/
const IPV4_MAPPED_IPV6_PREFIX = /^::ffff:/i;
/**
* Checks an address against the private BlockList. Handles IPv4-mapped IPv6
* addresses (`::ffff:a.b.c.d`) by extracting the embedded IPv4 and re-checking
* against the IPv4 ranges — Node's BlockList does not project v6 → v4
* automatically, and adding the `::ffff:0:0/96` subnet to the BlockList causes
* plain IPv4 lookups to false-positive due to an internal mapping quirk.
*/
function isBlocked(address, family) {
if (family === 6 && IPV4_MAPPED_IPV6_PREFIX.test(address)) {
const embedded = address.slice('::ffff:'.length);
if ((0, net_1.isIP)(embedded) === 4) {
return privateBlockList.check(embedded, 'ipv4');
}
// Hex-form IPv4-mapped (e.g. `::ffff:7f00:1`). Convert the trailing two
// 16-bit groups to dotted-quad and re-check.
const hexMatch = embedded.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (hexMatch) {
const high = parseInt(hexMatch[1], 16);
const low = parseInt(hexMatch[2], 16);
if (Number.isFinite(high) && Number.isFinite(low) && high <= 0xffff && low <= 0xffff) {
/* eslint-disable no-bitwise */
const dotted = [(high >> 8) & 0xff, high & 0xff, (low >> 8) & 0xff, low & 0xff].join('.');
/* eslint-enable no-bitwise */
return privateBlockList.check(dotted, 'ipv4');
}
}
// Fall through: an unparseable `::ffff:` form is itself suspicious. Block.
return true;
}
return privateBlockList.check(address, family === 6 ? 'ipv6' : 'ipv4');
}
const MAX_LOGGED_URL_LENGTH = 200;
const GENERIC_REJECTION_MESSAGE = 'Refusing to fetch asset URL: target is not a public network address';
const GENERIC_INVALID_URL_MESSAGE = 'Refusing to fetch asset URL: invalid or unsupported URL';
/**
* Truncates and strips control characters from a URL before it is embedded in
* log messages. Prevents log forgery via CRLF injection in attacker-controlled
* URLs.
*/
function safeForLog(value) {
return value.replace(/[\x00-\x1f\x7f]/g, '?').slice(0, MAX_LOGGED_URL_LENGTH);
}
const defaultResolver = hostname => (0, promises_1.lookup)(hostname, { all: true, verbatim: true });
/**
* @description
* Validates a URL before allowing the asset importer to fetch it. Rejects:
* - non-http(s) schemes
* - hostnames that resolve to private, loopback, link-local, IPv4-mapped IPv6
* or other non-public address ranges (covers SSRF against cloud metadata and
* internal services)
*
* On success, returns the parsed URL plus the address that was validated so the
* caller can connect directly to that IP. Pinning the validated IP for the
* subsequent fetch closes the DNS-rebinding TOCTOU window — without it,
* `http.get` would perform a second, independent DNS lookup and could resolve
* the same hostname to a private IP this time around.
*
* On failure, the thrown {@link InternalServerError} carries a generic message;
* the specific reason (URL, resolved IP, remediation hint) is emitted via
* `Logger.warn` so operators can see it without the client learning about the
* `allowPrivateNetworks` bypass.
*
* Exported for unit testing; production callers should not pass a resolver
* override.
*/
async function assertPublicUrl(urlString, options = {}) {
var _a;
let url;
try {
url = new URL(urlString);
}
catch (_) {
vendure_logger_1.Logger.warn(`Refusing to fetch invalid URL: ${safeForLog(urlString)}`, loggerCtx);
throw new errors_1.InternalServerError(GENERIC_INVALID_URL_MESSAGE);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
vendure_logger_1.Logger.warn(`Refusing to fetch URL with unsupported protocol "${url.protocol}": ${safeForLog(urlString)}`, loggerCtx);
throw new errors_1.InternalServerError(GENERIC_INVALID_URL_MESSAGE);
}
if (options.allowPrivateNetworks) {
return { url, pinned: null };
}
// WHATWG URL keeps the brackets around IPv6 literals in `hostname`; strip
// them so `isIP` and the BlockList can parse the address. Also drop any
// zone-id suffix (e.g. `fe80::1%eth0`) since BlockList does not accept
// zone-tagged addresses.
const hostname = url.hostname.replace(/^\[|\]$/g, '').replace(/%.*$/, '');
const resolve = (_a = options.resolver) !== null && _a !== void 0 ? _a : defaultResolver;
let addresses;
const literalFamily = (0, net_1.isIP)(hostname);
if (literalFamily === 4 || literalFamily === 6) {
addresses = [{ address: hostname, family: literalFamily }];
}
else if (literalFamily === 0 && hostname.length > 0) {
try {
addresses = await resolve(hostname);
}
catch (e) {
const message = e instanceof Error ? e.message : String(e);
vendure_logger_1.Logger.warn(`Refusing to fetch ${safeForLog(urlString)}: DNS lookup failed (${safeForLog(message)})`, loggerCtx);
throw new errors_1.InternalServerError(GENERIC_REJECTION_MESSAGE);
}
}
else {
vendure_logger_1.Logger.warn(`Refusing to fetch URL with empty hostname: ${safeForLog(urlString)}`, loggerCtx);
throw new errors_1.InternalServerError(GENERIC_INVALID_URL_MESSAGE);
}
for (const { address, family } of addresses) {
const normalisedFamily = family === 6 ? 6 : 4;
if (isBlocked(address, normalisedFamily)) {
vendure_logger_1.Logger.warn(`Refusing to fetch ${safeForLog(urlString)}: resolves to non-public address ${address}. ` +
'Set `assetImportStrategy: new DefaultAssetImportStrategy({ allowPrivateNetworks: true })` ' +
'in your VendureConfig if this is intentional (e.g. a trusted internal asset server).', loggerCtx);
throw new errors_1.InternalServerError(GENERIC_REJECTION_MESSAGE);
}
}
const first = addresses[0];
const pinned = {
address: first.address,
family: first.family === 6 ? 6 : 4,
};
return { url, pinned };
}
//# sourceMappingURL=assert-public-url.js.map