Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 94 additions & 160 deletions library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ function wrapDNSLookupCallback(
}

const context = getContext();
const resolvedIPAddresses = getResolvedIPAddresses(addresses);

const privateIP = resolvedIPAddresses.find(isPrivateIP);
if (!privateIP) {
// If the hostname doesn't resolve to a private IP address, it's not an SSRF attack
// Just call the original callback to allow the DNS lookup
return callback(err, addresses, family);
}

if (context) {
const matches = agent.getConfig().getEndpoints(context);
Expand All @@ -99,154 +107,114 @@ function wrapDNSLookupCallback(
// Just call the original callback to allow the DNS lookup
return callback(err, addresses, family);
}
}

const resolvedIPAddresses = getResolvedIPAddresses(addresses);

const imdsIpResult = resolvesToIMDSIP(resolvedIPAddresses, hostname);
if (!context && imdsIpResult.isIMDS) {
reportStoredImdsIpSSRF({
agent,
module,
operation,
hostname,
privateIp: imdsIpResult.ip,
callingLocationStackTrace,
});

// Block stored SSRF attack that target IMDS IP addresses
// An attacker could have stored a hostname in a database that points to an IMDS IP address
// We don't check if the user input contains the hostname because there's no context
if (agent.shouldBlock()) {
return callback(
new Error(
`Zen has blocked ${attackKindHumanName("stored_ssrf")}: ${operation}(...) originating from unknown source`
)
);
}
}

if (!context) {
// If there's no context, we can't check if the hostname is in the context
// Just call the original callback to allow the DNS lookup
return callback(err, addresses, family);
}

// This is set if this resolve is part of an outgoing request that we are inspecting
const requestContext = RequestContextStorage.getStore();
const isBypassedIP =
context.remoteAddress &&
agent.getConfig().isBypassedIP(context.remoteAddress);

let port: number | undefined;

if (urlArg) {
port = getPortFromURL(urlArg);
} else if (requestContext) {
port = requestContext.port;
}

const privateIP = resolvedIPAddresses.find(isPrivateIP);

if (!privateIP) {
// If the hostname doesn't resolve to a private IP address, it's not an SSRF attack
// Just call the original callback to allow the DNS lookup
return callback(err, addresses, family);
}

let found = findHostnameInContext(hostname, context, port);

