Skip to content

Commit 5040d3e

Browse files
committed
JS: add query for loop index bug
1 parent a4b3b1e commit 5040d3e

8 files changed

+362
-0
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
<overview>
6+
<p>
7+
Items can be removed from an array using the <code>splice</code> method, but when doing so,
8+
all subseequent items will be shifted to a lower index. If this is done while iterating over
9+
the array, the shifting may cause the loop to skip over the element immediately after the
10+
removed element.
11+
</p>
12+
13+
</overview>
14+
<recommendation>
15+
16+
<p>
17+
Determine what the loop is supposed to do:
18+
<ul>
19+
<li>
20+
If the intention is to remove <em>every occurence</em> of a certain value, decrement the loop counter after removing an element, to counterbalance
21+
the shift.
22+
</li>
23+
<li>
24+
If the loop is only intended to remove <em>a single value</em> from the array, consider adding a <code>break</code> after the <code>splice</code> call.
25+
</li>
26+
<li>
27+
If the loop is deliberately skipping over elements, consider moving the index increment into the body of the loop,
28+
so it is clear that the loop is not a trivial array iteration loop.
29+
</li>
30+
</ul>
31+
</p>
32+
33+
</recommendation>
34+
<example>
35+
36+
<p>
37+
In this example, a function is intended to remove "<code>..</code>" parts from a path:
38+
</p>
39+
40+
<sample src="examples/MisleadingIndentationAfterControlStmt.js" />
41+
42+
<p>
43+
However, whenever the input contain two "<code>..</code>" parts right after one another, only the first will be removed.
44+
For example, the string "<code>../../secret.txt</code>" will be mapped to "<code>../secret.txt</code>". After removing
45+
the element at index 0, the loop counter is incremented to 1, but the second "<code>..</code>" string has now been shifted down to
46+
index 0 and will therefore be skipped.
47+
</p>
48+
49+
<p>
50+
One way to avoid this is to decrement the loop counter after removing an element from the array:
51+
</p>
52+
53+
<sample src="examples/MisleadingIndentationAfterControlStmtGood.js" />
54+
55+
<p>
56+
Alternatively, use the <code>filter</code> method:
57+
</p>
58+
59+
<sample src="examples/MisleadingIndentationAfterControlStmtGoodFilter.js" />
60+
61+
</example>
62+
<references>
63+
64+
<li>MDN: <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice">Array.prototype.splice()</a>.</li>
65+
<li>MDN: <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter">Array.prototype.filter()</a>.</li>
66+
67+
</references>
68+
</qhelp>
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* @name Missing index adjustment after concurrent modification
3+
* @description Removing elements from an array while iterating over it can cause the loop to skip over some elements,
4+
* unless the loop index is decremented accordingly.
5+
* @kind problem
6+
* @problem.severity warning
7+
* @id js/missing-index-adjustment-after-concurrent-modification
8+
* @tags correctness
9+
* @precision high
10+
*/
11+
import javascript
12+
13+
/**
14+
* Operation that inserts or removes elements from an array while shifting all elements
15+
* occuring after the insertion/removal point.
16+
*
17+
* Does not include `push` and `pop` since these never shift any elements.
18+
*/
19+
class ArrayShiftingCall extends DataFlow::MethodCallNode {
20+
string name;
21+
22+
ArrayShiftingCall() {
23+
name = getMethodName() and
24+
(name = "splice" or name = "shift" or name = "unshift")
25+
}
26+
27+
DataFlow::SourceNode getArray() {
28+
result = getReceiver().getALocalSource()
29+
}
30+
}
31+
32+
/**
33+
* A call to `splice` on an array.
34+
*/
35+
class SpliceCall extends ArrayShiftingCall {
36+
SpliceCall() {
37+
name = "splice"
38+
}
39+
40+
/**
41+
* Gets the index from which elements are removed and possibly new elemenst are inserted.
42+
*/
43+
DataFlow::Node getIndex() {
44+
result = getArgument(0)
45+
}
46+
47+
/**
48+
* Gets the number of removed elements.
49+
*/
50+
int getNumRemovedElements() {
51+
result = getArgument(1).asExpr().getIntValue() and
52+
result >= 0
53+
}
54+
55+
/**
56+
* Gets the number of inserted elements.
57+
*/
58+
int getNumInsertedElements() {
59+
result = getNumArgument() - 2 and
60+
result >= 0
61+
}
62+
}
63+
64+
/**
65+
* A `for` loop iterating over the indices of an array, in increasing order.
66+
*/
67+
class ArrayIterationLoop extends ForStmt {
68+
DataFlow::SourceNode array;
69+
LocalVariable indexVariable;
70+
71+
ArrayIterationLoop() {
72+
exists (RelationalComparison compare | compare = getTest() |
73+
compare.getLesserOperand() = indexVariable.getAnAccess() and
74+
compare.getGreaterOperand() = array.getAPropertyRead("length").asExpr()) and
75+
getUpdate().(IncExpr).getOperand() = indexVariable.getAnAccess()
76+
}
77+
78+
/**
79+
* Gets the variable holding the loop variable and current array index.
80+
*/
81+
LocalVariable getIndexVariable() {
82+
result = indexVariable
83+
}
84+
85+
/**
86+
* Gets the loop entry point.
87+
*/
88+
ReachableBasicBlock getLoopEntry() {
89+
result = getTest().getFirstControlFlowNode().getBasicBlock()
90+
}
91+
92+
/**
93+
* Gets a call that potentially shifts the elements of the given array.
94+
*/
95+
ArrayShiftingCall getAnArrayShiftingCall() {
96+
result.getArray() = array
97+
}
98+
99+
/**
100+
* Gets a call to `splice` that removes elements from the looped-over array at the current index
101+
*
102+
* The `splice` call is not guaranteed to be inside the loop body.
103+
*/
104+
SpliceCall getACandidateSpliceCall() {
105+
result = getAnArrayShiftingCall() and
106+
result.getIndex().asExpr() = getIndexVariable().getAnAccess() and
107+
result.getNumRemovedElements() > result.getNumInsertedElements()
108+
}
109+
110+
/**
111+
* Holds if `cfg` modifies the index variable or shifts array elements, disturbing the
112+
* relationship between the array and the index variable.
113+
*/
114+
predicate hasIndexingManipulation(ControlFlowNode cfg) {
115+
cfg.(VarDef).getAVariable() = getIndexVariable() or
116+
cfg = getAnArrayShiftingCall().asExpr()
117+
}
118+
119+
/**
120+
* Holds if there is a `loop entry -> cfg` path that does not involve index manipulation or a successful index equality check.
121+
*/
122+
predicate hasPathTo(ControlFlowNode cfg) {
123+
exists(getACandidateSpliceCall()) and // restrict size of predicate
124+
cfg = getLoopEntry().getFirstNode()
125+
or
126+
hasPathTo(cfg.getAPredecessor()) and
127+
getLoopEntry().dominates(cfg.getBasicBlock()) and
128+
not hasIndexingManipulation(cfg) and
129+
// Ignore splice calls guarded by an index equality check.
130+
// This indicates that the index of an element is the basis for removal, not its value,
131+
// which means it may be okay to skip over elements.
132+
not exists (ConditionGuardNode guard, EqualityTest test | cfg = guard |
133+
test = guard.getTest() and
134+
test.getAnOperand() = getIndexVariable().getAnAccess() and
135+
guard.getOutcome() = test.getPolarity())
136+
}
137+
138+
/**
139+
* Holds if there is a `loop entry -> splice -> cfg` path that does not involve index manipulation,
140+
* other than the `splice` call.
141+
*/
142+
predicate hasPathThrough(SpliceCall splice, ControlFlowNode cfg) {
143+
splice = getACandidateSpliceCall() and
144+
cfg = splice.asExpr() and
145+
hasPathTo(cfg.getAPredecessor())
146+
or
147+
hasPathThrough(splice, cfg.getAPredecessor()) and
148+
getLoopEntry().dominates(cfg.getBasicBlock()) and
149+
not hasIndexingManipulation(cfg)
150+
}
151+
}
152+
153+
from ArrayIterationLoop loop, SpliceCall splice
154+
where loop.hasPathThrough(splice, loop.getUpdate().getFirstControlFlowNode())
155+
select splice, "Missing loop index adjustment after removing array item. Some array items will be skipped due to shifting."
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
function removePathTraversal(path) {
2+
let parts = path.split('/');
3+
for (let i = 0; i < parts.length; ++i) {
4+
if (parts[i] === '..') {
5+
parts.splice(i, 1);
6+
}
7+
}
8+
return path.join('/');
9+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
function removePathTraversal(path) {
2+
let parts = path.split('/');
3+
for (let i = 0; i < parts.length; ++i) {
4+
if (parts[i] === '..') {
5+
parts.splice(i, 1);
6+
--i; // adjust for array shift
7+
}
8+
}
9+
return path.join('/');
10+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
function removePathTraversal(path) {
2+
return path.split('/').filter(part => part !== '..').join('/');
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
| tst.js:4:27:4:44 | parts.splice(i, 1) | Missing loop index adjustment after removing array item. Some array items will be skipped due to shifting. |
2+
| tst.js:13:29:13:46 | parts.splice(i, 1) | Missing loop index adjustment after removing array item. Some array items will be skipped due to shifting. |
3+
| tst.js:24:9:24:26 | parts.splice(i, 1) | Missing loop index adjustment after removing array item. Some array items will be skipped due to shifting. |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Statements/MissingIndexAdjustmentAfterConcurrentModification.ql
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
function removeX(string) {
2+
let parts = string.split('/');
3+
for (let i = 0; i < parts.length; ++i) {
4+
if (parts[i] === 'X') parts.splice(i, 1); // NOT OK
5+
}
6+
return parts.join('/');
7+
}
8+
9+
function removeXInnerLoop(string, n) {
10+
let parts = string.split('/');
11+
for (let j = 0; j < n; ++j) {
12+
for (let i = 0; i < parts.length; ++i) {
13+
if (parts[i] === 'X') parts.splice(i, 1); // NOT OK
14+
}
15+
}
16+
return parts.join('/');
17+
}
18+
19+
function removeXOuterLoop(string, n) {
20+
let parts = string.split('/');
21+
for (let i = 0; i < parts.length; ++i) {
22+
for (let j = 0; j < n; ++j) {
23+
if (parts[i] === 'X') {
24+
parts.splice(i, 1); // NOT OK
25+
break;
26+
}
27+
}
28+
}
29+
return parts.join('/');
30+
}
31+
32+
function decrementAfter(string) {
33+
let parts = string.split('/');
34+
for (let i = 0; i < parts.length; ++i) {
35+
if (parts[i] === 'X') {
36+
parts.splice(i, 1); // OK
37+
--i;
38+
}
39+
}
40+
return parts.join('/');
41+
}
42+
43+
function postDecrementArgument(string) {
44+
let parts = string.split('/');
45+
for (let i = 0; i < parts.length; ++i) {
46+
if (parts[i] === 'X') {
47+
parts.splice(i--, 1); // OK
48+
}
49+
}
50+
return parts.join('/');
51+
}
52+
53+
54+
function breakAfter(string) {
55+
let parts = string.split('/');
56+
for (let i = 0; i < parts.length; ++i) {
57+
if (parts[i] === 'X') {
58+
parts.splice(i, 1); // OK - only removes first occurrence
59+
break;
60+
}
61+
}
62+
return parts.join('/');
63+
}
64+
65+
function insertNewElements(string) {
66+
let parts = string.split('/');
67+
for (let i = 0; i < parts.length; ++i) {
68+
if (parts[i] === 'X') {
69+
parts.splice(i, 1, '.'); // OK - no shifting due to insert
70+
}
71+
}
72+
return parts.join('/');
73+
}
74+
75+
function spliceAfterLoop(string) {
76+
let parts = string.split('/');
77+
let i = 0;
78+
for (; i < parts.length; ++i) {
79+
if (parts[i] === 'X') break;
80+
}
81+
if (parts[i] === 'X') {
82+
parts.splice(i, 1); // OK - not inside loop
83+
}
84+
return parts.join('/');
85+
}
86+
87+
function spliceAfterLoopNested(string) {
88+
let parts = string.split('/');
89+
for (let j = 0; j < parts.length; ++j) {
90+
let i = j;
91+
for (; i < parts.length; ++i) {
92+
if (parts[i] === 'X') break;
93+
}
94+
parts.splice(i, 1); // OK - not inside 'i' loop
95+
}
96+
return parts.join('/');
97+
}
98+
99+
function removeAtSpecificPlace(string, k) {
100+
let parts = string.split('/');
101+
for (let i = 0; i < parts.length; ++i) {
102+
if (i === k && parts[i] === 'X') parts.splice(i, 1); // OK - more complex logic
103+
}
104+
return parts.join('/');
105+
}
106+
107+
function removeFirstAndLast(string) {
108+
let parts = string.split('/');
109+
for (let i = 0; i < parts.length; ++i) {
110+
if (i === 0 || i === parts.length - 1) parts.splice(i, 1); // OK - out of scope of this query
111+
}
112+
return parts.join('/');
113+
}

0 commit comments

Comments
 (0)