Skip to content

Commit 3f7d6e6

Browse files
authored
Merge pull request github#6136 from smowton/smowton/admin/spring-xss-content-type-sensitivity
Spring HTTP: improve content-type sensitivity
2 parents 2a9e3da + 406466d commit 3f7d6e6

File tree

5 files changed

+219
-56
lines changed

5 files changed

+219
-56
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
lgtm,codescanning
2+
* The XSS query now accounts for more ways to set the content-type of an entity served via a Spring HTTP endpoint. This may flag more cases where an XSS-vulnerable content-type is set, and exclude more cases where a non-vulnerable content-type such as `application/json` is set.

java/ql/lib/semmle/code/java/frameworks/spring/SpringController.qll

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,30 +100,55 @@ class SpringResponseBodyAnnotationType extends AnnotationType {
100100
}
101101
}
102102

103+
private class SpringRequestMappingAnnotation extends Annotation {
104+
SpringRequestMappingAnnotation() { this.getType() instanceof SpringRequestMappingAnnotationType }
105+
}
106+
107+
private Expr getProducesExpr(RefType rt) {
108+
result = rt.getAnAnnotation().(SpringRequestMappingAnnotation).getValue("produces")
109+
or
110+
rt.getAnAnnotation().(SpringRequestMappingAnnotation).getValue("produces").(ArrayInit).getSize() =
111+
0 and
112+
result = getProducesExpr(rt.getASupertype())
113+
}
114+
103115
/**
104116
* A method on a Spring controller that is executed in response to a web request.
105117
*/
106118
class SpringRequestMappingMethod extends SpringControllerMethod {
107-
Annotation requestMappingAnnotation;
119+
SpringRequestMappingAnnotation requestMappingAnnotation;
108120

109121
SpringRequestMappingMethod() {
110122
// Any method that declares the @RequestMapping annotation, or overrides a method that declares
111123
// the annotation. We have to do this explicit check because the @RequestMapping annotation is
112124
// not declared with @Inherited.
113125
exists(Method superMethod |
114126
this.overrides*(superMethod) and
115-
requestMappingAnnotation = superMethod.getAnAnnotation() and
116-
requestMappingAnnotation.getType() instanceof SpringRequestMappingAnnotationType
127+
requestMappingAnnotation = superMethod.getAnAnnotation()
117128
)
118129
}
119130

120131
/** Gets a request mapping parameter. */
121132
SpringRequestMappingParameter getARequestParameter() { result = getAParameter() }
122133

123-
/** Gets the "produces" @RequestMapping annotation value, if present. */
134+
/** Gets the "produces" @RequestMapping annotation value, if present. If an array is specified, gets the array. */
135+
Expr getProducesExpr() {
136+
result = requestMappingAnnotation.getValue("produces")
137+
or
138+
requestMappingAnnotation.getValue("produces").(ArrayInit).getSize() = 0 and
139+
result = getProducesExpr(this.getDeclaringType())
140+
}
141+
142+
/** Gets a "produces" @RequestMapping annotation value. If an array is specified, gets a member of the array. */
143+
Expr getAProducesExpr() {
144+
result = this.getProducesExpr() and not result instanceof ArrayInit
145+
or
146+
result = this.getProducesExpr().(ArrayInit).getAnInit()
147+
}
148+
149+
/** Gets the "produces" @RequestMapping annotation value, if present and a string constant. */
124150
string getProduces() {
125-
result =
126-
requestMappingAnnotation.getValue("produces").(CompileTimeConstantExpr).getStringValue()
151+
result = this.getProducesExpr().(CompileTimeConstantExpr).getStringValue()
127152
}
128153

129154
/** Gets the "value" @RequestMapping annotation value, if present. */

java/ql/lib/semmle/code/java/frameworks/spring/SpringHttp.qll

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

66
import java
77
private import semmle.code.java.dataflow.ExternalFlow
8+
private import semmle.code.java.dataflow.DataFlow
9+
private import semmle.code.java.frameworks.spring.SpringController
10+
private import semmle.code.java.security.XSS as XSS
811

912
/** The class `org.springframework.http.HttpEntity` or an instantiation of it. */
1013
class SpringHttpEntity extends Class {
@@ -140,3 +143,178 @@ private class SpringHttpFlowStep extends SummaryModelCsv {
140143
]
141144
}
142145
}
146+
147+
private predicate specifiesContentType(SpringRequestMappingMethod method) {
148+
exists(method.getAProducesExpr())
149+
}
150+
151+
private class SpringXssSink extends XSS::XssSink {
152+
SpringXssSink() {
153+
exists(SpringRequestMappingMethod requestMappingMethod, ReturnStmt rs |
154+
requestMappingMethod = rs.getEnclosingCallable() and
155+
this.asExpr() = rs.getResult() and
156+
(
157+
not specifiesContentType(requestMappingMethod) or
158+
isXssVulnerableContentTypeExpr(requestMappingMethod.getAProducesExpr())
159+
)
160+
|
161+
// If a Spring request mapping method is either annotated with @ResponseBody (or equivalent),
162+
// or returns a HttpEntity or sub-type, then the return value of the method is converted into
163+
// a HTTP reponse using a HttpMessageConverter implementation. The implementation is chosen
164+
// based on the return type of the method, and the Accept header of the request.
165+
//
166+
// By default, the only message converter which produces a response which is vulnerable to
167+
// XSS is the StringHttpMessageConverter, which "Accept"s all text/* content types, including
168+
// text/html. Therefore, if a browser request includes "text/html" in the "Accept" header,
169+
// any String returned will be converted into a text/html response.
170+
requestMappingMethod.isResponseBody() and
171+
requestMappingMethod.getReturnType() instanceof TypeString
172+
or
173+
exists(Type returnType |
174+
// A return type of HttpEntity<T> or ResponseEntity<T> represents an HTTP response with both
175+
// a body and a set of headers. The body is subject to the same HttpMessageConverter
176+
// process as above.
177+
returnType = requestMappingMethod.getReturnType() and
178+
(
179+
returnType instanceof SpringHttpEntity
180+
or
181+
returnType instanceof SpringResponseEntity
182+
)
183+
|
184+
// The type argument, representing the type of the body, is type String
185+
returnType.(ParameterizedClass).getTypeArgument(0) instanceof TypeString
186+
or
187+
// Return type is a Raw class, which means no static type information on the body. In this
188+
// case we will still treat this as an XSS sink, but rely on our taint flow steps for
189+
// HttpEntity/ResponseEntity to only pass taint into those instances if the body type was
190+
// String.
191+
returnType instanceof RawClass
192+
)
193+
)
194+
}
195+
}
196+
197+
private string getSpringConstantContentType(FieldAccess e) {
198+
e.getQualifier().getType().(RefType).hasQualifiedName("org.springframework.http", "MediaType") and
199+
exists(string fieldName | e.getField().hasName(fieldName) |
200+
fieldName = "APPLICATION_ATOM_XML" + ["", "_VALUE"] and result = "application/atom+xml"
201+
or
202+
fieldName = "APPLICATION_CBOR" + ["", "_VALUE"] and result = "application/cbor"
203+
or
204+
fieldName = "APPLICATION_FORM_URLENCODED" + ["", "_VALUE"] and
205+
result = "application/x-www-form-urlencoded"
206+
or
207+
fieldName = "APPLICATION_JSON" + ["", "_VALUE"] and result = "application/json"
208+
or
209+
fieldName = "APPLICATION_JSON_UTF8" + ["", "_VALUE"] and
210+
result = "application/json;charset=UTF-8"
211+
or
212+
fieldName = "APPLICATION_NDJSON" + ["", "_VALUE"] and result = "application/x-ndjson"
213+
or
214+
fieldName = "APPLICATION_OCTET_STREAM" + ["", "_VALUE"] and result = "application/octet-stream"
215+
or
216+
fieldName = "APPLICATION_PDF" + ["", "_VALUE"] and result = "application/pdf"
217+
or
218+
fieldName = "APPLICATION_PROBLEM_JSON" + ["", "_VALUE"] and result = "application/problem+json"
219+
or
220+
fieldName = "APPLICATION_PROBLEM_JSON_UTF8" + ["", "_VALUE"] and
221+
result = "application/problem+json;charset=UTF-8"
222+
or
223+
fieldName = "APPLICATION_PROBLEM_XML" + ["", "_VALUE"] and result = "application/problem+xml"
224+
or
225+
fieldName = "APPLICATION_RSS_XML" + ["", "_VALUE"] and result = "application/rss+xml"
226+
or
227+
fieldName = "APPLICATION_STREAM_JSON" + ["", "_VALUE"] and result = "application/stream+json"
228+
or
229+
fieldName = "APPLICATION_XHTML_XML" + ["", "_VALUE"] and result = "application/xhtml+xml"
230+
or
231+
fieldName = "APPLICATION_XML" + ["", "_VALUE"] and result = "application/xml"
232+
or
233+
fieldName = "IMAGE_GIF" + ["", "_VALUE"] and result = "image/gif"
234+
or
235+
fieldName = "IMAGE_JPEG" + ["", "_VALUE"] and result = "image/jpeg"
236+
or
237+
fieldName = "IMAGE_PNG" + ["", "_VALUE"] and result = "image/png"
238+
or
239+
fieldName = "MULTIPART_FORM_DATA" + ["", "_VALUE"] and result = "multipart/form-data"
240+
or
241+
fieldName = "MULTIPART_MIXED" + ["", "_VALUE"] and result = "multipart/mixed"
242+
or
243+
fieldName = "MULTIPART_RELATED" + ["", "_VALUE"] and result = "multipart/related"
244+
or
245+
fieldName = "TEXT_EVENT_STREAM" + ["", "_VALUE"] and result = "text/event-stream"
246+
or
247+
fieldName = "TEXT_HTML" + ["", "_VALUE"] and result = "text/html"
248+
or
249+
fieldName = "TEXT_MARKDOWN" + ["", "_VALUE"] and result = "text/markdown"
250+
or
251+
fieldName = "TEXT_PLAIN" + ["", "_VALUE"] and result = "text/plain"
252+
or
253+
fieldName = "TEXT_XML" + ["", "_VALUE"] and result = "text/xml"
254+
)
255+
}
256+
257+
private predicate isXssVulnerableContentTypeExpr(Expr e) {
258+
XSS::isXssVulnerableContentType(e.(CompileTimeConstantExpr).getStringValue()) or
259+
XSS::isXssVulnerableContentType(getSpringConstantContentType(e))
260+
}
261+
262+
private predicate isXssSafeContentTypeExpr(Expr e) {
263+
XSS::isXssSafeContentType(e.(CompileTimeConstantExpr).getStringValue()) or
264+
XSS::isXssSafeContentType(getSpringConstantContentType(e))
265+
}
266+
267+
private DataFlow::Node getABodyBuilderWithExplicitContentType(Expr contentType) {
268+
result.asExpr() =
269+
any(MethodAccess ma |
270+
ma.getCallee()
271+
.hasQualifiedName("org.springframework.http", "ResponseEntity$BodyBuilder", "contentType") and
272+
contentType = ma.getArgument(0)
273+
)
274+
or
275+
result.asExpr() =
276+
any(MethodAccess ma |
277+
ma.getQualifier() = getABodyBuilderWithExplicitContentType(contentType).asExpr() and
278+
ma.getType()
279+
.(RefType)
280+
.hasQualifiedName("org.springframework.http", "ResponseEntity$BodyBuilder")
281+
)
282+
or
283+
DataFlow::localFlow(getABodyBuilderWithExplicitContentType(contentType), result)
284+
}
285+
286+
private DataFlow::Node getASanitizedBodyBuilder() {
287+
result = getABodyBuilderWithExplicitContentType(any(Expr e | isXssSafeContentTypeExpr(e)))
288+
}
289+
290+
private DataFlow::Node getAVulnerableBodyBuilder() {
291+
result = getABodyBuilderWithExplicitContentType(any(Expr e | isXssVulnerableContentTypeExpr(e)))
292+
}
293+
294+
private class SanitizedBodyCall extends XSS::XssSanitizer {
295+
SanitizedBodyCall() {
296+
this.asExpr() =
297+
any(MethodAccess ma |
298+
ma.getQualifier() = getASanitizedBodyBuilder().asExpr() and
299+
ma.getCallee().hasName("body")
300+
).getArgument(0)
301+
}
302+
}
303+
304+
/**
305+
* Mark BodyBuilder.body calls with an explicitly vulnerable Content-Type as themselves sinks,
306+
* as the eventual return site from a RequestHandler may have a benign @Produces annotation that
307+
* would otherwise sanitise the result.
308+
*
309+
* Note these are SinkBarriers so that a return from a RequestHandlerMethod is not also flagged
310+
* for the same path.
311+
*/
312+
private class ExplicitlyVulnerableBodyArgument extends XSS::XssSinkBarrier {
313+
ExplicitlyVulnerableBodyArgument() {
314+
this.asExpr() =
315+
any(MethodAccess ma |
316+
ma.getQualifier() = getAVulnerableBodyBuilder().asExpr() and
317+
ma.getCallee().hasName("body")
318+
).getArgument(0)
319+
}
320+
}

