Skip to content
Open
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions agent/build.gradle

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 Open source vulnerabilities detected - high severity
Aikido detected 7 vulnerabilities across 1 package, it includes 2 high, 2 medium and 3 low vulnerabilities.

Details

Remediation Aikido suggests bumping the vulnerable packages to a safe version.

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies {
compileOnly 'io.projectreactor.netty:reactor-netty-http:1.2.1' // For Spring Webflux
compileOnly 'io.javalin:javalin:6.4.0'
compileOnly 'org.springframework:spring-web:5.3.20'
compileOnly 'org.springframework:spring-webflux:5.3.20' // For Spring WebClient
}

shadowJar {
Expand Down
5 changes: 5 additions & 0 deletions agent/src/main/java/dev/aikido/agent/Wrappers.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import dev.aikido.agent.wrappers.spring.SpringWebfluxWrapper;
import dev.aikido.agent.wrappers.spring.SpringControllerWrapper;
import dev.aikido.agent.wrappers.spring.SpringMVCJakartaWrapper;
import dev.aikido.agent.wrappers.spring.SpringWebClientWrapper;
import dev.aikido.agent.wrappers.spring.SpringWebClientRedirectWrapper;

import java.util.Arrays;
import java.util.List;
Expand All @@ -30,11 +32,14 @@ private Wrappers() {}
// SSRF/HTTP wrappers
new HttpURLConnectionWrapper(),
new InetAddressWrapper(),
new SocketChannelWrapper(),
new HttpClientWrapper(),
new HttpConnectionRedirectWrapper(),
new HttpClientSendWrapper(),
new OkHttpWrapper(),
new ApacheHttpClientWrapper(),
new SpringWebClientWrapper(),
new SpringWebClientRedirectWrapper(),

new PathWrapper(),
new PathsWrapper(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package dev.aikido.agent.wrappers;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.description.method.MethodDescription;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.matcher.ElementMatcher;
import net.bytebuddy.matcher.ElementMatchers;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.SocketAddress;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.channels.SocketChannel;

public class SocketChannelWrapper implements Wrapper {
public String getName() {
// Wrap connect(SocketAddress) on SocketChannel. Clients that resolve hostnames with
// their own DNS resolver instead of InetAddress.getAllByName() (e.g. Reactor Netty's
// async resolver, used by default by Spring's WebClient) never trigger
// InetAddressWrapper, so this is the only point where we see the resolved address
// before the connection is made. Also catches literal IP targets, which never go
// through any resolver at all.
// https://docs.oracle.com/javase/8/docs/api/java/nio/channels/SocketChannel.html#connect-java.net.SocketAddress-
return SocketChannelAdvice.class.getName();
}
public ElementMatcher<? super MethodDescription> getMatcher() {
return ElementMatchers.named("connect");
}
@Override
public ElementMatcher<? super TypeDescription> getTypeMatcher() {
return ElementMatchers.isSubTypeOf(SocketChannel.class);
}
public static class SocketChannelAdvice {
// Since we have to wrap a native Java Class stuff gets more complicated
// The classpath is not the same anymore, and we can't import our modules directly.
// To bypass this issue we load collectors from a .jar file
@Advice.OnMethodEnter
public static void before(
@Advice.Argument(0) SocketAddress remoteAddress
) throws Throwable {
if (!(remoteAddress instanceof InetSocketAddress)) {
return;
}
InetSocketAddress inetSocketAddress = (InetSocketAddress) remoteAddress;
InetAddress resolvedAddress = inetSocketAddress.getAddress();
if (resolvedAddress == null) {
// Unresolved: nothing to report yet, connect() will throw on its own.
return;
}
String hostname = inetSocketAddress.getHostString();

String jarFilePath = System.getProperty("AIK_agent_api_jar");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new URLClassLoader and doing reflective class/method lookups on every SocketChannel.connect() is expensive. Cache the loader/reflection results or use a preloaded bridge to avoid per-connection classloader and reflection overhead.

Details

✨ AI Reasoning
​This code attempts to report the resolved address of a SocketChannel connection by dynamically loading the agent_api JAR and invoking DNSRecordCollector.reportConnect via reflection. It performs the following expensive operations on each connect: reads a system property, constructs a URL and new URLClassLoader, loads a class by name, iterates methods to find reportConnect, invokes reflectively, and closes the class loader. Doing this on the hot path (connect) will cause significant allocation and classloading overhead that scales with connection frequency. Caching the URLClassLoader (or, better, obtaining a static bridge or a lightweight agent-to-api interface) would avoid repeated expensive work. The issue was introduced by the new SocketChannelWrapper in this PR which added this per-invocation classloader/reflection flow.

🔧 How do I fix it?
Move constant work outside loops. Use StringBuilder instead of string concatenation in loops. Cache compiled regex patterns. Use hash-based lookups instead of nested loops. Batch database operations instead of N+1 queries.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading a JAR path from System property then runtime-classloading and reflective method invocation hides executed code; avoid dynamic loading/reflection or document and validate the JAR path and expected class/method.

Details

✨ AI Reasoning
​The new wrapper loads a JAR path read from a system property, constructs a URLClassLoader at runtime, loads a class by name, iterates over its methods and invokes one by matching its name, all via reflection. This sequence (system-property-controlled JAR path + runtime classloading + reflective invocation) obscures the control flow and makes it hard to audit what code will run, which fits obfuscation/hiding patterns. It also depends on external input (System property) to locate executable code, increasing the risk that different code executes in different environments and hiding behavior from static analysis and code reviewers. These are deliberate runtime indirections introduced by this PR and therefore should be flagged.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

URLClassLoader classLoader = null;
try {
URL[] urls = { new URL(jarFilePath) };
classLoader = new URLClassLoader(urls);
} catch (MalformedURLException ignored) {}

@aikido-pr-checks aikido-pr-checks Bot Jul 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty catch swallowing MalformedURLException; at minimum log the exception or document why it can be safely ignored to avoid hiding classloader/URL setup failures.

Show fix
Suggested change
} catch (MalformedURLException ignored) {}
} catch (MalformedURLException e) {
// Log malformed JAR path to aid debugging of classloader setup failures
System.out.println("AIKIDO: Failed to load agent API JAR from path: " + jarFilePath + " - " + e.getMessage());
}
Details

✨ AI Reasoning
​The new wrapper code performs URL/JAR classloader setup inside a try block and then silently swallows MalformedURLException with an empty catch body. Silently ignoring classloading/URL errors can hide configuration or instrumentation failures and makes debugging harder; the catch should at least log the error or document why it is intentionally ignored. The surrounding try also involves loading an external JAR and reflective invocation, which are sensitive operations where failures should not be silently dropped.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

if (classLoader == null) {
return;
}

try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SocketChannelWrapper dynamically loads a JAR and reflectively invokes reportConnect, hiding execution via URLClassLoader/reflection and selective exception handling; this runtime indirection obscures intent and should be clarified or documented.

Details

✨ AI Reasoning
​The new SocketChannelWrapper uses runtime dynamic class loading and reflection to locate and invoke DNSRecordCollector.reportConnect from an external JAR path supplied via a system property. It constructs a URLClassLoader from a string path, loads a class by name, searches methods by string name, and invokes one reflectively. It selectively rethrows InvocationTargetException causes only when the cause's toString() starts with the project's vulnerability package prefix, and otherwise swallows or prints exceptions. These behaviors introduce non-transparent control flow and runtime execution paths that can hide behavior from static review. Such patterns are commonly used to obscure intent and can be indicative of first-party malware or backdoor techniques.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

// Load the class from the JAR
Class<?> clazz = classLoader.loadClass("dev.aikido.agent_api.collectors.DNSRecordCollector");

// Run reportConnect with "argument"
for (Method method2: clazz.getMethods()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iterating clazz.getMethods() to find and invoke reportConnect is verbose; use clazz.getMethod("reportConnect", String.class, InetAddress.class) and invoke it directly.

Details

✨ AI Reasoning
​The code loads a class via reflection and then iterates over clazz.getMethods() to find a method named "reportConnect", invoking it when found. This is more verbose than directly calling clazz.getMethod("reportConnect", parameterTypes...). Iterating all methods is unnecessary and reduces clarity; a direct reflective lookup is standard, clearer, and concise. This change was introduced in the new SocketChannelWrapper and is a straightforward simplification opportunity that keeps identical behavior.

🔧 How do I fix it?
Rewrite the snippet in the simpler, behavior-equivalent form: return a boolean expression directly instead of if cond return true else return false, avoid using lists when they are guaranteed to contain one element, etc.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

if(method2.getName().equals("reportConnect")) {
method2.invoke(null, hostname, resolvedAddress);
break;
}
}
Comment on lines +70 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflectively scanning/ invoking reportConnect on a runtime-loaded class obscures the actual operation performed; prefer a clearly referenced API boundary or documented explicit delegation.

Show fix
Suggested change
for (Method method2: clazz.getMethods()) {
if(method2.getName().equals("reportConnect")) {
method2.invoke(null, hostname, resolvedAddress);
break;
}
}
Method reportConnectMethod = clazz.getMethod("reportConnect", String.class, InetAddress.class);
reportConnectMethod.invoke(null, hostname, resolvedAddress);
Details

✨ AI Reasoning
​The change locates and invokes a method named reportConnect via reflection on a class loaded from the external classloader. Using reflection in this way (scanning methods by name and invoking them) obscures the actual call target and arguments from code readers and static tools, complicating understanding of what is executed when a socket connects. Reflection here is used to execute security-relevant logic (reporting/SSRF checks) and is performed conditionally at runtime, which can hide behavior and make auditing more difficult.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

classLoader.close(); // Close the class loader
} catch (InvocationTargetException invocationTargetException) {
if(invocationTargetException.getCause().toString().startsWith("dev.aikido.agent_api.vulnerabilities")) {
throw invocationTargetException.getCause();
Comment on lines +77 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relying on exception.getCause().toString().startsWith(...) to decide to rethrow hides error semantics; use explicit exception type checks instead of string-matching the cause.

Show fix
Suggested change
} catch (InvocationTargetException invocationTargetException) {
if(invocationTargetException.getCause().toString().startsWith("dev.aikido.agent_api.vulnerabilities")) {
throw invocationTargetException.getCause();
} catch (InvocationTargetException invocationTargetException) {
Throwable cause = invocationTargetException.getCause();
if(cause != null && cause.getClass().getName().startsWith("dev.aikido.agent_api.vulnerabilities")) {
throw cause;
Details

✨ AI Reasoning
​The added code catches InvocationTargetException and inspects invocationTargetException.getCause().toString().startsWith(...) to decide whether to throw the cause. Using string matching on the cause's toString() to classify exceptions is brittle and can be used to selectively propagate certain errors while swallowing others, masking failures or creating hidden control paths. This conditional propagation was introduced here, increasing opacity of error handling and potentially hiding malicious or unexpected behavior.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

}
// Ignore non-aikido throwables.
} catch(Throwable e) {
System.out.println("AIKIDO: " + e.getMessage());

@aikido-pr-checks aikido-pr-checks Bot Jul 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.out.println used for error reporting in SocketChannelWrapper. Use the project's Logger or remove this debug print.

Suggested change
System.out.println("AIKIDO: " + e.getMessage());
Details

✨ AI Reasoning
​A new ad-hoc console print was added inside an exception handler that swallows errors. Using System.out.println in library/agent code is a debug artifact: it produces unstructured output and may leak internal errors to stdout. This harms maintainability and can confuse production logs. Replacing it with the project's Logger or removing it will restore production-grade logging practices.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package dev.aikido.agent.wrappers.spring;

import dev.aikido.agent.wrappers.Wrapper;
import dev.aikido.agent_api.collectors.RedirectCollector;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.description.method.MethodDescription;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.matcher.ElementMatcher;
import net.bytebuddy.matcher.ElementMatchers;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.URL;

public class SpringWebClientRedirectWrapper implements Wrapper {
// Package-private in Reactor Netty, referenced by name only. This is the internal method
// that runs once per redirect hop, for both WebClient and the Netty-backed RestClient -
// Spring's own request-adaptation layer (ExchangeFunction/ReactorClientHttpRequest) is
// only invoked once per top-level call and never sees redirect targets for bodiless (e.g.
// GET) requests, since Reactor Netty resends internally without going back through it.
// Mirrors HttpConnectionRedirectWrapper, which hooks the JDK's equally-internal
// followRedirect0 for the same reason.
private static final String HTTP_CLIENT_HANDLER_CLASS_NAME =
"reactor.netty.http.client.HttpClientConnect$HttpClientHandler";

public String getName() {
return RedirectAdvice.class.getName();
}
public ElementMatcher<? super MethodDescription> getMatcher() {
return ElementMatchers.isDeclaredBy(getTypeMatcher())
.and(ElementMatchers.named("redirect"))
.and(ElementMatchers.takesArguments(1));
}
@Override
public ElementMatcher<? super TypeDescription> getTypeMatcher() {
return ElementMatchers.named(HTTP_CLIENT_HANDLER_CLASS_NAME);
}
public static class RedirectAdvice {
@Advice.OnMethodExit(suppress = Throwable.class)
public static void after(@Advice.This Object handler) throws Exception {
// fromURI/toURI are UriEndpoint (also package-private), both reassigned by
// redirect() before this advice runs: fromURI is the hostname that redirected,
// toURI is where it redirected to.
String origin = externalForm(handler, "fromURI");
String dest = externalForm(handler, "toURI");
if (origin == null || dest == null) {
return;
}
RedirectCollector.report(new URL(origin), new URL(dest));
}

// Must be public: after weaving, this is called as a real cross-class invocation from
// inside the target class's own bytecode, so a private method would raise IllegalAccessError.
public static String externalForm(Object handler, String fieldName) throws Exception {
Field field = handler.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
Object uriEndpoint = field.get(handler);
if (uriEndpoint == null) {
return null;
}
Method toExternalForm = uriEndpoint.getClass().getDeclaredMethod("toExternalForm");
toExternalForm.setAccessible(true);
return (String) toExternalForm.invoke(uriEndpoint);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package dev.aikido.agent.wrappers.spring;

import dev.aikido.agent.wrappers.Wrapper;
import dev.aikido.agent_api.collectors.URLCollector;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.description.method.MethodDescription;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.matcher.ElementMatcher;
import net.bytebuddy.matcher.ElementMatchers;
import org.springframework.web.reactive.function.client.ClientRequest;

import java.net.MalformedURLException;

public class SpringWebClientWrapper implements Wrapper {
// Referenced by name (not by .class) in the matchers below: ExchangeFunction is only on
// the target application's classloader (spring-webflux is compileOnly here), not on the
// agent's own classloader, so a .class literal would throw NoClassDefFoundError at premain.
private static final String EXCHANGE_FUNCTION_CLASS_NAME =
"org.springframework.web.reactive.function.client.ExchangeFunction";

public String getName() {
// Wrap exchange(ClientRequest) on ExchangeFunction, the interface every WebClient
// request goes through before Reactor Netty resolves/connects.
// https://docs.spring.io/spring-framework/docs/5.3.20/javadoc-api/org/springframework/web/reactive/function/client/ExchangeFunction.html
return SpringWebClientAdvice.class.getName();
}
public ElementMatcher<? super MethodDescription> getMatcher() {
return ElementMatchers.isDeclaredBy(getTypeMatcher())
.and(ElementMatchers.named("exchange"));
}
@Override
public ElementMatcher<? super TypeDescription> getTypeMatcher() {
return ElementMatchers.hasSuperType(ElementMatchers.named(EXCHANGE_FUNCTION_CLASS_NAME));
}
public static class SpringWebClientAdvice {
@Advice.OnMethodEnter(suppress = Throwable.class)
public static void before(
@Advice.Argument(0) ClientRequest request
) throws MalformedURLException {
if (request == null || request.url() == null) {
return;
}
// Report the URL before the request is sent, so DNSRecordCollector can match the
// DNS lookup that follows to this outgoing request.
URLCollector.report(request.url().toURL());
}
}
}
3 changes: 3 additions & 0 deletions agent_api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ dependencies {
testImplementation 'org.springframework:spring-web:5.3.20'
testImplementation 'org.springframework:spring-webmvc:5.3.20'
testImplementation 'org.springframework:spring-test:5.3.20'
// Spring WebFlux for WebClient
testImplementation 'org.springframework:spring-webflux:5.3.20'
testImplementation 'io.projectreactor.netty:reactor-netty-http:1.2.1'

testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import dev.aikido.agent_api.vulnerabilities.ssrf.SSRFException;
import dev.aikido.agent_api.helpers.logging.LogManager;
import dev.aikido.agent_api.helpers.logging.Logger;
import dev.aikido.agent_api.vulnerabilities.ssrf.IsPrivateIP;
import dev.aikido.agent_api.vulnerabilities.ssrf.StoredSSRFDetector;
import dev.aikido.agent_api.vulnerabilities.ssrf.StoredSSRFException;

Expand All @@ -26,30 +27,46 @@
public final class DNSRecordCollector {
private DNSRecordCollector() {}
private static final Logger logger = LogManager.getLogger(DNSRecordCollector.class);
private static final String OPERATION_NAME = "java.net.InetAddress.getAllByName";
private static final String INET_ADDRESS_OPERATION_NAME = "java.net.InetAddress.getAllByName";
private static final String SOCKET_CHANNEL_OPERATION_NAME = "java.nio.channels.SocketChannel.connect";

public static void report(String hostname, InetAddress[] inetAddresses) {
// InetAddress.getAllByName() resolves everything in one call, so it's safe to consume.
process(hostname, inetAddresses, PendingHostnamesStore.getAndRemove(hostname), INET_ADDRESS_OPERATION_NAME);
}

// For clients that resolve their own DNS (e.g. Reactor Netty, used by Spring's WebClient) or
// connect straight to an IP literal. A single request can trigger multiple connect() calls to
// the same hostname (IPv4 then IPv6), so unlike report(), this peeks the pending port instead
// of consuming it - consuming on the first attempt would let a later attempt bypass SSRF.
public static void reportConnect(String hostname, InetAddress resolvedAddress) {
process(hostname, new InetAddress[]{resolvedAddress}, PendingHostnamesStore.getPorts(hostname), SOCKET_CHANNEL_OPERATION_NAME);
}

private static void process(String hostname, InetAddress[] inetAddresses, Set<Integer> ports, String operationName) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method 'process' is too vague; rename to reflect its responsibilities (e.g., handleDnsLookupRecordingAndSsrfChecks) and add Javadoc describing side effects and when it throws.

Details

✨ AI Reasoning
​A newly added private static method named 'process' was introduced to handle statistics registration, hostname/port recording, outbound-block checks, and SSRF detection for different invocation paths. The implementation mixes multiple responsibilities and performs important side effects (mutating stores, throwing blocking exceptions). The name 'process' doesn't convey the multi-step purpose (recording, blocking checks, SSRF evaluation). Although file comments give context, the function name is still vague and makes the code harder to understand at call sites and during maintenance.

🔧 How do I fix it?
Use descriptive verb-noun function names, add docstrings explaining the function's purpose, or provide meaningful return type hints.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DNSRecordCollector.process() is large and mixes multiple unrelated responsibilities (stats, pending-port semantics, outbound blocking, SSRF detection and stored-SSRF checks); consider splitting into smaller focused methods.

Details

✨ AI Reasoning
​1. What is this code trying to accomplish? It converts resolved InetAddress objects and pending ports into outbound-hit statistics and runs outbound-blocking and SSRF detectors. 2. Does this harm maintainability? The method combines several distinct responsibilities (stats, store mutation vs peeking semantics, blocking policy, SSRF detection, stored-SSRF detection and exception handling) making it cognitively heavy. 3. Is it appropriate? The method grew in this change to handle both InetAddress.getAllByName and SocketChannel.connect callers plus different port-consumption semantics - this increases branching and conceptual load. 4. Would fixing it meaningfully improve codebase? Yes — splitting concerns (port handling vs SSRF checks vs storage/stats) would make logic easier to reason about and reduce risk of regression. 5. Common pattern? Not particularly; existing code previously separated some behavior but this change merged additional responsibilities into one process() method. 6. Fixability within PR? Yes: extraction of smaller helper methods is feasible here.

🔧 How do I fix it?
Break down long functions into smaller helper functions. Aim for functions under 60 lines with fewer than 10 local variables.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

try {
logger.trace("DNSRecordCollector called with %s & inet addresses: %s", hostname, List.of(inetAddresses));

// store stats
StatisticsStore.registerCall("java.net.InetAddress.getAllByName", OperationKind.OUTGOING_HTTP_OP);

// Consume pending ports recorded by URLCollector for this hostname.
// Removing them here ensures each (hostname, port) pair is counted exactly once.
Set<Integer> ports = PendingHostnamesStore.getAndRemove(hostname);
if (!ports.isEmpty()) {
for (int port : ports) {
HostnamesStore.incrementHits(hostname, port);
StatisticsStore.registerCall(operationName, OperationKind.OUTGOING_HTTP_OP);

// No pending port + private IP literal = infrastructure noise (Netty resolver bootstrap
// etc), not a real request - skip recording/blocking. SSRF checks below still run regardless.
if (!ports.isEmpty() || !IsPrivateIP.isPrivateIp(hostname)) {
if (!ports.isEmpty()) {
for (int port : ports) {
HostnamesStore.incrementHits(hostname, port);
}
} else {
// We still need to report a hit to the hostname for outbound domain blocking
HostnamesStore.incrementHits(hostname, 0);
}
} else {
// We still need to report a hit to the hostname for outbound domain blocking
HostnamesStore.incrementHits(hostname, 0);
}

// Block if the hostname is in the blocked domains list
if (ServiceConfigStore.shouldBlockOutgoingRequest(hostname)) {
logger.debug("Blocking DNS lookup for domain: %s", hostname);
throw BlockedOutboundException.get();
// Block if the hostname is in the blocked domains list
if (ServiceConfigStore.shouldBlockOutgoingRequest(hostname)) {
logger.debug("Blocking DNS lookup for domain: %s", hostname);
throw BlockedOutboundException.get();
}
}

// Convert inetAddresses array to a List of IP strings :
Expand All @@ -62,7 +79,7 @@ public static void report(String hostname, InetAddress[] inetAddresses) {
for (int port : ports) {
logger.debug("Hostname: %s, Port: %s, IPs: %s", hostname, port, ipAddresses);

Attack attack = SSRFDetector.run(hostname, port, ipAddresses, OPERATION_NAME);
Attack attack = SSRFDetector.run(hostname, port, ipAddresses, operationName);
if (attack == null) {
continue;
}
Expand All @@ -81,7 +98,7 @@ public static void report(String hostname, InetAddress[] inetAddresses) {

// We don't need the context object to check for stored ssrf, but we do want to run this after our other
// SSRF checks, making sure if it's a normal ssrf attack it gets reported like that.
Attack storedSsrfAttack = new StoredSSRFDetector().run(hostname, ipAddresses, OPERATION_NAME);
Attack storedSsrfAttack = new StoredSSRFDetector().run(hostname, ipAddresses, operationName);
if (storedSsrfAttack != null) {
attackDetected(storedSsrfAttack, Context.get());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,30 @@

/**
* Thread-local bridge between URLCollector and DNSRecordCollector.
* URLCollector records hostname+port here; DNSRecordCollector reads and removes the entry
* so each (hostname, port) pair is processed exactly once per DNS lookup.
* URLCollector records hostname+port here; DNSRecordCollector.report() (fed by
* InetAddress.getAllByName(), which resolves everything in one call) reads and removes the
* entry so each (hostname, port) pair is processed exactly once per DNS lookup.
* DNSRecordCollector.reportConnect() (fed by SocketChannel.connect(), which fires once per
* connect attempt) instead peeks the entry, since a single outbound request can trigger
* multiple connect attempts to the same hostname (e.g. IPv4 then IPv6 for a dual-stack host).
*
* Entries are normally cleared per incoming request by WebRequestCollector, but a peeked
* entry added outside any incoming-request context (e.g. a WebClient call from a @Scheduled
* task) would never be cleared that way. Capped at MAX_ENTRIES per thread, evicting the least
* recently used entry once exceeded, same bounded-LRU pattern as Hostnames.
*/
public final class PendingHostnamesStore {
private PendingHostnamesStore() {}

private static final int MAX_ENTRIES = 1000;

private static final ThreadLocal<Map<String, Set<Integer>>> store =
ThreadLocal.withInitial(LinkedHashMap::new);
ThreadLocal.withInitial(() -> new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Set<Integer>> eldest) {
return size() > MAX_ENTRIES;
}
});

public static void add(String hostname, int port) {
Map<String, Set<Integer>> map = store.get();
Expand Down
Loading
Loading