Skip to content

Commit 9e41166

Browse files
committed
JS: Add CaseSensitiveMiddlewarePath query
1 parent d6fd43f commit 9e41166

File tree

6 files changed

+212
-0
lines changed

6 files changed

+212
-0
lines changed

javascript/ql/lib/semmle/javascript/Routing.qll

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,18 @@ module Routing {
148148
this instanceof MkRouter
149149
}
150150

151+
/**
152+
* Like `mayResumeDispatch` but without the assumption that functions with an unknown
153+
* implementation invoke their continuation.
154+
*/
155+
predicate definitelyResumesDispatch() {
156+
this.getLastChild().definitelyResumesDispatch()
157+
or
158+
exists(this.(RouteHandler).getAContinuationInvocation())
159+
or
160+
this instanceof MkRouter
161+
}
162+
151163
/** Gets the parent of this node, provided that this node may invoke its continuation. */
152164
private Node getContinuationParent() {
153165
result = this.getParent() and

javascript/ql/lib/semmle/javascript/frameworks/Express.qll

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ module Express {
3333
or
3434
// `app = [new] express.Router()`
3535
result = DataFlow::moduleMember("express", "Router").getAnInvocation()
36+
or
37+
exists(DataFlow::SourceNode app |
38+
app.hasUnderlyingType("probot/lib/application", "Application") and
39+
result = app.getAMethodCall("route")
40+
)
3641
}
3742

3843
/**
@@ -1043,4 +1048,22 @@ module Express {
10431048

10441049
override DataFlow::SourceNode getOutput() { result = this.getCallback(2).getParameter(1) }
10451050
}
1051+
1052+
private class ResumeDispatchRefinement extends Routing::RouteHandler {
1053+
ResumeDispatchRefinement() { getFunction() instanceof RouteHandler }
1054+
1055+
override predicate mayResumeDispatch() { getAParameter().getName() = "next" }
1056+
1057+
override predicate definitelyResumesDispatch() { getAParameter().getName() = "next" }
1058+
}
1059+
1060+
private class ExpressStaticResumeDispatchRefinement extends Routing::Node {
1061+
ExpressStaticResumeDispatchRefinement() {
1062+
this = Routing::getNode(DataFlow::moduleMember("express", "static").getACall())
1063+
}
1064+
1065+
override predicate mayResumeDispatch() { none() }
1066+
1067+
override predicate definitelyResumesDispatch() { none() }
1068+
}
10461069
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* @name Case-sensitive middleware path
3+
* @description Middleware with case-sensitive paths do not protect endpoints with case-insensitive paths
4+
* @kind problem
5+
* @problem.severity warning
6+
* @security-severity 7.3
7+
* @precision high
8+
* @id js/case-sensitive-middleware-path
9+
* @tags security
10+
* external/cwe/cwe-178
11+
*/
12+
13+
import javascript
14+
15+
/**
16+
* Converts `s` to upper case, or to lower-case if it was already upper case.
17+
*/
18+
bindingset[s]
19+
string invertCase(string s) {
20+
if s.regexpMatch(".*[a-z].*") then result = s.toUpperCase() else result = s.toLowerCase()
21+
}
22+
23+
/**
24+
* Holds if `term` distinguishes between upper and lower case letters, assuming the `i` flag is not present.
25+
*/
26+
pragma[inline]
27+
predicate isCaseSensitiveRegExp(RegExpTerm term) {
28+
exists(RegExpConstant const |
29+
const = term.getAChild*() and
30+
const.getValue().regexpMatch(".*[a-zA-Z].*") and
31+
not const.getParent().(RegExpCharacterClass).getAChild().(RegExpConstant).getValue() =
32+
invertCase(const.getValue()) and
33+
not const.getParent*() instanceof RegExpNegativeLookahead and
34+
not const.getParent*() instanceof RegExpNegativeLookbehind
35+
)
36+
}
37+
38+
/**
39+
* Gets a string matched by `term`, or part of such a string.
40+
*/
41+
string getExampleString(RegExpTerm term) {
42+
result = term.getAMatchedString()
43+
or
44+
// getAMatchedString does not recurse into sequences. Perform one step manually.
45+
exists(RegExpSequence seq | seq = term |
46+
result =
47+
strictconcat(RegExpTerm child, int i, string text |
48+
child = seq.getChild(i) and
49+
(
50+
text = child.getAMatchedString()
51+
or
52+
not exists(child.getAMatchedString()) and
53+
text = ""
54+
)
55+
|
56+
text order by i
57+
)
58+
)
59+
}
60+
61+
string getCaseSensitiveBypassExample(RegExpTerm term) {
62+
result = invertCase(getExampleString(term)) and
63+
result != ""
64+
}
65+
66+
/**
67+
* Holds if `setup` has a path-argument `arg` referring to the given case-sensitive `regexp`.
68+
*/
69+
predicate isCaseSensitiveMiddleware(
70+
Routing::RouteSetup setup, DataFlow::RegExpCreationNode regexp, DataFlow::Node arg
71+
) {
72+
exists(DataFlow::MethodCallNode call |
73+
setup = Routing::getRouteSetupNode(call) and
74+
(
75+
setup.definitelyResumesDispatch()
76+
or
77+
// If applied to all HTTP methods, be a bit more lenient in detecting middleware
78+
setup.mayResumeDispatch() and
79+
not exists(setup.getOwnHttpMethod())
80+
) and
81+
arg = call.getArgument(0) and
82+
regexp.getAReference().flowsTo(arg) and
83+
isCaseSensitiveRegExp(regexp.getRoot()) and
84+
exists(string flags |
85+
flags = regexp.getFlags() and
86+
not flags.matches("%i%")
87+
)
88+
)
89+
}
90+
91+
predicate isGuardedCaseInsensitiveEndpoint(
92+
Routing::RouteSetup endpoint, Routing::RouteSetup middleware
93+
) {
94+
isCaseSensitiveMiddleware(middleware, _, _) and
95+
exists(DataFlow::MethodCallNode call |
96+
endpoint = Routing::getRouteSetupNode(call) and
97+
endpoint.isGuardedByNode(middleware) and
98+
call.getArgument(0).mayHaveStringValue(_)
99+
)
100+
}
101+
102+
from
103+
DataFlow::RegExpCreationNode regexp, Routing::RouteSetup middleware, Routing::RouteSetup endpoint,
104+
DataFlow::Node arg, string example
105+
where
106+
isCaseSensitiveMiddleware(middleware, regexp, arg) and
107+
example = getCaseSensitiveBypassExample(regexp.getRoot()) and
108+
isGuardedCaseInsensitiveEndpoint(endpoint, middleware) and
109+
exists(endpoint.getRelativePath().toLowerCase().indexOf(example.toLowerCase()))
110+
select arg,
111+
"This route uses a case-sensitive path $@, but is guarding a case-insensitive path $@. A path such as '"
112+
+ example + "' will bypass the middleware.", regexp, "pattern", endpoint, "here"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
| tst.js:8:9:8:19 | /\\/foo\\/.*/ | This route uses a case-sensitive path $@, but is guarding a case-insensitive path $@. A path such as '/FOO/' will bypass the middleware. | tst.js:8:9:8:19 | /\\/foo\\/.*/ | pattern | tst.js:60:1:61:2 | app.get ... ware\\n}) | here |
2+
| tst.js:14:5:14:28 | new Reg ... (.*)?') | This route uses a case-sensitive path $@, but is guarding a case-insensitive path $@. A path such as '/FOO' will bypass the middleware. | tst.js:14:5:14:28 | new Reg ... (.*)?') | pattern | tst.js:60:1:61:2 | app.get ... ware\\n}) | here |
3+
| tst.js:41:9:41:25 | /\\/foo\\/([0-9]+)/ | This route uses a case-sensitive path $@, but is guarding a case-insensitive path $@. A path such as '/FOO/' will bypass the middleware. | tst.js:41:9:41:25 | /\\/foo\\/([0-9]+)/ | pattern | tst.js:60:1:61:2 | app.get ... ware\\n}) | here |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Security/CWE-178/CaseSensitiveMiddlewarePath.ql
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
const express = require('express');
2+
const app = express();
3+
const unknown = require('~something/blah');
4+
5+
app.all(/\/.*/, unknown()); // OK - does not contain letters
6+
app.all(/\/.*/i, unknown()); // OK
7+
8+
app.all(/\/foo\/.*/, unknown()); // NOT OK
9+
app.all(/\/foo\/.*/i, unknown()); // OK - case insensitive
10+
11+
app.use(/\/x\/#\d{6}/, express.static('images/')); // OK - not a middleware
12+
13+
app.get(
14+
new RegExp('^/foo(.*)?'), // NOT OK - case sensitive
15+
unknown(),
16+
function(req, res, next) {
17+
if (req.params.blah) {
18+
next();
19+
}
20+
}
21+
);
22+
23+
app.get(
24+
new RegExp('^/foo(.*)?', 'i'), // OK - case insensitive
25+
unknown(),
26+
function(req, res, next) {
27+
if (req.params.blah) {
28+
next();
29+
}
30+
}
31+
);
32+
33+
app.get(
34+
new RegExp('^/foo(.*)?'), // OK - not a middleware
35+
unknown(),
36+
function(req,res) {
37+
res.send('Hello World!');
38+
}
39+
);
40+
41+
app.use(/\/foo\/([0-9]+)/, (req, res, next) => { // NOT OK - case sensitive
42+
unknown(req);
43+
next();
44+
});
45+
46+
app.use(/\/foo\/([0-9]+)/i, (req, res, next) => { // OK - case insensitive
47+
unknown(req);
48+
next();
49+
});
50+
51+
52+
app.use(/\/foo\/([0-9]+)/, (req, res) => { // OK - not middleware
53+
unknown(req, res);
54+
});
55+
56+
app.use(/\/foo\/([0-9]+)/i, (req, res) => { // OK - not middleware (also case insensitive)
57+
unknown(req, res);
58+
});
59+
60+
app.get('/foo/:param', (req, res) => { // OK - not a middleware
61+
});

0 commit comments

Comments
 (0)