java/ql/lib/semmle/code/java/security/XSS.qll

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -46,48 +46,6 @@ private class DefaultXssSink extends XssSink {
4646
writer.hasFlowToExpr(ma.getQualifier()) and
4747
this.asExpr() = ma.getArgument(_)
4848
)
49-
or
50-
exists(SpringRequestMappingMethod requestMappingMethod, ReturnStmt rs |
51-
requestMappingMethod = rs.getEnclosingCallable() and
52-
this.asExpr() = rs.getResult() and
53-
(
54-
not exists(requestMappingMethod.getProduces()) or
55-
requestMappingMethod.getProduces().matches("text/%")
56-
)
57-
|
58-
// If a Spring request mapping method is either annotated with @ResponseBody (or equivalent),
59-
// or returns a HttpEntity or sub-type, then the return value of the method is converted into
60-
// a HTTP reponse using a HttpMessageConverter implementation. The implementation is chosen
61-
// based on the return type of the method, and the Accept header of the request.
62-
//
63-
// By default, the only message converter which produces a response which is vulnerable to
64-
// XSS is the StringHttpMessageConverter, which "Accept"s all text/* content types, including
65-
// text/html. Therefore, if a browser request includes "text/html" in the "Accept" header,
66-
// any String returned will be converted into a text/html response.
67-
requestMappingMethod.isResponseBody() and
68-
requestMappingMethod.getReturnType() instanceof TypeString
69-
or
70-
exists(Type returnType |
71-
// A return type of HttpEntity<T> or ResponseEntity<T> represents an HTTP response with both
72-
// a body and a set of headers. The body is subject to the same HttpMessageConverter
73-
// process as above.
74-
returnType = requestMappingMethod.getReturnType() and
75-
(
76-
returnType instanceof SpringHttpEntity
77-
or
78-
returnType instanceof SpringResponseEntity
79-
)
80-
|
81-
// The type argument, representing the type of the body, is type String
82-
returnType.(ParameterizedClass).getTypeArgument(0) instanceof TypeString
83-
or
84-
// Return type is a Raw class, which means no static type information on the body. In this
85-
// case we will still treat this as an XSS sink, but rely on our taint flow steps for
86-
// HttpEntity/ResponseEntity to only pass taint into those instances if the body type was
87-
// String.
88-
returnType instanceof RawClass
89-
)
90-
)
9149
}
9250
}
9351

