Skip to content

Commit 5353d51

Browse files
authored
Extract RestEasy json body response schemas (#9015)
What Does This Do Adds smoke test to probe that response body extraction for RestEasy JSON endpoints to enable automatic API schema discovery and protection by the Web Application Firewall (WAF) was covered with the instrumentation done in #9014 Jira ticket: APPSEC-57916
1 parent 036f8f1 commit 5353d51

File tree

5 files changed

+163
-0
lines changed

5 files changed

+163
-0
lines changed

dd-smoke-tests/resteasy/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies {
1616
implementation group: 'org.jboss.resteasy', name: 'resteasy-undertow', version:'3.1.0.Final'
1717
implementation group: 'org.jboss.resteasy', name: 'resteasy-cdi', version:'3.1.0.Final'
1818
implementation group: 'org.jboss.weld.servlet', name: 'weld-servlet', version: '2.4.8.Final'
19+
implementation group: 'org.jboss.resteasy', name: 'resteasy-jackson2-provider', version: '3.1.0.Final'
1920

2021
implementation group: 'javax.el', name: 'javax.el-api', version:'3.0.0'
2122

@@ -24,6 +25,7 @@ dependencies {
2425

2526
testImplementation project(':dd-smoke-tests')
2627
testImplementation(testFixtures(project(":dd-smoke-tests:iast-util")))
28+
testImplementation project(':dd-smoke-tests:appsec')
2729
}
2830

2931
tasks.withType(Test).configureEach {

dd-smoke-tests/resteasy/src/main/java/smoketest/resteasy/App.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package smoketest.resteasy;
22

3+
import com.fasterxml.jackson.jaxrs.json.JacksonJsonProvider;
34
import java.util.HashSet;
45
import java.util.Set;
56
import javax.ws.rs.core.Application;
7+
import org.jboss.resteasy.plugins.providers.StringTextStar;
68

79
public class App extends Application {
810

911
private Set<Object> singletons = new HashSet<Object>();
1012

1113
public App() {
1214
singletons.add(new Resource());
15+
singletons.add(new StringTextStar()); // Writer for String
16+
singletons.add(new JacksonJsonProvider()); // Writer for json
1317
}
1418

1519
@Override
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package smoketest.resteasy;
2+
3+
import java.util.List;
4+
5+
public class RequestBody {
6+
private List<KeyValue> main;
7+
private Object nullable;
8+
9+
public List<KeyValue> getMain() {
10+
return main;
11+
}
12+
13+
public void setMain(List<KeyValue> main) {
14+
this.main = main;
15+
}
16+
17+
public Object getNullable() {
18+
return nullable;
19+
}
20+
21+
public void setNullable(Object nullable) {
22+
this.nullable = nullable;
23+
}
24+
25+
public static class KeyValue {
26+
private String key;
27+
private Double value;
28+
29+
public String getKey() {
30+
return key;
31+
}
32+
33+
public void setKey(String key) {
34+
this.key = key;
35+
}
36+
37+
public Double getValue() {
38+
return value;
39+
}
40+
41+
public void setValue(Double value) {
42+
this.value = value;
43+
}
44+
}
45+
}

dd-smoke-tests/resteasy/src/main/java/smoketest/resteasy/Resource.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
import java.util.List;
77
import java.util.Set;
88
import java.util.SortedSet;
9+
import javax.ws.rs.Consumes;
910
import javax.ws.rs.CookieParam;
1011
import javax.ws.rs.GET;
1112
import javax.ws.rs.HeaderParam;
13+
import javax.ws.rs.POST;
1214
import javax.ws.rs.Path;
1315
import javax.ws.rs.PathParam;
1416
import javax.ws.rs.Produces;
@@ -94,4 +96,18 @@ public Response responseLocation(@QueryParam("param") String param) throws URISy
9496
public Response getCookie() throws SQLException {
9597
return Response.ok().cookie(new NewCookie("user-id", "7")).build();
9698
}
99+
100+
@Path("/api_security/response")
101+
@POST
102+
@Produces(MediaType.APPLICATION_JSON)
103+
@Consumes(MediaType.APPLICATION_JSON)
104+
public Response apiSecurityResponse(RequestBody input) {
105+
return Response.ok(input).build();
106+
}
107+
108+
@GET
109+
@Path("/api_security/sampling/{i}")
110+
public Response apiSecuritySamplingWithStatus(@PathParam("i") int i) {
111+
return Response.status(i).header("content-type", "text/plain").entity("Hello!\n").build();
112+
}
97113
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package smoketest
2+
3+
import datadog.smoketest.appsec.AbstractAppSecServerSmokeTest
4+
import datadog.trace.api.Platform
5+
import groovy.json.JsonOutput
6+
import groovy.json.JsonSlurper
7+
import okhttp3.MediaType
8+
import okhttp3.Request
9+
import okhttp3.RequestBody
10+
import okhttp3.Response
11+
12+
import java.util.zip.GZIPInputStream
13+
14+
15+
class ResteasyAppsecSmokeTest extends AbstractAppSecServerSmokeTest {
16+
17+
@Override
18+
ProcessBuilder createProcessBuilder() {
19+
String jarPath = System.getProperty("datadog.smoketest.resteasy.jar.path")
20+
21+
List<String> command = new ArrayList<>()
22+
command.add(javaPath())
23+
command.addAll(defaultJavaProperties)
24+
command.addAll(defaultAppSecProperties)
25+
if (Platform.isJavaVersionAtLeast(17)) {
26+
command.addAll(["--add-opens", "java.base/java.lang=ALL-UNNAMED"])
27+
}
28+
command.addAll(["-jar", jarPath, Integer.toString(httpPort)])
29+
ProcessBuilder processBuilder = new ProcessBuilder(command)
30+
processBuilder.directory(new File(buildDirectory))
31+
}
32+
33+
void 'API Security samples only one request per endpoint'() {
34+
given:
35+
def url = "http://localhost:${httpPort}/hello/api_security/sampling/200?test=value"
36+
def request = new Request.Builder()
37+
.url(url)
38+
.addHeader('X-My-Header', "value")
39+
.get()
40+
.build()
41+
42+
when:
43+
List<Response> responses = (1..3).collect {
44+
client.newCall(request).execute()
45+
}
46+
47+
then:
48+
responses.each {
49+
assert it.code() == 200
50+
}
51+
waitForTraceCount(3)
52+
def spans = rootSpans.toList().toSorted { it.span.duration }
53+
spans.size() == 3
54+
def sampledSpans = spans.findAll {
55+
it.meta.keySet().any {
56+
it.startsWith('_dd.appsec.s.req.')
57+
}
58+
}
59+
sampledSpans.size() == 1
60+
def span = sampledSpans[0]
61+
span.meta.containsKey('_dd.appsec.s.req.query')
62+
span.meta.containsKey('_dd.appsec.s.req.params')
63+
span.meta.containsKey('_dd.appsec.s.req.headers')
64+
}
65+
66+
67+
void 'test response schema extraction'() {
68+
given:
69+
def url = "http://localhost:${httpPort}/hello/api_security/response"
70+
def body = [
71+
"main" : [["key": "id001", "value": 1345.67], ["value": 1567.89, "key": "id002"]],
72+
"nullable": null,
73+
]
74+
def request = new Request.Builder()
75+
.url(url)
76+
.post(RequestBody.create(MediaType.get('application/json'), JsonOutput.toJson(body)))
77+
.build()
78+
79+
when:
80+
final response = client.newCall(request).execute()
81+
waitForTraceCount(1)
82+
83+
then:
84+
response.code() == 200
85+
def span = rootSpans.first()
86+
span.meta.containsKey('_dd.appsec.s.res.headers')
87+
span.meta.containsKey('_dd.appsec.s.res.body')
88+
final schema = new JsonSlurper().parse(unzip(span.meta.get('_dd.appsec.s.res.body')))
89+
assert schema == [["main": [[[["key": [8], "value": [16]]]], ["len": 2]], "nullable": [1]]]
90+
}
91+
92+
private static byte[] unzip(final String text) {
93+
final inflaterStream = new GZIPInputStream(new ByteArrayInputStream(text.decodeBase64()))
94+
return inflaterStream.getBytes()
95+
}
96+
}

0 commit comments

Comments
 (0)