Skip to content

Commit 280ec1b

Browse files
Merge pull request #91 from AikidoSec/AIK-4059-3
AIK-4059 Add Javalin support
2 parents 46f7e9d + dcc4107 commit 280ec1b

File tree

19 files changed

+727
-8
lines changed

19 files changed

+727
-8
lines changed

.github/workflows/end2end.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ jobs:
4242
- { name: SpringWebfluxSampleApp, test_file: end2end/spring_webflux_postgres.py }
4343
- { name: SpringMVCPostgresKotlin, test_file: end2end/spring_mvc_postgres_kotlin.py }
4444
- { name: SpringMVCPostgresGroovy, test_file: end2end/spring_mvc_postgres_groovy.py }
45+
- { name: JavalinPostgres, test_file: end2end/javalin_postgres.py }
4546
java-version: [17, 18, 19, 20, 21]
4647
steps:
4748
- name: Download build artifacts

agent/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies {
99
// Compile only for interface types :
1010
compileOnly 'jakarta.servlet:jakarta.servlet-api:6.1.0' // For Context object (Spring Boot)
1111
compileOnly 'io.projectreactor.netty:reactor-netty-http:1.2.1' // For Spring Webflux
12+
compileOnly 'io.javalin:javalin:6.4.0'
1213
}
1314