java/ql/test/query-tests/security/CWE-079/semmle/tests/SpringXSS.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ public static ResponseEntity<String> specificContentType(boolean safeContentType
2828
}
2929
else {
3030
if(chainDirectly) {
31-
return builder.contentType(MediaType.APPLICATION_JSON).body(userControlled); // $SPURIOUS: xss
31+
return builder.contentType(MediaType.APPLICATION_JSON).body(userControlled);
3232
}
3333
else {
3434
ResponseEntity.BodyBuilder builder2 = builder.contentType(MediaType.APPLICATION_JSON);
35-
return builder2.body(userControlled); // $SPURIOUS: xss
35+
return builder2.body(userControlled);
3636
}
3737
}
3838

@@ -60,7 +60,7 @@ public static ResponseEntity<String> methodContentTypeSafeStringLiteral(String u
6060

6161
@GetMapping(value = "/xyz", produces = MediaType.TEXT_HTML_VALUE)
6262
public static ResponseEntity<String> methodContentTypeUnsafe(String userControlled) {
63-
return ResponseEntity.ok(userControlled); // $MISSING: xss
63+
return ResponseEntity.ok(userControlled); // $xss
6464
}
6565

6666
@GetMapping(value = "/xyz", produces = "text/html")
@@ -75,7 +75,7 @@ public static ResponseEntity<String> methodContentTypeMaybeSafe(String userContr
7575

7676
@GetMapping(value = "/xyz", produces = MediaType.APPLICATION_JSON_VALUE)
7777
public static ResponseEntity<String> methodContentTypeSafeOverriddenWithUnsafe(String userControlled) {
78-
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(userControlled); // $MISSING: xss
78+
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(userControlled); // $xss
7979
}
8080

8181
@GetMapping(value = "/xyz", produces = MediaType.TEXT_HTML_VALUE)
@@ -105,12 +105,12 @@ public static ResponseEntity<String> methodContentTypeMaybeSafeStringLiterals(St
105105
private static class ClassContentTypeSafe {
106106
@GetMapping(value = "/abc")
107107
public ResponseEntity<String> test(String userControlled) {
108-
return ResponseEntity.ok(userControlled); // $SPURIOUS: xss
108+
return ResponseEntity.ok(userControlled);
109109
}
110110

111111
@GetMapping(value = "/abc")
112112
public String testDirectReturn(String userControlled) {
113-
return userControlled; // $SPURIOUS: xss
113+
return userControlled;
114114
}
115115

116116
@GetMapping(value = "/xyz", produces = {"text/html"})
@@ -139,12 +139,12 @@ public String testDirectReturn(String userControlled) {
139139

140140
@GetMapping(value = "/xyz", produces = {"application/json"})
141141
public ResponseEntity<String> overridesWithSafe(String userControlled) {
142-
return ResponseEntity.ok(userControlled); // $SPURIOUS: xss
142+
return ResponseEntity.ok(userControlled);
143143
}
144144

145145
@GetMapping(value = "/abc")
146146
public ResponseEntity<String> overridesWithSafe2(String userControlled) {
147-
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(userControlled); // $SPURIOUS: xss
147+
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(userControlled);
148148
}
149149
}
150150

0 commit comments

Comments
 (0)