Skip to content

Commit 4f34bff

Browse files
Add no-super-linear-backtracking rule (#242)
1 parent 8231401 commit 4f34bff

File tree

8 files changed

+331
-1
lines changed

8 files changed

+331
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
109109
| [regexp/no-lazy-ends](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-lazy-ends.html) | disallow lazy quantifiers at the end of an expression | |
110110
| [regexp/no-optional-assertion](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-optional-assertion.html) | disallow optional assertions | |
111111
| [regexp/no-potentially-useless-backreference](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-potentially-useless-backreference.html) | disallow backreferences that reference a group that might not be matched | |
112+
| [regexp/no-super-linear-backtracking](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-super-linear-backtracking.html) | disallow exponential and polynomial backtracking | :wrench: |
112113
| [regexp/no-useless-assertions](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-assertions.html) | disallow assertions that are known to always accept (or reject) | |
113114
| [regexp/no-useless-backreference](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-backreference.html) | disallow useless backreferences in regular expressions | :star: |
114115
| [regexp/no-useless-dollar-replacements](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-dollar-replacements.html) | disallow useless `$` replacements in replacement string | |

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
2323
| [regexp/no-lazy-ends](./no-lazy-ends.md) | disallow lazy quantifiers at the end of an expression | |
2424
| [regexp/no-optional-assertion](./no-optional-assertion.md) | disallow optional assertions | |
2525
| [regexp/no-potentially-useless-backreference](./no-potentially-useless-backreference.md) | disallow backreferences that reference a group that might not be matched | |
26+
| [regexp/no-super-linear-backtracking](./no-super-linear-backtracking.md) | disallow exponential and polynomial backtracking | :wrench: |
2627
| [regexp/no-useless-assertions](./no-useless-assertions.md) | disallow assertions that are known to always accept (or reject) | |
2728
| [regexp/no-useless-backreference](./no-useless-backreference.md) | disallow useless backreferences in regular expressions | :star: |
2829
| [regexp/no-useless-dollar-replacements](./no-useless-dollar-replacements.md) | disallow useless `$` replacements in replacement string | |
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/no-super-linear-backtracking"
5+
description: "disallow exponential and polynomial backtracking"
6+
---
7+
# regexp/no-super-linear-backtracking
8+
9+
> disallow exponential and polynomial backtracking
10+
11+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
12+
- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
13+
14+
## :book: Rule Details
15+
16+
This rule reports cases of exponential and polynomial backtracking.
17+
18+
These types of backtracking almost always cause an exponential or polynomial worst-case runtime. This super-linear worst-case runtime can be exploited by attackers in what is called [Regular expression Denial of Service - ReDoS][1].
19+
20+
<eslint-code-block fix>
21+
22+
```js
23+
/* eslint regexp/no-super-linear-backtracking: "error" */
24+
25+
/* ✓ GOOD */
26+
var foo = /a*b+a*$/;
27+
var foo = /(?:a+)?/;
28+
29+
/* ✗ BAD */
30+
var foo = /(?:a+)+$/;
31+
var foo = /a*b?a*$/;
32+
var foo = /(?:a|b|c+)*$/;
33+
// not all cases can automatically be fixed
34+
var foo = /\s*(.*?)(?=:)/;
35+
var foo = /.+?(?=\s*=)/;
36+
```
37+
38+
</eslint-code-block>
39+
40+
### Limitations
41+
42+
The rule only implements a very simplistic detection method and can only detect very simple cases of super-linear backtracking right now.
43+
44+
While the detection will improve in the future, this rule will never be able to perfectly detect all cases super-linear backtracking.
45+
46+
47+
## :wrench: Options
48+
49+
```json
50+
{
51+
"regexp/no-super-linear-backtracking": ["error", {
52+
"report": "certain"
53+
}]
54+
}
55+
```
56+
57+
### `report`
58+
59+
Every input string that exploits super-linear worst-case runtime can be separated into 3 parts:
60+
61+
1. A prefix to leads to exploitable part of the regex.
62+
2. A non-empty string that will be repeated to exploit the ambiguity.
63+
3. A rejecting suffix that forces the regex engine to backtrack.
64+
65+
For some regexes it is not possible to find a rejecting suffix even though the regex contains exploitable ambiguity (e.g. `/(?:a+)+/`). These regexes are safe as long as they are used as is. However, regexes can also be used as building blocks to create more complex regexes. In this case, the ambiguity might cause super-linear backtracking in the composite regex.
66+
67+
This options control whether ambiguity that might cause super-linear backtracking will be reported.
68+
69+
- `report: "certain"` (_default_)
70+
71+
Only certain cases of super-linear backtracking will be reported.
72+
73+
This means that ambiguity will only be reported if this rule can prove that there exists a rejecting suffix.
74+
75+
- `report: "potential"`
76+
77+
All certain and potential cases of super-linear backtracking will be reported.
78+
79+
Potential cases are ones where a rejecting might be possible. Whether the reported potential cases are false positives or not has to be decided by the developer.
80+
81+
## :books: Further reading
82+
83+
- [Regular expression Denial of Service - ReDoS][1]
84+
- [scslre]
85+
86+
[1]: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
87+
[scslre]: https://github.com/RunDevelopment/scslre
88+
89+
## :mag: Implementation
90+
91+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/no-super-linear-backtracking.ts)
92+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/no-super-linear-backtracking.ts)
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import type { RegExpVisitor } from "regexpp/visitor"
2+
import type { RegExpContext } from "../utils"
3+
import { createRule, defineRegexpVisitor } from "../utils"
4+
import { UsageOfPattern } from "../utils/get-usage-of-pattern"
5+
import type { ParsedLiteral } from "scslre"
6+
import { analyse } from "scslre"
7+
import type { Position, SourceLocation } from "estree"
8+
9+
/**
10+
* Returns the combined source location of the two given locations.
11+
*/
12+
function unionLocations(a: SourceLocation, b: SourceLocation): SourceLocation {
13+
/** x < y */
14+
function less(x: Position, y: Position): boolean {
15+
if (x.line < y.line) {
16+
return true
17+
} else if (x.line > y.line) {
18+
return false
19+
}
20+
return x.column < y.column
21+
}
22+
23+
return {
24+
start: { ...(less(a.start, b.start) ? a.start : b.start) },
25+
end: { ...(less(a.end, b.end) ? b.end : a.end) },
26+
}
27+
}
28+
29+
/**
30+
* Create a parsed literal object as required by the scslre library.
31+
*/
32+
function getParsedLiteral(context: RegExpContext): ParsedLiteral {
33+
const { flags, flagsString, patternAst } = context
34+
35+
return {
36+
pattern: patternAst,
37+
flags: {
38+
type: "Flags",
39+
raw: flagsString ?? "",
40+
parent: null,
41+
start: NaN,
42+
end: NaN,
43+
dotAll: flags.dotAll ?? false,
44+
global: flags.dotAll ?? false,
45+
ignoreCase: flags.dotAll ?? false,
46+
multiline: flags.dotAll ?? false,
47+
sticky: flags.dotAll ?? false,
48+
unicode: flags.dotAll ?? false,
49+
},
50+
}
51+
}
52+
53+
export default createRule("no-super-linear-backtracking", {
54+
meta: {
55+
docs: {
56+
description: "disallow exponential and polynomial backtracking",
57+
category: "Possible Errors",
58+
// TODO Switch to recommended in the major version.
59+
// recommended: true,
60+
recommended: false,
61+
},
62+
fixable: "code",
63+
schema: [
64+
{
65+
type: "object",
66+
properties: {
67+
report: {
68+
enum: ["certain", "potential"],
69+
},
70+
},
71+
additionalProperties: false,
72+
},
73+
],
74+
messages: {
75+
self:
76+
"This quantifier can reach itself via the loop '{{parent}}'." +
77+
" Using any string accepted by {{attack}}, this can be exploited to cause at least polynomial backtracking." +
78+
"{{exp}}",
79+
trade:
80+
"The quantifier '{{start}}' can exchange characters with '{{end}}'." +
81+
" Using any string accepted by {{attack}}, this can be exploited to cause at least polynomial backtracking." +
82+
"{{exp}}",
83+
},
84+
type: "problem",
85+
},
86+
create(context) {
87+
const reportUncertain =
88+
(context.options[0]?.report ?? "certain") === "potential"
89+
90+
/**
91+
* Create visitor
92+
*/
93+
function createVisitor(
94+
regexpContext: RegExpContext,
95+
): RegExpVisitor.Handlers {
96+
const {
97+
node,
98+
patternAst,
99+
flags,
100+
getRegexpLocation,
101+
fixReplaceNode,
102+
getUsageOfPattern,
103+
} = regexpContext
104+
105+
const result = analyse(getParsedLiteral(regexpContext), {
106+
reportTypes: { Move: false },
107+
assumeRejectingSuffix:
108+
reportUncertain &&
109+
getUsageOfPattern() !== UsageOfPattern.whole,
110+
})
111+
112+
for (const report of result.reports) {
113+
const exp = report.exponential
114+
? " This is going to cause exponential backtracking resulting in exponential worst-case runtime behavior."
115+
: getUsageOfPattern() !== UsageOfPattern.whole
116+
? " This might cause exponential backtracking."
117+
: ""
118+
119+
const attack = `/${report.character.literal.source}+/${
120+
flags.ignoreCase ? "i" : ""
121+
}`
122+
123+
const fix = fixReplaceNode(
124+
patternAst,
125+
() => report.fix()?.source ?? null,
126+
)
127+
128+
if (report.type === "Self") {
129+
context.report({
130+
node,
131+
loc: getRegexpLocation(report.quant),
132+
messageId: "self",
133+
data: {
134+
exp,
135+
attack,
136+
parent: report.parentQuant.raw,
137+
},
138+
fix,
139+
})
140+
} else if (report.type === "Trade") {
141+
context.report({
142+
node,
143+
loc: unionLocations(
144+
getRegexpLocation(report.startQuant),
145+
getRegexpLocation(report.endQuant),
146+
),
147+
messageId: "trade",
148+
data: {
149+
exp,
150+
attack,
151+
start: report.startQuant.raw,
152+
end: report.endQuant.raw,
153+
},
154+
fix,
155+
})
156+
}
157+
}
158+
159+
return {}
160+
}
161+
162+
return defineRegexpVisitor(context, {
163+
createVisitor,
164+
})
165+
},
166+
})