1415
shadowJar {

agent/src/main/java/dev/aikido/agent/Wrappers.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dev.aikido.agent;
22

33
import dev.aikido.agent.wrappers.*;
4+
import dev.aikido.agent.wrappers.javalin.*;
45
import dev.aikido.agent.wrappers.jdbc.MSSQLWrapper;
56
import dev.aikido.agent.wrappers.jdbc.MariaDBWrapper;
67
import dev.aikido.agent.wrappers.jdbc.MysqlCJWrapper;
@@ -30,6 +31,9 @@ private Wrappers() {}
3031
new ApacheHttpClientWrapper(),
3132
new PathWrapper(),
3233
new PathsWrapper(),
33-
new NettyWrapper()
34+
new NettyWrapper(),
35+
new JavalinWrapper(),
36+
new JavalinDataWrapper(),
37+
new JavalinContextClearWrapper()
3438
);
3539
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package dev.aikido.agent.wrappers.javalin;
2+
3+
import dev.aikido.agent.wrappers.Wrapper;
4+
import dev.aikido.agent_api.context.Context;
5+
import net.bytebuddy.asm.Advice;
6+
import net.bytebuddy.description.method.MethodDescription;
7+
import net.bytebuddy.description.type.TypeDescription;
8+
import net.bytebuddy.matcher.ElementMatcher;
9+
import static net.bytebuddy.matcher.ElementMatchers.*;
10+
11+
public class JavalinContextClearWrapper implements Wrapper {
12+
13+
@Override
14+
public String getName() {
15+
return JavalinContextClearAdvice.class.getName();
16+
}
17+
18+
@Override
19+
public ElementMatcher<? super MethodDescription> getMatcher() {
20+
return isDeclaredBy(getTypeMatcher()).and(named("service"));
21+
}
22+
23+
@Override
24+
public ElementMatcher<? super TypeDescription> getTypeMatcher() {
25+
return nameContains("io.javalin.jetty.JavalinJettyServlet");
26+
}
27+
28+
public static class JavalinContextClearAdvice {
29+
@Advice.OnMethodEnter
30+
public static void before() {
31+
Context.reset();
32+
}
33+
}
34+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package dev.aikido.agent.wrappers.javalin;
2+
3+
import dev.aikido.agent.wrappers.Wrapper;
4+
import dev.aikido.agent_api.context.Context;
5+
import dev.aikido.agent_api.context.JavalinContextObject;
6+
import net.bytebuddy.asm.Advice;
7+
import net.bytebuddy.description.method.MethodDescription;
8+
import net.bytebuddy.description.type.TypeDescription;
9+
import net.bytebuddy.matcher.ElementMatcher;
10+
11+
import java.lang.reflect.Executable;
12+
13+
import static net.bytebuddy.matcher.ElementMatchers.*;
14+
15+
/** JavalinDataWrapper
16+
* Wraps multiple functions for both body, form & path variable data.
17+
* https://github.com/javalin/javalin/blob/8b1dc1a55c28618df7f9f044aad4949d30a8cca8/javalin/src/main/java/io/javalin/http/Context.kt#L158
18+
*/
19+
public class JavalinDataWrapper implements Wrapper {
20+
@Override
21+
public String getName() {
22+
return JavalinDataAdvice.class.getName();
23+
}
24+
25+
@Override
26+
public ElementMatcher<? super MethodDescription> getMatcher() {
27+
return isDeclaredBy(getTypeMatcher()).and(namedOneOf(
28+
// parse-able bodies :
29+
"bodyAsClass",
30+
// Form parameters :
31+
"formParam", "formParamAsClass", "formParams", "formParamMap",
32+
// Path parameters :
33+
"pathParamMap", "pathParam", "pathParamAsClass"
34+
));
35+
}
36+
37+
@Override
38+
public ElementMatcher<? super TypeDescription> getTypeMatcher() {
39+
return hasSuperType(nameContains("io.javalin.http.Context"));
40+
}
41+
42+
public class JavalinDataAdvice {
43+
@Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
44+
public static void after(
45+
@Advice.This io.javalin.http.Context ctx,
46+
@Advice.Return Object data,
47+
@Advice.Origin Executable method
48+
) {
49+
String methodName = method.getName();
50+
if (Context.get() instanceof JavalinContextObject context) {
51+
if(methodName == "bodyAsClass") {
52+
// Body data via bodyAsClass, this overrides everything and is our preferred object :
53+
context.setBody(data);
54+
}
55+
56+
if (methodName == "formParamMap") {
57+
// Form data, this can only override body if body does not yet exist :
58+
if (context.getBody() == null) {
59+
context.setBody(data);
60+
}
61+
} else if(methodName.startsWith("form")) {
62+
// form functions that might not have used formParamMap, so we execute it here :
63+
ctx.formParamMap(); // This will fall through to the if-clause above.
64+
}
65+
66+
if (methodName == "pathParamMap") {
67+
// We will now store the path parameters :
68+
context.setParams(data);
69+
} else if(methodName.startsWith("path")) {
70+
// path functions that might not have used pathParamMap, so we execute pathParamMap here :
71+
ctx.pathParamMap(); // This will fall through to the if-clause above.
72+
}
73+
74+
// Store changes :
75+
Context.set(context);
76+
}
77+
}
78+
}
79+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package dev.aikido.agent.wrappers.javalin;
2+
3+
import dev.aikido.agent.wrappers.Wrapper;
4+
import dev.aikido.agent_api.collectors.WebRequestCollector;
5+
import dev.aikido.agent_api.collectors.WebResponseCollector;
6+
import dev.aikido.agent_api.context.Context;
7+
import dev.aikido.agent_api.context.ContextObject;
8+
import dev.aikido.agent_api.context.JavalinContextObject;
9+
import io.javalin.http.servlet.JavalinServletContext;
10+
import jakarta.servlet.http.HttpServletResponse;
11+
import net.bytebuddy.asm.Advice;
12+
import net.bytebuddy.description.method.MethodDescription;
13+
import net.bytebuddy.description.type.TypeDescription;
14+
import net.bytebuddy.matcher.*;
15+
16+
import java.lang.reflect.Executable;
17+
18+
import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC;
19+
import static net.bytebuddy.matcher.ElementMatchers.*;
20+
21+
/** JavalinWrapper
22+
* We're wrapping io.javalin.router.ParsedEndpoint's handle(JavalinServletContext, ...) function.
23+
* See here: https://github.com/javalin/javalin/blob/8b1dc1a55c28618df7f9f044aad4949d30a8cca8/javalin/src/main/java/io/javalin/router/ParsedEndpoint.kt#L14
24+
*/
25+
public class JavalinWrapper implements Wrapper {
26+
public String getName() {
27+
return JavalinAdvice.class.getName();
28+
}
29+
public ElementMatcher<? super MethodDescription> getMatcher() {
30+
return isDeclaredBy(getTypeMatcher()).and(named("handle"));
31+
}
32+
@Override
33+
public ElementMatcher<? super TypeDescription> getTypeMatcher() {
34+
return nameContains("io.javalin.router.ParsedEndpoint");
35+
}
36+
public class JavalinAdvice {
37+
@Advice.OnMethodEnter(suppress = Throwable.class)
38+
public static JavalinServletContext before(
39+
@Advice.This(typing = DYNAMIC, optional = true) Object target,
40+
@Advice.Origin Executable method,
41+
@Advice.Argument(value = 0, typing = DYNAMIC, optional = true) JavalinServletContext ctx
42+
) {
43+
if (Context.get() != null) {
44+
return ctx; // Do not extract if context already exists.
45+
}
46+
// Create a context object :
47+
ContextObject context = new JavalinContextObject(
48+
ctx.method().name(), ctx.url(), ctx.ip(),
49+
ctx.queryParamMap(), ctx.cookieMap(), ctx.headerMap()
50+
);
51+
WebRequestCollector.Res response = WebRequestCollector.report(context);
52+
53+
// Write a response if necessary :
54+
if (response != null) {
55+
ctx.result(response.msg());
56+
ctx.status(response.status());
57+
ctx.skipRemainingHandlers();
58+
}
59+
60+
return ctx;
61+
}
62+
@Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
63+
public static void after(
64+
@Advice.Enter(typing = DYNAMIC) JavalinServletContext ctx
65+
) {
66+
WebResponseCollector.report(ctx.statusCode());
67+
}
68+
}
69+
70+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package dev.aikido.agent_api.context;
2+
3+
import java.util.*;
4+
5+
import static dev.aikido.agent_api.helpers.net.ProxyForwardedParser.getIpFromRequest;
6+
import static dev.aikido.agent_api.helpers.url.BuildRouteFromUrl.buildRouteFromUrl;
7+
8+
public class JavalinContextObject extends ContextObject {
9+
// We use this map for when @RequestBody does not get used :
10+
protected transient Map<String, Object> bodyMap = new HashMap<>();
11+
public JavalinContextObject(
12+
String method, String url, String rawIp, Map<String, List<String>> queryParams,
13+
Map<String, String> cookies, Map<String, String> headers
14+
) {
15+
this.method = method;
16+
if (url != null) {
17+
this.url = url.toString();
18+
}
19+
this.query = new HashMap<>(queryParams);
20+
this.cookies = extractCookies(cookies);
21+
this.headers = extractHeaders(headers);
22+
this.route = buildRouteFromUrl(this.url);
23+
this.remoteAddress = getIpFromRequest(rawIp, this.headers);
24+
this.source = "Javalin";
25+
this.redirectStartNodes = new ArrayList<>();
26+
27+
// We don't have access yet to the route parameters, will add once we have access.
28+
this.params = null;
29+
}
30+
public void setParams(Object params) {
31+
this.params = params;
32+
this.cache.remove("routeParams"); // Reset cache
33+
}
34+
35+
private static HashMap<String, List<String>> extractCookies(Map<String, String> cookieMap) {
36+
HashMap<String, List<String>> cookies = new HashMap<>();
37+
38+
for (Map.Entry<String, String> entry : cookieMap.entrySet()) {
39+
cookies.put(entry.getKey(), List.of(entry.getValue()));
40+
}
41+
return cookies;
42+
}
43+
private static HashMap<String, String> extractHeaders(Map<String, String> rawHeaders) {
44+
HashMap<String, String> headers = new HashMap<>();
45+
for (Map.Entry<String, String> entry: rawHeaders.entrySet()) {
46+
// Lower-case keys :
47+
headers.put(entry.getKey().toLowerCase(), entry.getValue());
48+
}
49+
return headers;
50+
}
51+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package context;
2+
3+
import dev.aikido.agent_api.context.JavalinContextObject;
4+
import dev.aikido.agent_api.context.RouteMetadata;
5+
import org.junit.jupiter.api.BeforeEach;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.util.*;
9+
10+
import static org.junit.jupiter.api.Assertions.*;
11+
12+
class JavalinContextObjectTest {
13+
14+
private JavalinContextObject contextObject;
15+
16+
@BeforeEach
17+
void setUp() {
18+
String method = "GET";
19+
String url = "http://example.com/api/resource";
20+
String rawIp = "192.168.1.1";
21+
Map<String, List<String>> queryParams = new HashMap<>();
22+
queryParams.put("param1", List.of("value1"));
23+
Map<String, String> cookies = new HashMap<>();
24+
cookies.put("sessionId", "abc123");
25+
Map<String, String> headers = new HashMap<>();
26+
headers.put("Content-Type", "application/json");
27+
28+
contextObject = new JavalinContextObject(method, url, rawIp, queryParams, cookies, headers);
29+
}
30+
31+
@Test
32+
void testConstructor() {
33+
assertEquals("GET", contextObject.getMethod());
34+
assertEquals("http://example.com/api/resource", contextObject.getUrl());
35+
assertEquals("192.168.1.1", contextObject.getRemoteAddress());
36+
assertEquals("application/json", contextObject.getHeaders().get("content-type"));
37+
assertEquals(1, contextObject.getQuery().size());
38+
assertEquals("value1", contextObject.getQuery().get("param1").get(0));
39+
assertEquals(1, contextObject.getCookies().size());
40+
assertEquals("abc123", contextObject.getCookies().get("sessionId").get(0));
41+
}
42+
43+
@Test
44+
void testSetParams() {
45+
Object params = new HashMap<String, String>() {{
46+
put("key", "value");
47+
}};
48+
contextObject.setParams(params);
49+
assertEquals(params, contextObject.getParams());
50+
}
51+
52+
@Test
53+
void testSetBody() {
54+
Object body = new HashMap<String, String>() {{
55+
put("field", "data");
56+
}};
57+
contextObject.setBody(body);
58+
assertEquals(body, contextObject.getBody());
59+
}
60+
61+
@Test
62+
void testSetExecutedMiddleware() {
63+
contextObject.setExecutedMiddleware(true);
64+
assertTrue(contextObject.middlewareExecuted());
65+
}
66+
67+
@Test
68+
void testToJson() {
69+
String json = contextObject.toJson();
70+
assertNotNull(json);
71+
assertTrue(json.contains("GET"));
72+
assertTrue(json.contains("http://example.com/api/resource"));
73+
}
74+
75+
@Test
76+
void testGetRouteMetadata() {
77+
RouteMetadata metadata = contextObject.getRouteMetadata();
78+
assertNotNull(metadata);
79+
assertEquals(contextObject.getRoute(), metadata.route());
80+
assertEquals(contextObject.getUrl(), metadata.url());
81+
assertEquals(contextObject.getMethod(), metadata.method());
82+
}
83+
84+
@Test
85+
void testHeadersExtraction() {
86+
// Test headers extraction through the constructor
87+
assertEquals("application/json", contextObject.getHeaders().get("content-type"));
88+
assertEquals(1, contextObject.getHeaders().size());
89+
assertTrue(contextObject.getHeaders().containsKey("content-type"));
90+
}
91+
92+
@Test
93+
void testCookiesExtraction() {
94+
// Test cookies extraction through the constructor
95+
assertEquals(1, contextObject.getCookies().size());
96+
assertTrue(contextObject.getCookies().containsKey("sessionId"));
97+
assertEquals("abc123", contextObject.getCookies().get("sessionId").get(0));
98+
}
99+
100+
@Test
101+
void testMultipleCookiesExtraction() {
102+
// Test with multiple cookies
103+
Map<String, String> cookies = new HashMap<>();
104+
cookies.put("sessionId", "abc123");
105+
cookies.put("userId", "user456");
106+
contextObject = new JavalinContextObject("GET", "http://example.com", "192.168.1.1", new HashMap<>(), cookies, new HashMap<>());
107+
108+
assertEquals("abc123", contextObject.getCookies().get("sessionId").get(0));
109+
assertEquals("user456", contextObject.getCookies().get("userId").get(0));
110+
assertEquals(2, contextObject.getCookies().size());
111+
}
112+
}

0 commit comments

Comments
 (0)