diff --git a/javascript/ql/lib/change-notes/2026-09-15-linkify-it-match.md b/javascript/ql/lib/change-notes/2026-09-15-linkify-it-match.md new file mode 100644 index 000000000000..dcd038ebf6bf --- /dev/null +++ b/javascript/ql/lib/change-notes/2026-09-15-linkify-it-match.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* Calls to `LinkifyIt.match()` are no longer incorrectly identified as regular expression operations. diff --git a/javascript/ql/lib/semmle/javascript/Regexp.qll b/javascript/ql/lib/semmle/javascript/Regexp.qll index c42df3939c22..3aebc78b17ed 100644 --- a/javascript/ql/lib/semmle/javascript/Regexp.qll +++ b/javascript/ql/lib/semmle/javascript/Regexp.qll @@ -986,6 +986,22 @@ private predicate isMatchObjectProperty(string name) { name in ["length", "index", "input", "groups"] } +/** Gets an API node representing a `LinkifyIt` instance. */ +private API::Node linkifyItInstance() { + result = API::moduleImport("linkify-it").getMember("exports").getMember("LinkifyIt").getInstance() + or + result = API::moduleImport("linkify-it").getMember("LinkifyIt").getInstance() + or + result = API::moduleImport("linkify-it").getMember("exports").getMember("linkifyit").getReturn() + or + result = API::moduleImport("linkify-it").getMember("linkifyit").getReturn() + or + // Before version 6, the module export was the factory function. + result = API::moduleImport("linkify-it").getReturn() + or + result = linkifyItInstance().getMember(["add", "set", "tlds"]).getReturn() +} + /** Holds if `call` is a call to `match` whose result is used in a way that is incompatible with Match objects. */ overlay[global] private predicate isUsedAsNonMatchObject(DataFlow::MethodCallNode call) { @@ -1006,6 +1022,8 @@ private predicate isUsedAsNonMatchObject(DataFlow::MethodCallNode call) { call.asExpr() = any(ExprStmt stmt).getExpr() or call = API::moduleImport("sinon").getMember("match").getACall() + or + call = linkifyItInstance().getMember("match").getACall() ) } diff --git a/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/IncompleteHostnameRegExp.expected b/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/IncompleteHostnameRegExp.expected index 90d4e925d21e..018ad74dc576 100644 --- a/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/IncompleteHostnameRegExp.expected +++ b/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/IncompleteHostnameRegExp.expected @@ -1,3 +1,4 @@ +| linkify-it/tst-LinkifyIt.js:21:25:21:48 | ^https://www.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | linkify-it/tst-LinkifyIt.js:21:24:21:49 | "^https ... le.com" | here | | tst-IncompleteHostnameRegExp.js:3:3:3:28 | ^http:\\/\\/test.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:3:2:3:29 | /^http: ... le.com/ | here | | tst-IncompleteHostnameRegExp.js:6:3:6:28 | ^http:\\/\\/test.example.net | This regular expression has an unescaped '.' before 'example.net', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:6:2:6:29 | /^http: ... le.net/ | here | | tst-IncompleteHostnameRegExp.js:7:3:7:42 | ^http:\\/\\/test.(example-a\|example-b).com | This regular expression has an unescaped '.' before '(example-a\|example-b).com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:7:2:7:43 | /^http: ... b).com/ | here | diff --git a/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/linkify-it/package.json b/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/linkify-it/package.json new file mode 100644 index 000000000000..c677a4136597 --- /dev/null +++ b/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/linkify-it/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "dependencies": { + "linkify-it": "6.1.0" + } +} diff --git a/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/linkify-it/tst-LinkifyIt.js b/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/linkify-it/tst-LinkifyIt.js new file mode 100644 index 000000000000..018014b40ee8 --- /dev/null +++ b/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/linkify-it/tst-LinkifyIt.js @@ -0,0 +1,21 @@ +import { LinkifyIt, linkifyit } from "linkify-it"; +import { LinkifyIt as OtherLinkifyIt } from "other-linkify-it"; + +const scanner = new LinkifyIt({ fuzzyLink: false, fuzzyEmail: false }) + .add("ftp:", null) + .add("mailto:", null) + .add("//", null); +const text = + "😀 *literal* (https://www.youtube.com/watch?v=tax4e4hBBZc), then https://store.steampowered.com/app/457140/."; +const matches = scanner.match(text); +if (matches) { + console.log(matches.map((match) => match.raw)); +} + +if (new LinkifyIt().match("https://www.example.com")) {} +if (new LinkifyIt().set({ fuzzyLink: false }).match("https://www.example.com")) {} +if (new LinkifyIt().tlds("onion", true).match("https://www.example.com")) {} +if (linkifyit().match("https://www.example.com")) {} + +const otherScanner = new OtherLinkifyIt().add("ftp:", null); +if (otherScanner.match("^https://www.example.com")) {} // $ Alert diff --git a/javascript/ql/test/query-tests/Security/CWE-020/MissingRegExpAnchor/tst-LinkifyIt.js b/javascript/ql/test/query-tests/Security/CWE-020/MissingRegExpAnchor/tst-LinkifyIt.js new file mode 100644 index 000000000000..cb2c51142166 --- /dev/null +++ b/javascript/ql/test/query-tests/Security/CWE-020/MissingRegExpAnchor/tst-LinkifyIt.js @@ -0,0 +1,7 @@ +const { LinkifyIt } = require("linkify-it"); +const legacyLinkifyIt = require("linkify-it"); + +const scanner = new LinkifyIt().add("ftp:", null).set({ fuzzyLink: false }); +const text = "https://a.b.com"; +console.log(scanner.match(text)); +console.log(legacyLinkifyIt().match(text)); diff --git a/javascript/ql/test/query-tests/Security/CWE-730/Threat-models-disabled/RegExpInjectionGood.js b/javascript/ql/test/query-tests/Security/CWE-730/Threat-models-disabled/RegExpInjectionGood.js index 8222104109ce..d2d2722eb1a9 100644 --- a/javascript/ql/test/query-tests/Security/CWE-730/Threat-models-disabled/RegExpInjectionGood.js +++ b/javascript/ql/test/query-tests/Security/CWE-730/Threat-models-disabled/RegExpInjectionGood.js @@ -9,3 +9,12 @@ app.get('/findKey', function(req, res) { var safeKey = _.escapeRegExp(key); var re = new RegExp("\\b" + safeKey + "=(.*)\n"); }); + +var { LinkifyIt } = require("linkify-it"); + +app.get('/findLinks', function(req, res) { + var text = req.param("text"); + var scanner = new LinkifyIt().set({ fuzzyLink: false }); + var matches = scanner.match(text); + res.json(matches); +});