Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
category: minorAnalysis
---
* Regular expression checks using multiline (`m`) or unknown flags are no longer treated as generic taint sanitizers just because the pattern is anchored with `^` and `$`. This can reveal previously missed alerts, including command injection and reflected cross-site scripting.
28 changes: 20 additions & 8 deletions javascript/ql/lib/semmle/javascript/Regexp.qll
Original file line number Diff line number Diff line change
Expand Up @@ -1275,16 +1275,26 @@ module RegExp {
bindingset[flags]
predicate maybeDotAll(string flags) { flags = unknownFlag() or isDotAll(flags) }

/** Holds if `term` and all of its disjuncts are anchored on both ends. */
/**
* Holds if `term` and all of its disjuncts are anchored to both ends of the input.
*
* Does not hold if a regular expression using the term has multiline or unknown flags.
*/
predicate isFullyAnchoredTerm(RegExpTerm term) {
exists(RegExpSequence seq | term = seq |
seq.getChild(0) instanceof RegExpCaret and
seq.getLastChild() instanceof RegExpDollar
not exists(DataFlow::RegExpCreationNode regexp |
regexp.getRoot() = term.getRootTerm() and
maybeMultiline(regexp.tryGetFlags())
) and
(
exists(RegExpSequence seq | term = seq |
seq.getChild(0) instanceof RegExpCaret and
seq.getLastChild() instanceof RegExpDollar
)
or
isFullyAnchoredTerm(term.(RegExpGroup).getAChild())
or
isFullyAnchoredAlt(term, term.getNumChild())
)
or
isFullyAnchoredTerm(term.(RegExpGroup).getAChild())
or
isFullyAnchoredAlt(term, term.getNumChild())
}

/** Holds if the first `i` disjuncts of `term` are fully anchored. */
Expand Down Expand Up @@ -1330,6 +1340,8 @@ module RegExp {
*
* Specifically, whitelisting regexps such as `^(foo|bar)$` sanitize matches in the true case.
* Inverted character classes such as `[^a-z]` or `\W` sanitize matches in the false case.
* Anchored terms do not sanitize matches if a regular expression using the term has
* multiline or unknown flags.
*/
predicate isGenericRegExpSanitizer(RegExpTerm term, boolean outcome) {
term.isRootTerm() and
Expand Down
22 changes: 22 additions & 0 deletions javascript/ql/test/library-tests/RegExpSanitizer/decorators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Matches } from "class-validator";

class Validated {
@Matches(/^\w+$/) // $ sanitizer
singleLine: string;

@Matches(/^\w+$/m)
multiline: string;

@Matches(new RegExp("^\\w+$")) // $ sanitizer
constructedSingleLine: string;

@Matches(new RegExp("^\\w+$", "m"))
constructedMultiline: string;

@Matches("^\\w+$", "i") // $ sanitizer
stringSingleLine: string;

// Separate flags for string patterns are not currently covered by the sanitizer model.
@Matches("^\\w+$", "m") // $ SPURIOUS: sanitizer
stringMultiline: string;
}
123 changes: 123 additions & 0 deletions javascript/ql/test/library-tests/RegExpSanitizer/guards.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
function literals() {
const value = source();
sink(value); // $ flow

if (/^[\w.-]+$/m.test(value)) {
sink(value); // $ flow
}
if (/^[\w.-]+$/.test(value)) {
sink(value);
} else {
sink(value); // $ flow
}
if (/^[\w.-]+$/i.test(value)) {
sink(value);
}
if (/^[\w.-]+$/s.test(value)) {
sink(value);
}
if (/^[\w.-]+$/m.exec(value)) {
sink(value); // $ flow
}
if (/^[\w.-]+$/.exec(value)) {
sink(value);
}
if (value.match(/^[\w.-]+$/m)) {
sink(value); // $ flow
}
if (value.match(/^[\w.-]+$/)) {
sink(value);
}
if (value.match("^[\\w.-]+$")) {
sink(value);
}
if (value.matchAll(/^[\w.-]+$/gm)) {
sink(value); // $ flow
}
}

function references() {
const value = source();
const regexp = /^[\w.-]+$/m;
if (regexp.test(value)) {
sink(value); // $ flow
}
let match;
if (match = regexp.exec(value)) {
sink(value); // $ flow
}
}

function constructors(flags) {
const value = source();
if (new RegExp("^[\\w.-]+$", "m").test(value)) {
sink(value); // $ flow
}
if (RegExp("^[\\w.-]+$", "im").test(value)) {
sink(value); // $ flow
}
const multiline = "m";
if (new RegExp("^[\\w.-]+$", multiline).test(value)) {
sink(value); // $ flow
}
if (new RegExp("^[\\w.-]+$", flags).test(value)) {
sink(value); // $ flow
}
if (new RegExp("^[\\w.-]+$").test(value)) {
sink(value);
}
if (new RegExp("^[\\w.-]+$", "").test(value)) {
sink(value);
}
if (new RegExp("^[\\w.-]+$", "i").test(value)) {
sink(value);
}
if (new RegExp(/^[\w.-]+$/m).test(value)) {
sink(value); // $ flow
}
if (new RegExp(/^[\w.-]+$/, "m").test(value)) {
sink(value); // $ flow
}
}

function overriddenFlags() {
const value = source();
// Explicit overrides removing a literal's `m` flag are not currently covered by the sanitizer model.
if (new RegExp(/^[\w.-]+$/m, "").test(value)) {
sink(value); // $ SPURIOUS: flow
}
if (new RegExp(/^[\w.-]+$/m, "i").test(value)) {
sink(value); // $ SPURIOUS: flow
}
}

function groupsAndAlternatives() {
const value = source();
if (/(^[\w.-]+$)/m.test(value)) {
sink(value); // $ flow
}
if (/(^[\w.-]+$)/.test(value)) {
sink(value);
}
if (/^foo$|^bar$/m.test(value)) {
sink(value); // $ flow
}
if (/^foo$|^bar$/.test(value)) {
sink(value);
}
}

function negativeChecks(flags) {
const value = source();
if (/[^a-z]/m.test(value)) {
sink(value); // $ flow
} else {
sink(value);
}
if (!/\W/m.test(value)) {
sink(value);
}
if (!new RegExp("[^a-z]", flags).test(value)) {
sink(value);
}
}
36 changes: 36 additions & 0 deletions javascript/ql/test/library-tests/RegExpSanitizer/routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const cp = require("child_process");
const app = require("express")();

app.get("/a", (req, res) => {
const dir = req.query.dir;
if (/^[\w.-]+$/m.test(dir)) {
cp.exec("ls " + dir); // $ command-injection
}
res.send("ok");
});

app.get("/b", (req, res) => {
const dir = req.query.dir;
if (/^[\w.-]+$/.test(dir)) {
cp.exec("ls " + dir);
}
res.send("ok");
});

app.get("/c", (req, res) => {
cp.exec("ls " + req.query.dir); // $ command-injection
res.send("ok");
});

app.get("/d", (req, res) => {
const name = req.query.name;
if (/^\w+$/m.test(name)) {
res.send("<p>Hello " + name + "</p>"); // $ reflected-xss
} else {
res.send("bad name");
}
});

app.get("/e", (req, res) => {
res.send("<p>Hello " + req.query.name + "</p>"); // $ reflected-xss
});
Empty file.
40 changes: 40 additions & 0 deletions javascript/ql/test/library-tests/RegExpSanitizer/test.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import javascript
import utils.test.InlineExpectationsTest
import semmle.javascript.frameworks.ClassValidator
import semmle.javascript.security.dataflow.CommandInjectionQuery as CommandInjection
import semmle.javascript.security.dataflow.ReflectedXssQuery as ReflectedXss

module TestConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
source.(DataFlow::CallNode).getCalleeName() = "source"
}

predicate isSink(DataFlow::Node sink) {
sink = any(DataFlow::CallNode call | call.getCalleeName() = "sink").getArgument(0)
}
}

module TestFlow = TaintTracking::Global<TestConfig>;

module InlineTest implements TestSig {
string getARelevantTag() { result = ["flow", "command-injection", "reflected-xss", "sanitizer"] }

predicate hasActualResult(Location location, string element, string tag, string value) {
element = "" and
value = "" and
exists(DataFlow::Node node |
location = node.getLocation() and
(
tag = "flow" and TestFlow::flowTo(node)
or
tag = "command-injection" and CommandInjection::CommandInjectionFlow::flowTo(node)
or
tag = "reflected-xss" and ReflectedXss::ReflectedXssFlow::flowTo(node)
or
tag = "sanitizer" and node = ClassValidator::sanitizingDecorator()
)
)
}
}

import MakeTest<InlineTest>
Loading