Skip to content

Commit 855d4c2

Browse files
authored
Merge pull request #9718 from asgerf/js/case-sensitive-middleware
JS: Add 'case sensitive middleware' query
2 parents 43a8200 + 18c5a8c commit 855d4c2

File tree

11 files changed

+306
-0
lines changed

11 files changed

+306
-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() { this.getFunction() instanceof RouteHandler }
1054+
1055+
override predicate mayResumeDispatch() { this.getAParameter().getName() = "next" }
1056+
1057+
override predicate definitelyResumesDispatch() { this.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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
6+
<overview>
7+
<p>
8+
Using a case-sensitive regular expression path in a middleware route enables an attacker to bypass that middleware
9+
when accessing an endpoint with a case-insensitive path.
10+
Paths specified using a string are case-insensitive, whereas regular expressions are case-sensitive by default.
11+
</p>
12+
</overview>
13+
14+
<recommendation>
15+
<p>
16+
When using a regular expression as a middleware path, make sure the regular expression is
17+
case-insensitive by adding the <code>i</code> flag.
18+
</p>
19+
</recommendation>
20+
21+
<example>
22+
<p>
23+
The following example restricts access to paths in the <code>/admin</code> path to users logged in as
24+
administrators:
25+
</p>
26+
<sample src="examples/CaseSensitiveMiddlewarePath.js" />
27+
<p>
28+
A path such as <code>/admin/users/45</code> can only be accessed by an administrator. However, the path
29+
<code>/ADMIN/USERS/45</code> can be accessed by anyone because the upper-case path doesn't match the case-sensitive regular expression, whereas
30+
Express considers it to match the path string <code>/admin/users</code>.
31+
</p>
32+
<p>
33+
The issue can be fixed by adding the <code>i</code> flag to the regular expression:
34+
</p>
35+
<sample src="examples/CaseSensitiveMiddlewarePathGood.js" />
36+
</example>
37+
38+
<references>
39+
<li>
40+
MDN
41+
<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#advanced_searching_with_flags">Regular Expression Flags</a>.
42+
</li>
43+
</references>
44+
</qhelp>
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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 toOtherCase(string s) {
20+
if s.regexpMatch(".*[a-z].*") then result = s.toUpperCase() else result = s.toLowerCase()
21+
}
22+
23+
RegExpCharacterClass getEnclosingClass(RegExpTerm term) {
24+
term = result.getAChild()
25+
or
26+
term = result.getAChild().(RegExpRange).getAChild()
27+
}
28+
29+
/**
30+
* Holds if `term` seems to distinguish between upper and lower case letters, assuming the `i` flag is not present.
31+
*/
32+
pragma[inline]
33+
predicate isLikelyCaseSensitiveRegExp(RegExpTerm term) {
34+
exists(RegExpConstant const |
35+
const = term.getAChild*() and
36+
const.getValue().regexpMatch(".*[a-zA-Z].*") and
37+
not getEnclosingClass(const).getAChild().(RegExpConstant).getValue() =
38+
toOtherCase(const.getValue()) and
39+
not const.getParent*() instanceof RegExpNegativeLookahead and
40+
not const.getParent*() instanceof RegExpNegativeLookbehind
41+
)
42+
}
43+
44+
/**
45+
* Gets a string matched by `term`, or part of such a string.
46+
*/
47+
string getExampleString(RegExpTerm term) {
48+
result = term.getAMatchedString()
49+
or
50+
// getAMatchedString does not recurse into sequences. Perform one step manually.
51+
exists(RegExpSequence seq | seq = term |
52+
result =
53+
strictconcat(RegExpTerm child, int i, string text |
54+
child = seq.getChild(i) and
55+
(
56+
text = child.getAMatchedString()
57+
or
58+
not exists(child.getAMatchedString()) and
59+
text = ""
60+
)
61+
|
62+
text order by i
63+
)
64+
)
65+
}
66+
67+
string getCaseSensitiveBypassExample(RegExpTerm term) {
68+
exists(string example |
69+
example = getExampleString(term) and
70+
result = toOtherCase(example) and
71+
result != example // getting an example string is approximate; ensure we got a proper case-change example
72+
)
73+
}
74+
75+
/**
76+
* Holds if `setup` has a path-argument `arg` referring to the given case-sensitive `regexp`.
77+
*/
78+
predicate isCaseSensitiveMiddleware(
79+
Routing::RouteSetup setup, DataFlow::RegExpCreationNode regexp, DataFlow::Node arg
80+
) {
81+
exists(DataFlow::MethodCallNode call |
82+
setup = Routing::getRouteSetupNode(call) and
83+
(
84+
setup.definitelyResumesDispatch()
85+
or
86+
// If applied to all HTTP methods, be a bit more lenient in detecting middleware
87+
setup.mayResumeDispatch() and
88+
not exists(setup.getOwnHttpMethod())
89+
) and
90+
arg = call.getArgument(0) and
91+
regexp.getAReference().flowsTo(arg) and
92+
isLikelyCaseSensitiveRegExp(regexp.getRoot()) and
93+
exists(string flags |
94+
flags = regexp.getFlags() and
95+
not RegExp::isIgnoreCase(flags)
96+
)
97+
)
98+
}
99+
100+
predicate isGuardedCaseInsensitiveEndpoint(
101+
Routing::RouteSetup endpoint, Routing::RouteSetup middleware
102+
) {
103+
isCaseSensitiveMiddleware(middleware, _, _) and
104+
exists(DataFlow::MethodCallNode call |
105+
endpoint = Routing::getRouteSetupNode(call) and
106+
endpoint.isGuardedByNode(middleware) and
107+
call.getArgument(0).mayHaveStringValue(_)
108+
)
109+
}
110+
111+
from
112+
DataFlow::RegExpCreationNode regexp, Routing::RouteSetup middleware, Routing::RouteSetup endpoint,
113+
DataFlow::Node arg, string example
114+
where
115+
isCaseSensitiveMiddleware(middleware, regexp, arg) and
116+
example = getCaseSensitiveBypassExample(regexp.getRoot()) and
117+
isGuardedCaseInsensitiveEndpoint(endpoint, middleware) and
118+
exists(endpoint.getRelativePath().toLowerCase().indexOf(example.toLowerCase()))
119+
select arg,
120+
"This route uses a case-sensitive path $@, but is guarding a case-insensitive path $@. A path such as '"
121+
+ example + "' will bypass the middleware.", regexp, "pattern", endpoint, "here"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const app = require('express')();
2+
3+
app.use(/\/admin\/.*/, (req, res, next) => {
4+
if (!req.user.isAdmin) {
5+
res.status(401).send('Unauthorized');
6+
} else {
7+
next();
8+
}
9+
});
10+
11+
app.get('/admin/users/:id', (req, res) => {
12+
res.send(app.database.users[req.params.id]);
13+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const app = require('express')();
2+
3+
app.use(/\/admin\/.*/i, (req, res, next) => {
4+
if (!req.user.isAdmin) {
5+
res.status(401).send('Unauthorized');
6+
} else {
7+
next();
8+
}
9+
});
10+
11+
app.get('/admin/users/:id', (req, res) => {
12+
res.send(app.database.users[req.params.id]);
13+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
category: newQuery
3+
---
4+
5+
- A new query "Case-sensitive middleware path" (`js/case-sensitive-middleware-path`) has been added.
6+
It highlights middleware routes that can be bypassed due to having a case-sensitive regular expression path.
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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const express = require('express');
2+
const app = express();
3+
4+
app.get(/\/[a-zA-Z]+/, (req, res, next) => { // OK - regexp term is case insensitive
5+
next();
6+
});
7+
8+
app.get('/foo', (req, res) => {
9+
});

0 commit comments

Comments
 (0)