fix(ssrf): centralize host/ip block checks

This commit is contained in:
Peter Steinberger
2026-02-22 15:41:32 +01:00
parent 39be5e44df
commit 44dfbd23df
3 changed files with 33 additions and 11 deletions

View File

@@ -99,7 +99,7 @@ describe("buildGatewayCronService", () => {
loadConfigMock.mockReturnValue(cfg);
fetchWithSsrFGuardMock.mockRejectedValue(
new SsrFBlockedError("Blocked: private/internal IP address"),
new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address"),
);
const state = buildGatewayCronService({

View File

@@ -49,8 +49,11 @@ describe("ssrf pinning", () => {
);
});
it("rejects private DNS results", async () => {
const lookup = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]) as unknown as LookupFn;
it.each([
{ name: "RFC1918 private address", address: "10.0.0.8" },
{ name: "RFC2544 benchmarking range", address: "198.18.0.1" },
])("rejects blocked DNS results: $name", async ({ address }) => {
const lookup = vi.fn(async () => [{ address, family: 4 }]) as unknown as LookupFn;
await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i);
});

View File

@@ -316,6 +316,8 @@ const BLOCKED_IPV4_SPECIAL_USE_CIDRS: readonly Ipv4Cidr[] = [
{ base: [240, 0, 0, 0], prefixLength: 4 },
];
// Keep this table as the single source of IPv4 non-global policy.
// Both plain IPv4 literals and IPv6-embedded IPv4 forms flow through it.
const BLOCKED_IPV4_SPECIAL_USE_RANGES = BLOCKED_IPV4_SPECIAL_USE_CIDRS.map(ipv4RangeFromCidr);
function isBlockedIpv4SpecialUse(parts: number[]): boolean {
@@ -430,6 +432,24 @@ export function isBlockedHostnameOrIp(hostname: string): boolean {
return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized);
}
const BLOCKED_HOST_OR_IP_MESSAGE = "Blocked hostname or private/internal/special-use IP address";
const BLOCKED_RESOLVED_IP_MESSAGE = "Blocked: resolves to private/internal/special-use IP address";
function assertAllowedHostOrIpOrThrow(hostnameOrIp: string): void {
if (isBlockedHostnameOrIp(hostnameOrIp)) {
throw new SsrFBlockedError(BLOCKED_HOST_OR_IP_MESSAGE);
}
}
function assertAllowedResolvedAddressesOrThrow(results: readonly LookupAddress[]): void {
for (const entry of results) {
// Reuse the exact same host/IP classifier as the pre-DNS check to avoid drift.
if (isBlockedHostnameOrIp(entry.address)) {
throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE);
}
}
}
export function createPinnedLookup(params: {
hostname: string;
addresses: string[];
@@ -506,13 +526,15 @@ export async function resolvePinnedHostnameWithPolicy(
const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
const isExplicitAllowed = allowedHostnames.has(normalized);
const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitAllowed;
if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);
}
if (!allowPrivateNetwork && !isExplicitAllowed && isBlockedHostnameOrIp(normalized)) {
throw new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address");
if (!skipPrivateNetworkChecks) {
// Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects.
assertAllowedHostOrIpOrThrow(normalized);
}
const lookupFn = params.lookupFn ?? dnsLookup;
@@ -521,12 +543,9 @@ export async function resolvePinnedHostnameWithPolicy(
throw new Error(`Unable to resolve hostname: ${hostname}`);
}
if (!allowPrivateNetwork && !isExplicitAllowed) {
for (const entry of results) {
if (isPrivateIpAddress(entry.address)) {
throw new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address");
}
}
if (!skipPrivateNetworkChecks) {
// Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets.
assertAllowedResolvedAddressesOrThrow(results);
}
const addresses = Array.from(new Set(results.map((entry) => entry.address)));