fix(ssrf): centralize host/ip block checks
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user