lib/utils/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import noOctal from "../rules/no-octal"
2222
import noOptionalAssertion from "../rules/no-optional-assertion"
2323
import noPotentiallyUselessBackreference from "../rules/no-potentially-useless-backreference"
2424
import noStandaloneBackslash from "../rules/no-standalone-backslash"
25+
import noSuperLinearBacktracking from "../rules/no-super-linear-backtracking"
2526
import noTriviallyNestedAssertion from "../rules/no-trivially-nested-assertion"
2627
import noTriviallyNestedQuantifier from "../rules/no-trivially-nested-quantifier"
2728
import noUnusedCapturingGroup from "../rules/no-unused-capturing-group"
@@ -87,6 +88,7 @@ export const rules = [
8788
noOptionalAssertion,
8889
noPotentiallyUselessBackreference,
8990
noStandaloneBackslash,
91+
noSuperLinearBacktracking,
9092
noTriviallyNestedAssertion,
9193
noTriviallyNestedQuantifier,
9294
noUnusedCapturingGroup,

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"jsdoctypeparser": "^9.0.0",
9191
"refa": "^0.8.0",
9292
"regexp-ast-analysis": "^0.2.2",
93-
"regexpp": "^3.1.0"
93+
"regexpp": "^3.1.0",
94+
"scslre": "^0.1.5"
9495
}
9596
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { RuleTester } from "eslint"
2+
import rule from "../../../lib/rules/no-super-linear-backtracking"
3+
4+
const tester = new RuleTester({
5+
parserOptions: {
6+
ecmaVersion: 2020,
7+
sourceType: "module",
8+
},
9+
})
10+
11+
tester.run("no-super-linear-backtracking", rule as any, {
12+
valid: [
13+
String.raw`/regexp/`,
14+
String.raw`/a+b+a+b+/`,
15+
String.raw`/\w+\b[\w-]+/`,
16+
],
17+
invalid: [
18+
// self
19+
{
20+
code: String.raw`/b(?:a+)+b/`,
21+
output: String.raw`/ba+b/`,
22+
errors: [
23+
"This quantifier can reach itself via the loop '(?:a+)+'. Using any string accepted by /a+/, this can be exploited to cause at least polynomial backtracking. This is going to cause exponential backtracking resulting in exponential worst-case runtime behavior.",
24+
],
25+
},
26+
{
27+
code: String.raw`/(?:ba+|a+b){2}/`,
28+
output: null,
29+
errors: [
30+
"The quantifier 'a+' can exchange characters with 'a+'. Using any string accepted by /a+/, this can be exploited to cause at least polynomial backtracking. This might cause exponential backtracking.",
31+
],
32+
},
33+
34+
// trade
35+
{
36+
code: String.raw`/\ba+a+$/`,
37+
output: String.raw`/\ba{2,}$/`,
38+
errors: [
39+
"The quantifier 'a+' can exchange characters with 'a+'. Using any string accepted by /a+/, this can be exploited to cause at least polynomial backtracking. This might cause exponential backtracking.",
40+
],
41+
},
42+
{
43+
code: String.raw`/\b\w+a\w+$/`,
44+
output: String.raw`/\b\w[\dA-Z_b-z]*a\w+$/`,
45+
errors: [
46+
"The quantifier '\\w+' can exchange characters with '\\w+'. Using any string accepted by /a+/, this can be exploited to cause at least polynomial backtracking. This might cause exponential backtracking.",
47+
],
48+
},
49+
{
50+
code: String.raw`/\b\w+a?b{4}\w+$/`,
51+
output: null,
52+
errors: [
53+
"The quantifier '\\w+' can exchange characters with '\\w+'. Using any string accepted by /b+/, this can be exploited to cause at least polynomial backtracking. This might cause exponential backtracking.",
54+
],
55+
},
56+
],
57+
})

0 commit comments

Comments
 (0)