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 .github/workflows/end2end.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
- { name: JavalinMySQLKotlin, test_file: end2end/javalin_mysql_kotlin.py, db: mysql_database }
- { name: SpringBoot2.7Postgres, test_file: end2end/spring_boot_2.7_postgres.py, db: postgres_database }
- { name: SpringBootHyperSQL, test_file: end2end/spring_boot_hypersql.py, db: "" }
- { name: SpringBoot4HyperSQL, test_file: end2end/spring_boot_4_hypersql.py, db: "" }
java-version: [17, 18, 19, 20, 21, 24, 25]
distribution: ['adopt', 'corretto', 'oracle']
exclude:
Expand Down
2 changes: 2 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,7 @@
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.SpringMVCDispatcherWrapper;

import java.util.Arrays;
import java.util.List;
Expand All @@ -18,6 +19,7 @@ private Wrappers() {}
public static final List<Wrapper> WRAPPERS = Arrays.asList(
new PostgresWrapper(),
new SpringMVCJakartaWrapper(),
new SpringMVCDispatcherWrapper(),
new SpringMVCJavaxWrapper(),
new SpringWebfluxWrapper(),
new SpringControllerWrapper(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package dev.aikido.agent.wrappers.spring;

import dev.aikido.agent.wrappers.Wrapper;
import dev.aikido.agent_api.collectors.WebRequestCollector;
import dev.aikido.agent_api.collectors.WebResponseCollector;
import dev.aikido.agent_api.context.ContextObject;
import dev.aikido.agent_api.context.SpringMVCContextObject;
import dev.aikido.agent_api.helpers.logging.LogManager;
import dev.aikido.agent_api.helpers.logging.Logger;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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.Executable;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;

import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC;
import static net.bytebuddy.matcher.ElementMatchers.nameContains;
import static net.bytebuddy.matcher.ElementMatchers.takesArgument;

// Fallback context creator for Spring MVC: wraps FrameworkServlet#processRequest (runs for every
// DispatcherServlet request) and creates the context when RequestContextFilter is absent from the chain.
public class SpringMVCDispatcherWrapper implements Wrapper {
public static final Logger logger = LogManager.getLogger(SpringMVCDispatcherWrapper.class);

@Override
public String getName() {
return SpringMVCDispatcherAdvice.class.getName();
}

@Override
public ElementMatcher<? super MethodDescription> getMatcher() {
return ElementMatchers.nameContainsIgnoreCase("processRequest")
.and(takesArgument(0, nameContains("jakarta")))
.and(takesArgument(1, nameContains("jakarta")));
}

@Override
public ElementMatcher<? super TypeDescription> getTypeMatcher() {
return nameContains("org.springframework.web.servlet.FrameworkServlet");
}

public static class SpringMVCDispatcherAdvice {
public record SkipOnWrapper(HttpServletResponse response) {}

@Advice.OnMethodEnter(skipOn = SkipOnWrapper.class, suppress = Throwable.class)
public static Object interceptOnEnter(
@Advice.Origin Executable method,
@Advice.Argument(value = 0, typing = DYNAMIC, optional = true) HttpServletRequest request,
@Advice.Argument(value = 1, typing = DYNAMIC, optional = true) HttpServletResponse response) throws Throwable {
if (request == null) {
return null;
}
// Per-request marker (not Context.get()) since pooled threads keep a stale context between requests.
if (request.getAttribute("dev.aikido.zen.springContextCreated") != null) {
return null;
}
HashMap<String, Enumeration<String>> headersMap = new HashMap<>();
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames != null && headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
Enumeration<String> headerValue = request.getHeaders(headerName);
headersMap.put(headerName, headerValue);
}
HashMap<String, List<String>> cookiesMap = new HashMap<>();
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (!cookiesMap.containsKey(cookie.getName())) {
cookiesMap.put(cookie.getName(), new ArrayList<>());
}
cookiesMap.get(cookie.getName()).add(cookie.getValue());
}
}

ContextObject contextObject = new SpringMVCContextObject(
request.getMethod(), request.getRequestURL(), request.getRemoteAddr(),
request.getParameterMap(), cookiesMap, headersMap, request.getQueryString()
);

request.setAttribute("dev.aikido.zen.springContextCreated", Boolean.TRUE);
WebRequestCollector.Res res = WebRequestCollector.report(contextObject);
if (res != null && response != null) {
response.setStatus(res.status());
response.setContentType("text/plain");
response.getWriter().write(res.msg());
return new SkipOnWrapper(response);
}
return response;
}

@Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
public static void interceptOnExit(@Advice.Enter Object response) {
if (response instanceof HttpServletResponse httpServletResponse) {
WebResponseCollector.report(httpServletResponse.getStatus());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ public static Object interceptOnEnter(
request.getParameterMap(), cookiesMap, headersMap, request.getQueryString()
);

// Marker read by SpringMVCDispatcherWrapper so the fallback does not re-create this context.
request.setAttribute("dev.aikido.zen.springContextCreated", Boolean.TRUE);

// Write a new response:
WebRequestCollector.Res res = WebRequestCollector.report(contextObject);
if (res != null) {
Expand Down
10 changes: 10 additions & 0 deletions end2end/spring_boot_4_hypersql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from utils import App, Request

spring_boot_4_hsql_app = App(8110)

spring_boot_4_hsql_app.add_payload("sql",
safe_request=Request("/api/pets/create", body={"name": "Bobby"}),
unsafe_request=Request("/api/pets/create", body={"name": "Malicious Pet', 'Gru from the Minions') -- "})
)

spring_boot_4_hsql_app.test_all_payloads()
2 changes: 2 additions & 0 deletions sample-apps/SpringBoot4HyperSQL/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
**output**.log
dd-java-agent**
38 changes: 38 additions & 0 deletions sample-apps/SpringBoot4HyperSQL/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Define variables
GRADLEW = ./gradlew
JAR_FILE = build/libs/demo-0.0.1-SNAPSHOT.jar
JAVA_AGENT = ../../dist/agent.jar

# Default target
.PHONY: all
all: build

# Build the project
.PHONY: build
build:
@echo "Building the project..."
chmod +x $(GRADLEW)
$(GRADLEW) build

# Run the application with the Java agent
.PHONY: run
run: build
@echo "Running SpringBoot4HyperSQL with Zen & ENV (http://localhost:8110)"
AIKIDO_LOG_LEVEL="error" \
AIKIDO_TOKEN="token" \
AIKIDO_REALTIME_ENDPOINT="http://localhost:5000/realtime" \
AIKIDO_ENDPOINT="http://localhost:5000" \
AIKIDO_BLOCK=1 \
java -javaagent:$(JAVA_AGENT) -jar $(JAR_FILE) --server.port=8110

# Run the application without Zen
.PHONY: runWithoutZen
runWithoutZen: build
@echo "Running SpringBoot4HyperSQL without Zen & ENV (http://localhost:8111)"
AIKIDO_TOKEN="random-invalid-token" java -jar $(JAR_FILE) --server.port=8111

# Clean the project
.PHONY: clean
clean:
@echo "Cleaning the project..."
$(GRADLEW) clean
7 changes: 7 additions & 0 deletions sample-apps/SpringBoot4HyperSQL/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# SpringBoot 4 + HyperSQL vulnerable sample app

Spring Boot 4 (Spring Framework 7, Jakarta) app on HyperSQL. It registers a `RequestContextListener`
bean, which makes `WebMvcAutoConfiguration` skip the auto `RequestContextFilter`. Zen must then create
the Spring MVC context from `FrameworkServlet#processRequest` instead of the filter.

- Inserting a malicious dog : `Malicious Pet', 'Gru from the Minions') -- `
23 changes: 23 additions & 0 deletions sample-apps/SpringBoot4HyperSQL/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.7'
id 'io.spring.dependency-management' version '1.1.6'
}

java {
sourceCompatibility = '17'
targetCompatibility = '17'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

repositories {
mavenCentral()
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.hsqldb:hsqldb:2.7.2'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#This file is generated by updateDaemonJvm
toolchainVersion=21
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading
Loading