Skip to content

Commit eaf3d3c

Browse files
authored
Merge pull request github#6162 from smowton/smowton/feature/jax-rs-content-type-sensitivity-fixes
Jax-RS: implement content-type tracking
2 parents 23ba7dc + 3bf4149 commit eaf3d3c

File tree

8 files changed

+228
-37
lines changed

8 files changed

+228
-37
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 Jax-RS 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/src/Security/CWE/CWE-079/XSS.ql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ class XSSConfig extends TaintTracking::Configuration {
2525

2626
override predicate isSanitizer(DataFlow::Node node) { node instanceof XssSanitizer }
2727

28+
override predicate isSanitizerOut(DataFlow::Node node) { node instanceof XssSinkBarrier }
29+
2830
override predicate isAdditionalTaintStep(DataFlow::Node node1, DataFlow::Node node2) {
2931
any(XssAdditionalTaintStep s).step(node1, node2)
3032
}

java/ql/src/semmle/code/java/frameworks/JaxWS.qll

Lines changed: 170 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -283,24 +283,34 @@ class MessageBodyReaderRead extends Method {
283283
}
284284
}
285285

286+
/**
287+
* Gets a constant content-type described by expression `e` (either a string constant or a Jax-RS MediaType field access).
288+
*/
289+
string getContentTypeString(Expr e) {
290+
result = e.(CompileTimeConstantExpr).getStringValue() and
291+
result != ""
292+
or
293+
exists(Field jaxMediaType |
294+
// Accesses to static fields on `MediaType` class do not have constant strings in the database
295+
// so convert the field name to a content type string
296+
jaxMediaType.getDeclaringType().hasQualifiedName(getAJaxRsPackage("core"), "MediaType") and
297+
jaxMediaType.getAnAccess() = e and
298+
// e.g. MediaType.TEXT_PLAIN => text/plain
299+
result = jaxMediaType.getName().toLowerCase().replaceAll("_value", "").replaceAll("_", "/")
300+
)
301+
}
302+
286303
/** An `@Produces` annotation that describes which content types can be produced by this resource. */
287304
class JaxRSProducesAnnotation extends JaxRSAnnotation {
288305
JaxRSProducesAnnotation() { this.getType().hasQualifiedName(getAJaxRsPackage(), "Produces") }
289306

290307
/**
291308
* Gets a declared content type that can be produced by this resource.
292309
*/
293-
string getADeclaredContentType() {
294-
result = this.getAValue().(CompileTimeConstantExpr).getStringValue()
310+
Expr getADeclaredContentTypeExpr() {
311+
result = this.getAValue() and not result instanceof ArrayInit
295312
or
296-
exists(Field jaxMediaType |
297-
// Accesses to static fields on `MediaType` class do not have constant strings in the database
298-
// so convert the field name to a content type string
299-
jaxMediaType.getDeclaringType().hasQualifiedName(getAJaxRsPackage("core"), "MediaType") and
300-
jaxMediaType.getAnAccess() = this.getAValue() and
301-
// e.g. MediaType.TEXT_PLAIN => text/plain
302-
result = jaxMediaType.getName().toLowerCase().replaceAll("_", "/")
303-
)
313+
result = this.getAValue().(ArrayInit).getAnInit()
304314
}
305315
}
306316

@@ -319,7 +329,9 @@ private class JaxRSXssSink extends XssSink {
319329
|
320330
not exists(resourceMethod.getProducesAnnotation())
321331
or
322-
resourceMethod.getProducesAnnotation().getADeclaredContentType() = "text/plain"
332+
isXssVulnerableContentType(getContentTypeString(resourceMethod
333+
.getProducesAnnotation()
334+
.getADeclaredContentTypeExpr()))
323335
)
324336
}
325337
}
@@ -796,3 +808,150 @@ private class JaxRsUrlOpenSink extends SinkModelCsv {
796808
]
797809
}
798810
}
811+
812+
private predicate isXssVulnerableContentTypeExpr(Expr e) {
813+
isXssVulnerableContentType(getContentTypeString(e))
814+
}
815+
816+
private predicate isXssSafeContentTypeExpr(Expr e) { isXssSafeContentType(getContentTypeString(e)) }
817+
818+
/**
819+
* Gets a builder expression or related type that is configured to use the given `contentType`.
820+
*
821+
* This could be an instance of `Response.ResponseBuilder`, `Variant`, `Variant.VariantListBuilder` or
822+
* a `List<Variant>`.
823+
*
824+
* This predicate is used to search forwards for response entities set after the content-type is configured.
825+
* It does not need to consider cases where the entity is set in the same call, or the entity has already
826+
* been set: these are handled by simple sanitization below.
827+
*/
828+
private DataFlow::Node getABuilderWithExplicitContentType(Expr contentType) {
829+
// Base case: ResponseBuilder.type(contentType)
830+
result.asExpr() =
831+
any(MethodAccess ma |
832+
ma.getCallee().hasQualifiedName(getAJaxRsPackage("core"), "Response$ResponseBuilder", "type") and
833+
contentType = ma.getArgument(0)
834+
)
835+
or
836+
// Base case: new Variant(contentType, ...)
837+
result.asExpr() =
838+
any(ClassInstanceExpr cie |
839+
cie.getConstructedType().hasQualifiedName(getAJaxRsPackage("core"), "Variant") and
840+
contentType = cie.getArgument(0)
841+
)
842+
or
843+
// Base case: Variant[.VariantListBuilder].mediaTypes(...)
844+
result.asExpr() =
845+
any(MethodAccess ma |
846+
ma.getCallee()
847+
.hasQualifiedName(getAJaxRsPackage("core"), ["Variant", "Variant$VariantListBuilder"],
848+
"mediaTypes") and
849+
contentType = ma.getAnArgument()
850+
)
851+
or
852+
// Recursive case: propagate through variant list building:
853+
result.asExpr() =
854+
any(MethodAccess ma |
855+
(
856+
ma.getType()
857+
.(RefType)
858+
.hasQualifiedName(getAJaxRsPackage("core"), "Variant$VariantListBuilder")
859+
or
860+
ma.getMethod()
861+
.hasQualifiedName(getAJaxRsPackage("core"), "Variant$VariantListBuilder", "build")
862+
) and
863+
[ma.getAnArgument(), ma.getQualifier()] =
864+
getABuilderWithExplicitContentType(contentType).asExpr()
865+
)
866+
or
867+
// Recursive case: propagate through a List.get operation
868+
result.asExpr() =
869+
any(MethodAccess ma |
870+
ma.getMethod().hasQualifiedName("java.util", "List<Variant>", "get") and
871+
ma.getQualifier() = getABuilderWithExplicitContentType(contentType).asExpr()
872+
)
873+
or
874+
// Recursive case: propagate through Response.ResponseBuilder operations, including the `variant(...)` operation.
875+
result.asExpr() =
876+
any(MethodAccess ma |
877+
ma.getType().(RefType).hasQualifiedName(getAJaxRsPackage("core"), "Response$ResponseBuilder") and
878+
[ma.getQualifier(), ma.getArgument(0)] =
879+
getABuilderWithExplicitContentType(contentType).asExpr()
880+
)
881+
or
882+
// Recursive case: ordinary local dataflow
883+
DataFlow::localFlowStep(getABuilderWithExplicitContentType(contentType), result)
884+
}
885+
886+
private DataFlow::Node getASanitizedBuilder() {
887+
result = getABuilderWithExplicitContentType(any(Expr e | isXssSafeContentTypeExpr(e)))
888+
}
889+
890+
private DataFlow::Node getAVulnerableBuilder() {
891+
result = getABuilderWithExplicitContentType(any(Expr e | isXssVulnerableContentTypeExpr(e)))
892+
}
893+
894+
/**
895+
* A response builder sanitized by setting a safe content type.
896+
*
897+
* The content type could be set before the `entity(...)` call that needs sanitizing
898+
* (e.g. `Response.ok().type("application/json").entity(sanitizeMe)`)
899+
* or at the same time (e.g. `Response.ok(sanitizeMe, "application/json")`
900+
* or the content-type could be set afterwards (e.g. `Response.ok().entity(userControlled).type("application/json")`)
901+
*
902+
* This differs from `getASanitizedBuilder` in that we also include functions that must set the entity
903+
* at the same time, or the entity must already have been set, so propagating forwards to sanitize future
904+
* build steps is not necessary.
905+
*/
906+
private class SanitizedResponseBuilder extends XssSanitizer {
907+
SanitizedResponseBuilder() {
908+
// e.g. sanitizeMe.type("application/json")
909+
this = getASanitizedBuilder()
910+
or
911+
this.asExpr() =
912+
any(MethodAccess ma |
913+
ma.getMethod().hasQualifiedName(getAJaxRsPackage("core"), "Response", "ok") and
914+
(
915+
// e.g. Response.ok(sanitizeMe, new Variant("application/json", ...))
916+
ma.getArgument(1) = getASanitizedBuilder().asExpr()
917+
or
918+
// e.g. Response.ok(sanitizeMe, "application/json")
919+
isXssSafeContentTypeExpr(ma.getArgument(1))
920+
)
921+
)
922+
}
923+
}
924+
925+
/**
926+
* An entity call that serves as a sink and barrier because it has a vulnerable content-type set.
927+
*
928+
* We flag these as direct sinks because otherwise it may be sanitized when it reaches a resource
929+
* method with a safe-looking `@Produces` annotation. They are barriers because otherwise if the
930+
* resource method does *not* have a safe-looking `@Produces` annotation then it would be doubly
931+
* reported, once at the `entity(...)` call and once on return from the resource method.
932+
*/
933+
private class VulnerableEntity extends XssSinkBarrier {
934+
VulnerableEntity() {
935+
this.asExpr() =
936+
any(MethodAccess ma |
937+
(
938+
// Vulnerable content-type already set:
939+
ma.getQualifier() = getAVulnerableBuilder().asExpr()
940+
or
941+
// Vulnerable content-type set in the future:
942+
getAVulnerableBuilder().asExpr().(MethodAccess).getQualifier*() = ma
943+
) and
944+
ma.getMethod().hasName("entity")
945+
).getArgument(0)
946+
or
947+
this.asExpr() =
948+
any(MethodAccess ma |
949+
(
950+
isXssVulnerableContentTypeExpr(ma.getArgument(1))
951+
or
952+
ma.getArgument(1) = getAVulnerableBuilder().asExpr()
953+
) and
954+
ma.getMethod().hasName("ok")
955+
).getArgument(0)
956+
}
957+
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ abstract class XssSink extends DataFlow::Node { }
1515
/** A sanitizer that neutralizes dangerous characters that can be used to perform a XSS attack. */
1616
abstract class XssSanitizer extends DataFlow::Node { }
1717

18+
/**
19+
* A sink that represent a method that outputs data without applying contextual output encoding,
20+
* and which should truncate flow paths such that downstream sinks are not flagged as well.
21+
*/
22+
abstract class XssSinkBarrier extends XssSink { }
23+
1824
/**
1925
* A unit class for adding additional taint steps.
2026
*
@@ -132,3 +138,20 @@ class ServletWriterSource extends MethodAccess {
132138
)
133139
}
134140
}
141+
142+
/**
143+
* Holds if `s` is an HTTP Content-Type vulnerable to XSS.
144+
*/
145+
bindingset[s]
146+
predicate isXssVulnerableContentType(string s) {
147+
s.regexpMatch("(?i)text/(html|xml|xsl|rdf|vtt|cache-manifest).*") or
148+
s.regexpMatch("(?i)application/(.*\\+)?xml.*") or
149+
s.regexpMatch("(?i)cache-manifest.*") or
150+
s.regexpMatch("(?i)image/svg\\+xml.*")
151+
}
152+
153+
/**
154+
* Holds if `s` is an HTTP Content-Type that is not vulnerable to XSS.
155+
*/
156+
bindingset[s]
157+
predicate isXssSafeContentType(string s) { not isXssVulnerableContentType(s) }

java/ql/test/library-tests/frameworks/JaxWs/JakartaRs1.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ int Get() { // $ ResourceMethod ResourceMethodOnResourceClass
7171
@Produces("text/html") // $ ProducesAnnotation=text/html
7272
@POST
7373
boolean Post() { // $ ResourceMethod=text/html ResourceMethodOnResourceClass
74-
return false;
74+
return false; // $ XssSink
7575
}
7676

7777
@Produces(MediaType.TEXT_PLAIN) // $ ProducesAnnotation=text/plain

java/ql/test/library-tests/frameworks/JaxWs/JaxRs.ql

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ class JaxRsTest extends InlineExpectationsTest {
2525
element = resourceMethod.toString() and
2626
if exists(resourceMethod.getProducesAnnotation())
2727
then
28-
value = resourceMethod.getProducesAnnotation().getADeclaredContentType() and
28+
value =
29+
getContentTypeString(resourceMethod.getProducesAnnotation().getADeclaredContentTypeExpr()) and
2930
value != ""
3031
else
3132
// Filter out empty strings that stem from using stubs.
@@ -143,7 +144,7 @@ class JaxRsTest extends InlineExpectationsTest {
143144
exists(JaxRSProducesAnnotation producesAnnotation |
144145
producesAnnotation.getLocation() = location and
145146
element = producesAnnotation.toString() and
146-
value = producesAnnotation.getADeclaredContentType() and
147+
value = getContentTypeString(producesAnnotation.getADeclaredContentTypeExpr()) and
147148
value != ""
148149
// Filter out empty strings that stem from using stubs.
149150
// If we built the test against the real JAR then the field

java/ql/test/library-tests/frameworks/JaxWs/JaxRs1.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ int Get() { // $ ResourceMethod ResourceMethodOnResourceClass
7171
@Produces("text/html") // $ ProducesAnnotation=text/html
7272
@POST
7373
boolean Post() { // $ ResourceMethod=text/html ResourceMethodOnResourceClass
74-
return false;
74+
return false; // $ XssSink
7575
}
7676

7777
@Produces(MediaType.TEXT_PLAIN) // $ ProducesAnnotation=text/plain

0 commit comments

Comments
 (0)