// The hostname is not found in the context, check if it's a redirect
if (!found && context.outgoingRequestRedirects) {
let url: URL | undefined;
// Url arg is passed when wrapping node:http(s), but not for undici / fetch because of the way they are wrapped
// For undici / fetch we need to get the url from the request context, which is an additional async context for outgoing requests,
// not to be confused with the "normal" context used in wide parts of this library
if (urlArg) {
url = urlArg;
} else if (requestContext) {
url = new URL(requestContext.url);
if (isBypassedIP) {
// If the IP address is allowed, we don't need to block the request
// Just call the original callback to allow the DNS lookup
return callback(err, addresses, family);
}

if (url) {
// Get the origin of the redirect chain (the first URL in the chain), if the URL is the result of a redirect
const redirectOrigin = getRedirectOrigin(
context.outgoingRequestRedirects,
url
);
// This is set if this resolve is part of an outgoing request that we are inspecting
const requestContext = RequestContextStorage.getStore();
const port = urlArg ? getPortFromURL(urlArg) : requestContext?.port;

let found = findHostnameInContext(hostname, context, port);

// The hostname is not found in the context, check if it's a redirect
if (!found && context.outgoingRequestRedirects) {
let url: URL | undefined;
// Url arg is passed when wrapping node:http(s), but not for undici / fetch because of the way they are wrapped
// For undici / fetch we need to get the url from the request context, which is an additional async context for outgoing requests,
// not to be confused with the "normal" context used in wide parts of this library
if (urlArg) {
url = urlArg;
} else if (requestContext) {
url = new URL(requestContext.url);
}

// If the URL is the result of a redirect, get the origin of the redirect chain for reporting the attack source
if (redirectOrigin) {
found = findHostnameInContext(
redirectOrigin.hostname,
context,
getPortFromURL(redirectOrigin)
if (url) {
// Get the origin of the redirect chain (the first URL in the chain), if the URL is the result of a redirect
const redirectOrigin = getRedirectOrigin(
context.outgoingRequestRedirects,
url
);

// If the URL is the result of a redirect, get the origin of the redirect chain for reporting the attack source
if (redirectOrigin) {
found = findHostnameInContext(
redirectOrigin.hostname,
context,
getPortFromURL(redirectOrigin)
);
}
}
}
}

if (!found) {
if (imdsIpResult.isIMDS) {
// Stored SSRF attack executed during another request (context set)
reportStoredImdsIpSSRF({
agent,
module,
operation,
hostname,
privateIp: imdsIpResult.ip,
callingLocationStackTrace,
if (found) {
// Used to get the stack trace of the calling location
// We don't throw the error, we just use it to get the stack trace
const stackTraceError = callingLocationStackTrace || new Error();

agent.onDetectedAttack({
module: module,
operation: operation,
kind: "ssrf",
source: found.source,
blocked: agent.shouldBlock(),
stack: cleanupStackTrace(stackTraceError.stack!, getLibraryRoot()),
paths: found.pathsToPayload,
metadata: getMetadataForSSRFAttack({ hostname, port, privateIP }),
request: context,
payload: found.payload,
});

// Block stored SSRF attack that target IMDS IP addresses
// An attacker could have stored a hostname in a database that points to an IMDS IP address
if (agent.shouldBlock()) {
return callback(
new Error(
`Zen has blocked ${attackKindHumanName("stored_ssrf")}: ${operation}(...) originating from unknown source`
cleanError(
new Error(
`Zen has blocked ${attackKindHumanName("ssrf")}: ${operation}(...) originating from ${found.source}${escapeHTML((found.pathsToPayload || []).join())}`
)
)
);
}
}

// If we can't find the hostname in the context, it's not an SSRF attack
// Just call the original callback to allow the DNS lookup
return callback(err, addresses, family);
}

const isBypassedIP =
context &&
context.remoteAddress &&
agent.getConfig().isBypassedIP(context.remoteAddress);

if (isBypassedIP) {
// If the IP address is allowed, we don't need to block the request
// Just call the original callback to allow the DNS lookup
return callback(err, addresses, family);
}

// Used to get the stack trace of the calling location
// We don't throw the error, we just use it to get the stack trace
const stackTraceError = callingLocationStackTrace || new Error();

agent.onDetectedAttack({
module: module,
operation: operation,
kind: "ssrf",
source: found.source,
blocked: agent.shouldBlock(),
stack: cleanupStackTrace(stackTraceError.stack!, getLibraryRoot()),
paths: found.pathsToPayload,
metadata: getMetadataForSSRFAttack({ hostname, port, privateIP }),
request: context,
payload: found.payload,
});
// Check for stored IMDS SSRF attack
const imdsIpResult = resolvesToIMDSIP(resolvedIPAddresses, hostname);
if (imdsIpResult.isIMDS) {
const stackTraceError = callingLocationStackTrace || new Error();
agent.onDetectedAttack({
module: module,
operation: operation,
kind: "stored_ssrf",
source: undefined,
blocked: agent.shouldBlock(),
stack: cleanupStackTrace(stackTraceError.stack!, getLibraryRoot()),
paths: [],
metadata: getMetadataForSSRFAttack({
hostname,
port: undefined,
privateIP: imdsIpResult.ip,
}),
request: undefined,
payload: undefined,
});

if (agent.shouldBlock()) {
return callback(
cleanError(
// Block stored SSRF attack that target IMDS IP addresses
// An attacker could have stored a hostname in a database that points to an IMDS IP address
// We don't check if the user input contains the hostname because there's no context
if (agent.shouldBlock()) {
return callback(
new Error(
`Zen has blocked ${attackKindHumanName("ssrf")}: ${operation}(...) originating from ${found.source}${escapeHTML((found.pathsToPayload || []).join())}`
`Zen has blocked ${attackKindHumanName("stored_ssrf")}: ${operation}(...) originating from unknown source`
)
)
);
);
}
}

// If the attack should not be blocked
Expand Down Expand Up @@ -295,37 +263,3 @@ function resolvesToIMDSIP(
isIMDS: false,
};
}

function reportStoredImdsIpSSRF({
agent,
callingLocationStackTrace,
module,
operation,
hostname,
privateIp,
}: {
agent: Agent;
callingLocationStackTrace?: Error;
module: string;
operation: string;
hostname: string;
privateIp: string;
}) {
const stackTraceError = callingLocationStackTrace || new Error();
agent.onDetectedAttack({
module: module,
operation: operation,
kind: "stored_ssrf",
source: undefined,
blocked: agent.shouldBlock(),
stack: cleanupStackTrace(stackTraceError.stack!, getLibraryRoot()),
paths: [],
metadata: getMetadataForSSRFAttack({
hostname,
port: undefined,
privateIP: privateIp,
}),
request: undefined,
payload: undefined,
});
}
Loading