Skip to content

Commit d05b11f

Browse files
authored
Merge pull request #587 from asger-semmle/incorrect-suffix-check
Approved by mc-semmle, xiemaisi
2 parents 0ba7633 + 7121a18 commit d05b11f

File tree

11 files changed

+327
-1
lines changed

11 files changed

+327
-1
lines changed

change-notes/1.20/analysis-javascript.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
| **Query** | **Tags** | **Purpose** |
1111
|-----------------------------------------------|------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1212
| Double escaping or unescaping (`js/double-escaping`) | correctness, security, external/cwe/cwe-116 | Highlights potential double escaping or unescaping of special characters, indicating a possible violation of [CWE-116](https://cwe.mitre.org/data/definitions/116.html). Results are shown on LGTM by default. |
13-
| Useless comparison test (`js/useless-comparison-test`) | correctness | Highlights code that is unreachable due to a numeric comparison that is always true or always false. |
13+
| Incorrect suffix check (`js/incorrect-suffix-check`) | correctness, security, external/cwe/cwe-020 | Highlights error-prone suffix checks based on `indexOf`, indicating a potential violation of [CWE-20](https://cwe.mitre.org/data/definitions/20.html). Results are shown on LGTM by default. |
14+
| Useless comparison test (`js/useless-comparison-test`) | correctness | Highlights code that is unreachable due to a numeric comparison that is always true or always false. Results are shown on LGTM by default. |
1415

1516
## Changes to existing queries
1617

javascript/config/suites/javascript/security

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
+ semmlecode-javascript-queries/DOM/TargetBlank.ql: /Security/CWE/CWE-200
22
+ semmlecode-javascript-queries/Electron/EnablingNodeIntegration.ql: /Security/CWE/CWE-094
3+
+ semmlecode-javascript-queries/Security/CWE-020/IncorrectSuffixCheck.ql: /Security/CWE/CWE-020
34
+ semmlecode-javascript-queries/Security/CWE-022/TaintedPath.ql: /Security/CWE/CWE-022
45
+ semmlecode-javascript-queries/Security/CWE-078/CommandInjection.ql: /Security/CWE/CWE-078
56
+ semmlecode-javascript-queries/Security/CWE-079/ReflectedXss.ql: /Security/CWE/CWE-079
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
6+
<overview>
7+
<p>
8+
9+
The <code>indexOf</code> and <code>lastIndexOf</code> methods are sometimes used to check
10+
if a substring occurs at a certain position in a string. However, if the returned index
11+
is compared to an expression that might evaluate to -1, the check may pass in some
12+
cases where the substring was not found at all.
13+
14+
</p>
15+
<p>
16+
17+
Specifically, this can easily happen when implementing <code>endsWith</code> using
18+
<code>indexOf</code>.
19+
20+
</p>
21+
</overview>
22+
23+
<recommendation>
24+
25+
<p>
26+
27+
Use <code>String.prototype.endsWith</code> if it is available.
28+
Otherwise, explicitly handle the -1 case, either by checking the relative
29+
lengths of the strings, or by checking if the returned index is -1.
30+
31+
</p>
32+
33+
</recommendation>
34+
35+
<example>
36+
37+
<p>
38+
39+
The following example uses <code>lastIndexOf</code> to determine if the string <code>x</code>
40+
ends with the string <code>y</code>:
41+
42+
</p>
43+
44+
<sample src="examples/IncorrectSuffixCheck.js"/>
45+
46+
<p>
47+
48+
However, if <code>y</code> is one character longer than <code>x</code>, the right-hand side
49+
<code>x.length - y.length</code> becomes -1, which then equals the return value
50+
of <code>lastIndexOf</code>. This will make the test pass, even though <code>x</code> does not
51+
end with <code>y</code>.
52+
53+
</p>
54+
55+
<p>
56+
57+
To avoid this, explicitly check for the -1 case:
58+
59+
</p>
60+
61+
<sample src="examples/IncorrectSuffixCheckGood.js"/>
62+
63+
64+
</example>
65+
66+
<references>
67+
68+
<li>MDN: <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith">String.prototype.endsWith</a></li>
69+
<li>MDN: <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/indexOf">String.prototype.indexOf</a></li>
70+
71+
</references>
72+
</qhelp>
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* @name Incorrect suffix check
3+
* @description Using indexOf to implement endsWith functionality is error-prone if the -1 case is not explicitly handled.
4+
* @kind problem
5+
* @problem.severity error
6+
* @precision high
7+
* @id js/incorrect-suffix-check
8+
* @tags security
9+
* correctness
10+
* external/cwe/cwe-020
11+
*/
12+
import javascript
13+
14+
/**
15+
* A call to `indexOf` or `lastIndexOf`.
16+
*/
17+
class IndexOfCall extends DataFlow::MethodCallNode {
18+
IndexOfCall() {
19+
exists (string name | name = getMethodName() |
20+
name = "indexOf" or
21+
name = "lastIndexOf") and
22+
getNumArgument() = 1
23+
}
24+
25+
/** Gets the receiver or argument of this call. */
26+
DataFlow::Node getAnOperand() {
27+
result = getReceiver() or
28+
result = getArgument(0)
29+
}
30+
31+
/**
32+
* Gets an `indexOf` call with the same receiver, argument, and method name, including this call itself.
33+
*/
34+
IndexOfCall getAnEquivalentIndexOfCall() {
35+
result.getReceiver().getALocalSource() = this.getReceiver().getALocalSource() and
36+
result.getArgument(0).getALocalSource() = this.getArgument(0).getALocalSource() and
37+
result.getMethodName() = this.getMethodName()
38+
}
39+
40+
/**
41+
* Gets an expression that refers to the return value of this call.
42+
*/
43+
Expr getAUse() {
44+
this.flowsToExpr(result)
45+
}
46+
}
47+
48+
/**
49+
* Gets a source of the given string value, or one of its operands if it is a concatenation.
50+
*/
51+
DataFlow::SourceNode getStringSource(DataFlow::Node node) {
52+
result = node.getALocalSource()
53+
or
54+
result = StringConcatenation::getAnOperand(node).getALocalSource()
55+
}
56+
57+
/**
58+
* An expression that takes the length of a string literal.
59+
*/
60+
class LiteralLengthExpr extends DotExpr {
61+
LiteralLengthExpr() {
62+
getPropertyName() = "length" and
63+
getBase() instanceof StringLiteral
64+
}
65+
66+
/**
67+
* Gets the value of the string literal whose length is taken.
68+
*/
69+
string getBaseValue() {
70+
result = getBase().getStringValue()
71+
}
72+
}
73+
74+
/**
75+
* Holds if `length` is derived from the length of the given `indexOf`-operand.
76+
*/
77+
predicate isDerivedFromLength(DataFlow::Node length, DataFlow::Node operand) {
78+
exists (IndexOfCall call | operand = call.getAnOperand() |
79+
length = getStringSource(operand).getAPropertyRead("length")
80+
or
81+
// Find a literal length with the same string constant
82+
exists (LiteralLengthExpr lengthExpr |
83+
lengthExpr.getContainer() = call.getContainer() and
84+
lengthExpr.getBaseValue() = operand.asExpr().getStringValue() and
85+
length = lengthExpr.flow())
86+
or
87+
// Find an integer constants that equals the length of string constant
88+
exists (Expr lengthExpr |
89+
lengthExpr.getContainer() = call.getContainer() and
90+
lengthExpr.getIntValue() = operand.asExpr().getStringValue().length() and
91+
length = lengthExpr.flow())
92+
)
93+
or
94+
isDerivedFromLength(length.getAPredecessor(), operand)
95+
or
96+
exists (SubExpr sub |
97+
isDerivedFromLength(sub.getAnOperand().flow(), operand) and
98+
length = sub.flow())
99+
}
100+
101+
/**
102+
* An equality comparison of form `A.indexOf(B) === A.length - B.length` or similar.
103+
*
104+
* We assume A and B are strings, even A and/or B could be also be arrays.
105+
* The comparison with the length rarely occurs for arrays, however.
106+
*/
107+
class UnsafeIndexOfComparison extends EqualityTest {
108+
IndexOfCall indexOf;
109+
DataFlow::Node testedValue;
110+
111+
UnsafeIndexOfComparison() {
112+
hasOperands(indexOf.getAUse(), testedValue.asExpr()) and
113+
isDerivedFromLength(testedValue, indexOf.getReceiver()) and
114+
isDerivedFromLength(testedValue, indexOf.getArgument(0)) and
115+
116+
// Ignore cases like `x.indexOf("/") === x.length - 1` that can only be bypassed if `x` is the empty string.
117+
// Sometimes strings are just known to be non-empty from the context, and it is unlikely to be a security issue,
118+
// since it's obviously not a domain name check.
119+
not indexOf.getArgument(0).mayHaveStringValue(any(string s | s.length() = 1)) and
120+
121+
// Relative string length comparison, such as A.length > B.length, or (A.length - B.length) > 0
122+
not exists (RelationalComparison compare |
123+
isDerivedFromLength(compare.getAnOperand().flow(), indexOf.getReceiver()) and
124+
isDerivedFromLength(compare.getAnOperand().flow(), indexOf.getArgument(0))
125+
) and
126+
127+
// Check for indexOf being -1
128+
not exists (EqualityTest test, Expr minusOne |
129+
test.hasOperands(indexOf.getAnEquivalentIndexOfCall().getAUse(), minusOne) and
130+
minusOne.getIntValue() = -1
131+
) and
132+
133+
// Check for indexOf being >1, or >=0, etc
134+
not exists (RelationalComparison test |
135+
test.getGreaterOperand() = indexOf.getAnEquivalentIndexOfCall().getAUse() and
136+
exists (int value | value = test.getLesserOperand().getIntValue() |
137+
value >= 0
138+
or
139+
not test.isInclusive() and
140+
value = -1)
141+
)
142+
}
143+
144+
IndexOfCall getIndexOf() {
145+
result = indexOf
146+
}
147+
}
148+
149+
from UnsafeIndexOfComparison comparison
150+
select comparison, "This suffix check is missing a length comparison to correctly handle " + comparison.getIndexOf().getMethodName() + " returning -1."
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
function endsWith(x, y) {
2+
return x.lastIndexOf(y) === x.length - y.length;
3+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
function endsWith(x, y) {
2+
let index = x.lastIndexOf(y);
3+
return index !== -1 && index === x.length - y.length;
4+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
| examples/IncorrectSuffixCheck.js:2:10:2:49 | x.lastI ... .length | This suffix check is missing a length comparison to correctly handle lastIndexOf returning -1. |
2+
| tst.js:2:10:2:45 | x.index ... .length | This suffix check is missing a length comparison to correctly handle indexOf returning -1. |
3+
| tst.js:9:10:9:55 | x.index ... gth - 1 | This suffix check is missing a length comparison to correctly handle indexOf returning -1. |
4+
| tst.js:17:10:17:31 | x.index ... = delta | This suffix check is missing a length comparison to correctly handle indexOf returning -1. |
5+
| tst.js:25:10:25:69 | x.index ... .length | This suffix check is missing a length comparison to correctly handle indexOf returning -1. |
6+
| tst.js:32:10:32:51 | x.index ... th - 11 | This suffix check is missing a length comparison to correctly handle indexOf returning -1. |
7+
| tst.js:39:10:39:49 | x.lastI ... .length | This suffix check is missing a length comparison to correctly handle lastIndexOf returning -1. |
8+
| tst.js:55:32:55:71 | x.index ... gth - 1 | This suffix check is missing a length comparison to correctly handle indexOf returning -1. |
9+
| tst.js:67:32:67:71 | x.index ... gth - 1 | This suffix check is missing a length comparison to correctly handle indexOf returning -1. |
10+
| tst.js:76:25:76:57 | index = ... gth - 1 | This suffix check is missing a length comparison to correctly handle indexOf returning -1. |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Security/CWE-020/IncorrectSuffixCheck.ql
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
function endsWith(x, y) {
2+
return x.lastIndexOf(y) === x.length - y.length;
3+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
function endsWith(x, y) {
2+
let index = x.lastIndexOf(y);
3+
return index !== -1 && index === x.length - y.length;
4+
}

0 commit comments

Comments
 (0)