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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import net.bytebuddy.matcher.ElementMatcher;
import net.bytebuddy.matcher.ElementMatchers;

import java.lang.reflect.Method;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
Expand Down Expand Up @@ -55,13 +54,9 @@ public static void before(
// Load the class from the JAR
Class<?> clazz = classLoader.loadClass("dev.aikido.agent_api.collectors.URLCollector");

// Run report with "argument"
for (Method method2: clazz.getMethods()) {
if(method2.getName().equals("report")) {
method2.invoke(null, httpRequest.uri().toURL());
break;
}
}
// report(URL) is overloaded (also has a report(URL, ContextObject) variant), so it
// must be looked up by exact signature - matching by name alone could pick either.
clazz.getMethod("report", URL.class).invoke(null, httpRequest.uri().toURL());
classLoader.close(); // Close the class loader
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import net.bytebuddy.matcher.ElementMatcher;
import net.bytebuddy.matcher.ElementMatchers;

import java.lang.reflect.Method;
import java.net.*;

import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC;
Expand Down Expand Up @@ -50,13 +49,9 @@ public static void before(
// Load the class from the JAR
Class<?> clazz = classLoader.loadClass("dev.aikido.agent_api.collectors.URLCollector");

// Run report with "argument"
for (Method method2: clazz.getMethods()) {
if(method2.getName().equals("report")) {
method2.invoke(null, url);
break;
}
}
// report(URL) is overloaded (also has a report(URL, ContextObject) variant), so it
// must be looked up by exact signature - matching by name alone could pick either.
clazz.getMethod("report", URL.class).invoke(null, url);
classLoader.close(); // Close the class loader
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package dev.aikido.agent.wrappers.spring;

import dev.aikido.agent_api.collectors.URLCollector;
import dev.aikido.agent_api.context.ContextObject;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.URL;

/**
* Carries the Aikido ContextObject through Reactor's own Context so it survives scheduler hops
* (e.g. .publishOn()) between the incoming WebFlux request and any WebClient calls made while
* handling it - unlike Context.get()'s ThreadLocal, which only sees the current OS thread.
*
* Everything here is Object-typed and goes through reflection, rooted at the classloader of a
* live Mono instance passed in. reactor-core is compileOnly for this module: a *separate* class
* (like this one, as opposed to an @Advice method whose parameter types ByteBuddy resolves
* specially against the woven target's own classloader) that declares Mono, Context or
* ContextView as a concrete parameter/return type throws NoClassDefFoundError at class
* verification time on the agent's own classloader, which has no visibility into the target
* application's classpath. Must be public: the woven target class (in a completely different
* package) needs to call into it directly.
*/
public final class ReactorAikidoContext {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ReactorAikidoContext mixes Reactor reflection/plumbing with URLCollector.report call; split reactor-context bridging from URL registration logic.

Details

✨ AI Reasoning
​The new ReactorAikidoContext both performs low-level Reactor reflection plumbing (constructing Context objects, creating Function proxies, invoking contextWrite/deferContextual) and directly calls URLCollector.report to register URLs. This couples reactor-integration plumbing with application-level collector behavior, increasing responsibility scope and coupling.

🔧 How do I fix it?
Split classes that handle database, HTTP, and UI concerns into separate, focused classes.

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

private static final String KEY = "dev.aikido.agent.wrappers.spring.ReactorAikidoContextKey";

private ReactorAikidoContext() {}

// `mono` is a Mono<Void>, returned Object is that same Mono<Void> wrapped with .contextWrite().

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ReactorAikidoContext uses extensive reflection, dynamic Proxy, and classloader-based lookups (write, deferRegisterUrl, RegisterUrlHandler.invoke), which obscures control flow and behavior and hinders auditing.

Details

✨ AI Reasoning
​The new helper uses runtime classloader lookups (Class.forName), reflection to call methods (getMethod/invoke), and a dynamic Proxy/InvocationHandler that inspects and invokes on opaque Objects passed from the target app's reactor classes. These constructs make the control flow and data flow non-obvious to code reviewers and static analysis tools and can be used to hide or obfuscate behavior. The code attempts to bridge reactor types without a compile-time dependency by keeping everything Object-typed and reflective, which increases opacity. Specifically, the write() and deferRegisterUrl() methods and the RegisterUrlHandler.invoke() perform many reflective operations and dynamically call into target-context objects, which can be used to execute logic that is hard to audit.

🔧 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

public static Object write(Object mono, ContextObject context) {
try {
ClassLoader cl = mono.getClass().getClassLoader();
Class<?> contextClass = Class.forName("reactor.util.context.Context", false, cl);
Class<?> contextViewClass = Class.forName("reactor.util.context.ContextView", false, cl);
Object newContext = contextClass.getMethod("of", Object.class, Object.class)
.invoke(null, KEY, context);
Method contextWrite = mono.getClass().getMethod("contextWrite", contextViewClass);
return contextWrite.invoke(mono, newContext);
} catch (Throwable t) {
return mono;
}
}

// `original` is a Mono<T>. Registers `url` once `original` is actually subscribed to, using
// whatever ContextObject write() captured upstream in the same reactive chain (null if
// none). Returns a Mono<T> equivalent to `original` (or `original` itself if anything here
// fails - registration is best-effort, must never break the actual request).
public static Object deferRegisterUrl(Object original, URL url) {
try {
ClassLoader cl = original.getClass().getClassLoader();
Class<?> functionClass = Class.forName("java.util.function.Function", false, cl);
Method deferContextual = original.getClass().getMethod("deferContextual", functionClass);
InvocationHandler handler = new RegisterUrlHandler(original, url);
Object proxy = Proxy.newProxyInstance(cl, new Class<?>[]{functionClass}, handler);
return deferContextual.invoke(null, proxy);
} catch (Throwable t) {
return original;
}
}

// Not a lambda: constructed from advice code that ByteBuddy inlines into the *target*
// class's bytecode, so a lambda here would become a private synthetic method that the
// target class can't call back into (IllegalAccessError). A plain named class implementing
// InvocationHandler - whose own methods only ever see java.lang.Object - avoids that.
private static final class RegisterUrlHandler implements InvocationHandler {
private final Object original;
private final URL url;

RegisterUrlHandler(Object original, URL url) {
this.original = original;
this.url = url;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) {
if (!"apply".equals(method.getName()) || args == null || args.length == 0) {
return original;
}
Object ctxView = args[0];
ContextObject context = null;
try {
Method getOrDefault = ctxView.getClass().getMethod("getOrDefault", Object.class, Object.class);
context = (ContextObject) getOrDefault.invoke(ctxView, KEY, null);
} catch (Throwable ignored) {

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 in RegisterUrlHandler.invoke swallowing Throwable; add logging or explicit handling so context extraction/reporting failures aren't silently ignored.

Details

✨ AI Reasoning
​A new private InvocationHandler implementation swallows all Throwables with an empty catch block. This handler runs during Reactor context extraction and URL registration, so silent failures could hide problems in request correlation. The empty catch neither logs nor documents why errors are ignored, making debugging harder and potentially masking bugs in context retrieval or URL reporting.

🔧 How do I fix it?
Add proper error handling in catch blocks. Log the error, show user feedback, or rethrow if needed.

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

}
URLCollector.report(url, context);
return original;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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 org.springframework.web.reactive.function.client.ClientResponse;
import reactor.core.publisher.Mono;

import java.net.MalformedURLException;
import java.net.URL;

public class SpringWebClientWrapper implements Wrapper {
// Referenced by name (not by .class) in the matchers below: ExchangeFunction is only on
Expand All @@ -33,16 +35,21 @@ 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
// Registration happens in onExit, wrapped around the returned Mono via
// deferContextual(), rather than eagerly in onEnter. That way it runs at subscribe
// time, reading back whatever ContextObject SpringWebfluxWrapper wrote into Reactor's
// Context (see ReactorAikidoContext) - reliable regardless of scheduler hops between
// the incoming request and this WebClient call, unlike Context.get()'s ThreadLocal.
@Advice.OnMethodExit(suppress = Throwable.class)
public static void after(
@Advice.Argument(0) ClientRequest request,
@Advice.Return(readOnly = false) Mono<ClientResponse> returnValue
) throws MalformedURLException {
if (request == null || request.url() == null) {
if (request == null || request.url() == null || returnValue == 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());
URL url = request.url().toURL();
returnValue = (Mono<ClientResponse>) ReactorAikidoContext.deferRegisterUrl(returnValue, url);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ public ElementMatcher<? super TypeDescription> getTypeMatcher() {
public record SkipOnWrapper(Mono<Void> newReturnValue) {
}

// Non-skip path result: carries the ContextObject alongside the response so onExit() can
// write it into Reactor's own Context (see ReactorAikidoContext), letting it survive
// scheduler hops before any WebClient call made while handling this request.
public record EnterResult(ServerHttpResponse res, ContextObject context) {
}

public static class SpringWebfluxAdvice {
@Advice.OnMethodEnter(skipOn = SkipOnWrapper.class, suppress = Throwable.class)
public static Object onEnter(
Expand Down Expand Up @@ -94,7 +100,7 @@ public static Object onEnter(
return new SkipOnWrapper(res.writeWith(Mono.just(dataBuffer)));
}

return res; // Return to analyze status code in OnMethodExit.
return new EnterResult(res, context); // Return to analyze status code in OnMethodExit.
}

/** onExit()
Expand All @@ -105,17 +111,20 @@ public static void onExit(
@Advice.Enter Object enterResult,
@Advice.Return(readOnly = false) Mono<Void> returnValue
) {
// enterResult can be two things : Either the SkipOnWrapper or the ServerHttpResponse
// ServerHttpResponse -> Extract status code.
// enterResult can be two things : Either the SkipOnWrapper or the EnterResult
// EnterResult -> Extract status code, write the context into Reactor's Context.
// SkipOnWrapper -> we blocked a request (e.g. IP Blocking), and are returning the value below
if (enterResult instanceof SkipOnWrapper wrapper && wrapper.newReturnValue() != null) {
returnValue = wrapper.newReturnValue();
} else if (enterResult instanceof ServerHttpResponse res) {
} else if (enterResult instanceof EnterResult er) {
// Report status code of response :
Integer statusCode = res.getRawStatusCode();
Integer statusCode = er.res() != null ? er.res().getRawStatusCode() : null;
if (statusCode != null) {
WebResponseCollector.report(statusCode);
}
if (returnValue != null) {
returnValue = (Mono<Void>) ReactorAikidoContext.write(returnValue, er.context());
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.aikido.agent_api.collectors;

import dev.aikido.agent_api.context.Context;
import dev.aikido.agent_api.context.ContextObject;
import dev.aikido.agent_api.storage.HostnamesStore;
import dev.aikido.agent_api.storage.PendingHostnamesStore;
import dev.aikido.agent_api.storage.ServiceConfigStore;
Expand Down Expand Up @@ -32,15 +33,40 @@ private DNSRecordCollector() {}

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);
withCapturedContext(hostname, () ->
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);
withCapturedContext(hostname, () ->
process(hostname, new InetAddress[]{resolvedAddress}, PendingHostnamesStore.getPorts(hostname), SOCKET_CHANNEL_OPERATION_NAME));
}

// Restores the ContextObject captured when this hostname's pending entry was registered
// (PendingHostnamesStore is global, not thread-local) so SSRFDetector's Context.get() sees
// the request that actually triggered the outbound call, even if we're running on a
// different thread than the one that registered it.
private static void withCapturedContext(String hostname, Runnable action) {
ContextObject capturedContext = PendingHostnamesStore.getContext(hostname);
if (capturedContext == null) {
action.run();
return;
}
ContextObject previous = Context.get();
Context.set(capturedContext);
try {
action.run();
} finally {
if (previous != null) {
Context.set(previous);
} else {
Context.reset();
}
}
}

private static void process(String hostname, InetAddress[] inetAddresses, Set<Integer> ports, String operationName) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.aikido.agent_api.collectors;

import dev.aikido.agent_api.context.Context;
import dev.aikido.agent_api.context.ContextObject;
import dev.aikido.agent_api.helpers.logging.LogManager;
import dev.aikido.agent_api.helpers.logging.Logger;
import dev.aikido.agent_api.storage.PendingHostnamesStore;
Expand All @@ -13,14 +15,21 @@ public final class URLCollector {

private URLCollector() {}
public static void report(URL url) {
report(url, Context.get());
}

// Used where the caller already resolved the correct context itself instead of relying on
// Context.get() (e.g. Spring WebClient reading it back from Reactor's own Context, which
// survives scheduler hops that break Context.get()'s ThreadLocal).
public static void report(URL url, ContextObject context) {
if (url != null) {
if (!url.getProtocol().startsWith("http")) {
return; // Non-HTTP(S) URL
}
logger.trace("Adding a new URL to the cache: %s", url);
// Store hostname+port in the pending store so DNSRecordCollector can pick it
// up during the DNS lookup that follows, for SSRF detection and outbound hostnames
PendingHostnamesStore.add(url.getHost(), getPortFromURL(url));
PendingHostnamesStore.add(url.getHost(), getPortFromURL(url), context);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import dev.aikido.agent_api.helpers.logging.LogManager;
import dev.aikido.agent_api.helpers.logging.Logger;
import dev.aikido.agent_api.storage.AttackQueue;
import dev.aikido.agent_api.storage.PendingHostnamesStore;
import dev.aikido.agent_api.storage.ServiceConfigStore;
import dev.aikido.agent_api.storage.ServiceConfiguration;
import dev.aikido.agent_api.storage.attack_wave_detector.AttackWaveDetectorStore;
Expand Down Expand Up @@ -41,10 +40,6 @@ public static Res report(ContextObject newContext) {
// clear context
Context.reset();

// Flush pending hostnames on every context change to prevent the store from
// growing unboundedly when a thread is reused across multiple requests.
PendingHostnamesStore.clear();

if (config.isIpBypassed(newContext.getRemoteAddress())) {
return null; // do not set context when the IP address is bypassed (zen = off)
}
Expand Down
Loading
Loading