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..38b34d8d6c4e 100644 --- a/javascript/ql/lib/semmle/javascript/Regexp.qll +++ b/javascript/ql/lib/semmle/javascript/Regexp.qll @@ -986,6 +986,18 @@ 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("LinkifyIt").getInstance() + 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 +1018,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..b37c0001cdd8 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,29 +1,29 @@ -| 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 | -| tst-IncompleteHostnameRegExp.js:8:3:8:30 | ^http:\\/\\/(.+).example.com\\/ | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:8:2:8:31 | /^http: ... .com\\// | here | -| tst-IncompleteHostnameRegExp.js:8:3:8:30 | ^http:\\/\\/(.+).example.com\\/ | This regular expression has an unrestricted wildcard '.+' which may cause 'example.com' to be matched anywhere in the URL, outside the hostname. | tst-IncompleteHostnameRegExp.js:8:2:8:31 | /^http: ... .com\\// | here | -| tst-IncompleteHostnameRegExp.js:10:3:10:39 | ^http:\\/\\/(?:.+)\\.test\\.example.com\\/ | This regular expression has an unrestricted wildcard '.+' which may cause 'example.com' to be matched anywhere in the URL, outside the hostname. | tst-IncompleteHostnameRegExp.js:10:2:10:40 | /^http: ... .com\\// | here | -| tst-IncompleteHostnameRegExp.js:11:3:11:36 | ^http:\\/\\/test.example.com\\/(?:.*) | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:11:2:11:37 | /^http: ... (?:.*)/ | here | -| tst-IncompleteHostnameRegExp.js:12:14:12:37 | ^http://test.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:12:13:12:38 | "^http: ... le.com" | here | -| tst-IncompleteHostnameRegExp.js:13:15:13:38 | ^http://test.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:13:14:13:39 | "^http: ... le.com" | here | -| tst-IncompleteHostnameRegExp.js:16:23:16:46 | ^http://test.example.com | This string, which is used as a regular expression $@, has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:16:13:16:50 | id(id(i ... com"))) | here | -| tst-IncompleteHostnameRegExp.js:20:18:20:34 | ^test.example.com | This string, which is used as a regular expression $@, has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:21:13:21:26 | `${hostname}$` | here | -| tst-IncompleteHostnameRegExp.js:23:28:23:44 | test.example.com$ | This string, which is used as a regular expression $@, has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:24:13:24:27 | domain.hostname | here | -| tst-IncompleteHostnameRegExp.js:29:24:29:40 | test.example.com$ | This string, which is used as a regular expression $@, has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:27:21:27:35 | domain.hostname | here | -| tst-IncompleteHostnameRegExp.js:31:31:31:47 | test.example.com$ | This string, which is used as a regular expression $@, has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:33:21:33:35 | domain.hostname | here | -| tst-IncompleteHostnameRegExp.js:38:3:38:53 | ^(https?:)?\\/\\/((service\|www).)?example.com(?=$\|\\/) | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:38:2:38:54 | /^(http ... =$\|\\/)/ | here | -| tst-IncompleteHostnameRegExp.js:39:3:39:43 | ^(http\|https):\\/\\/www.example.com\\/p\\/f\\/ | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:39:2:39:44 | /^(http ... p\\/f\\// | here | -| tst-IncompleteHostnameRegExp.js:40:5:40:30 | http:\\/\\/sub.example.com\\/ | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:40:2:40:33 | /^(http ... om\\/)/g | here | -| tst-IncompleteHostnameRegExp.js:41:3:41:29 | ^https?:\\/\\/api.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:41:2:41:30 | /^https ... le.com/ | here | -| tst-IncompleteHostnameRegExp.js:42:42:42:48 | ^https?://.+\\.example\\.com/ | This regular expression has an unrestricted wildcard '.+' which may cause 'example\\.com/' to be matched anywhere in the URL, outside the hostname. | tst-IncompleteHostnameRegExp.js:42:13:42:71 | '^http: ... \\.com/' | here | -| tst-IncompleteHostnameRegExp.js:44:3:44:32 | ^https:\\/\\/[a-z]*.example.com$ | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:44:2:44:33 | /^https ... e.com$/ | here | -| tst-IncompleteHostnameRegExp.js:45:32:45:45 | .+.example.net | This regular expression has an unescaped '.' before 'example.net', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:45:9:45:101 | '^proto ... ernal)' | here | -| tst-IncompleteHostnameRegExp.js:45:47:45:62 | .+.example-a.com | This regular expression has an unescaped '.' before 'example-a.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:45:9:45:101 | '^proto ... ernal)' | here | -| tst-IncompleteHostnameRegExp.js:45:64:45:79 | .+.example-b.com | This regular expression has an unescaped '.' before 'example-b.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:45:9:45:101 | '^proto ... ernal)' | here | -| tst-IncompleteHostnameRegExp.js:49:42:49:47 | ^https?://.+.example\\.com/ | This regular expression has an unescaped '.' before 'example\\.com/', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:49:13:49:69 | '^http: ... \\.com/' | here | -| tst-IncompleteHostnameRegExp.js:49:42:49:47 | ^https?://.+.example\\.com/ | This regular expression has an unrestricted wildcard '.+' which may cause 'example\\.com/' to be matched anywhere in the URL, outside the hostname. | tst-IncompleteHostnameRegExp.js:49:13:49:69 | '^http: ... \\.com/' | here | -| tst-IncompleteHostnameRegExp.js:54:14:54:35 | test.example.com$ | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:54:13:54:36 | 'test.' ... e.com$' | here | -| tst-IncompleteHostnameRegExp.js:56:14:56:38 | ^http://test.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:56:13:56:39 | '^http: ... le.com' | here | -| tst-IncompleteHostnameRegExp.js:60:5:60:20 | foo.example\\.com | This regular expression has an unescaped '.' before 'example\\.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:60:2:60:32 | /^(foo. ... ever)$/ | here | -| tst-IncompleteHostnameRegExp.js:62:18:62:41 | ^http://test.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:62:17:62:42 | "^http: ... le.com" | here | +| tst-IncompleteHostnameRegExp.js:5:3:5: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:5:2:5:29 | /^http: ... le.com/ | here | +| tst-IncompleteHostnameRegExp.js:8:3:8: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:8:2:8:29 | /^http: ... le.net/ | here | +| tst-IncompleteHostnameRegExp.js:9:3:9: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:9:2:9:43 | /^http: ... b).com/ | here | +| tst-IncompleteHostnameRegExp.js:10:3:10:30 | ^http:\\/\\/(.+).example.com\\/ | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:10:2:10:31 | /^http: ... .com\\// | here | +| tst-IncompleteHostnameRegExp.js:10:3:10:30 | ^http:\\/\\/(.+).example.com\\/ | This regular expression has an unrestricted wildcard '.+' which may cause 'example.com' to be matched anywhere in the URL, outside the hostname. | tst-IncompleteHostnameRegExp.js:10:2:10:31 | /^http: ... .com\\// | here | +| tst-IncompleteHostnameRegExp.js:12:3:12:39 | ^http:\\/\\/(?:.+)\\.test\\.example.com\\/ | This regular expression has an unrestricted wildcard '.+' which may cause 'example.com' to be matched anywhere in the URL, outside the hostname. | tst-IncompleteHostnameRegExp.js:12:2:12:40 | /^http: ... .com\\// | here | +| tst-IncompleteHostnameRegExp.js:13:3:13:36 | ^http:\\/\\/test.example.com\\/(?:.*) | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:13:2:13:37 | /^http: ... (?:.*)/ | here | +| tst-IncompleteHostnameRegExp.js:14:14:14:37 | ^http://test.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:14:13:14:38 | "^http: ... le.com" | here | +| tst-IncompleteHostnameRegExp.js:15:15:15:38 | ^http://test.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:15:14:15:39 | "^http: ... le.com" | here | +| tst-IncompleteHostnameRegExp.js:18:23:18:46 | ^http://test.example.com | This string, which is used as a regular expression $@, has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:18:13:18:50 | id(id(i ... com"))) | here | +| tst-IncompleteHostnameRegExp.js:22:18:22:34 | ^test.example.com | This string, which is used as a regular expression $@, has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:23:13:23:26 | `${hostname}$` | here | +| tst-IncompleteHostnameRegExp.js:25:28:25:44 | test.example.com$ | This string, which is used as a regular expression $@, has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:26:13:26:27 | domain.hostname | here | +| tst-IncompleteHostnameRegExp.js:31:24:31:40 | test.example.com$ | This string, which is used as a regular expression $@, has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:29:21:29:35 | domain.hostname | here | +| tst-IncompleteHostnameRegExp.js:33:31:33:47 | test.example.com$ | This string, which is used as a regular expression $@, has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:35:21:35:35 | domain.hostname | here | +| tst-IncompleteHostnameRegExp.js:40:3:40:53 | ^(https?:)?\\/\\/((service\|www).)?example.com(?=$\|\\/) | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:40:2:40:54 | /^(http ... =$\|\\/)/ | here | +| tst-IncompleteHostnameRegExp.js:41:3:41:43 | ^(http\|https):\\/\\/www.example.com\\/p\\/f\\/ | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:41:2:41:44 | /^(http ... p\\/f\\// | here | +| tst-IncompleteHostnameRegExp.js:42:5:42:30 | http:\\/\\/sub.example.com\\/ | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:42:2:42:33 | /^(http ... om\\/)/g | here | +| tst-IncompleteHostnameRegExp.js:43:3:43:29 | ^https?:\\/\\/api.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:43:2:43:30 | /^https ... le.com/ | here | +| tst-IncompleteHostnameRegExp.js:44:42:44:48 | ^https?://.+\\.example\\.com/ | This regular expression has an unrestricted wildcard '.+' which may cause 'example\\.com/' to be matched anywhere in the URL, outside the hostname. | tst-IncompleteHostnameRegExp.js:44:13:44:71 | '^http: ... \\.com/' | here | +| tst-IncompleteHostnameRegExp.js:46:3:46:32 | ^https:\\/\\/[a-z]*.example.com$ | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:46:2:46:33 | /^https ... e.com$/ | here | +| tst-IncompleteHostnameRegExp.js:47:32:47:45 | .+.example.net | This regular expression has an unescaped '.' before 'example.net', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:47:9:47:101 | '^proto ... ernal)' | here | +| tst-IncompleteHostnameRegExp.js:47:47:47:62 | .+.example-a.com | This regular expression has an unescaped '.' before 'example-a.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:47:9:47:101 | '^proto ... ernal)' | here | +| tst-IncompleteHostnameRegExp.js:47:64:47:79 | .+.example-b.com | This regular expression has an unescaped '.' before 'example-b.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:47:9:47:101 | '^proto ... ernal)' | here | +| tst-IncompleteHostnameRegExp.js:51:42:51:47 | ^https?://.+.example\\.com/ | This regular expression has an unescaped '.' before 'example\\.com/', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:51:13:51:69 | '^http: ... \\.com/' | here | +| tst-IncompleteHostnameRegExp.js:51:42:51:47 | ^https?://.+.example\\.com/ | This regular expression has an unrestricted wildcard '.+' which may cause 'example\\.com/' to be matched anywhere in the URL, outside the hostname. | tst-IncompleteHostnameRegExp.js:51:13:51:69 | '^http: ... \\.com/' | here | +| tst-IncompleteHostnameRegExp.js:56:14:56:35 | test.example.com$ | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:56:13:56:36 | 'test.' ... e.com$' | here | +| tst-IncompleteHostnameRegExp.js:58:14:58:38 | ^http://test.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:58:13:58:39 | '^http: ... le.com' | here | +| tst-IncompleteHostnameRegExp.js:62:5:62:20 | foo.example\\.com | This regular expression has an unescaped '.' before 'example\\.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:62:2:62:32 | /^(foo. ... ever)$/ | here | +| tst-IncompleteHostnameRegExp.js:64:18:64:41 | ^http://test.example.com | This regular expression has an unescaped '.' before 'example.com', so it might match more hosts than expected. | tst-IncompleteHostnameRegExp.js:64:17:64:42 | "^http: ... le.com" | here | diff --git a/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/tst-IncompleteHostnameRegExp.js b/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/tst-IncompleteHostnameRegExp.js index b2e030a54921..f2ae3dfcae32 100644 --- a/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/tst-IncompleteHostnameRegExp.js +++ b/javascript/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegExp/tst-IncompleteHostnameRegExp.js @@ -1,3 +1,5 @@ +import { LinkifyIt, linkifyit } from "linkify-it"; + (function() { /^http:\/\/example.com/; /^http:\/\/test.example.com/; // $ Alert @@ -64,4 +66,8 @@ const sinon = require('sinon'); const megacliteUrl = "https://a.b.com"; sinon.assert.calledWith(postStub.firstCall, sinon.match(megacliteUrl)); + + const scanner = new LinkifyIt().add("ftp:", null).set({ fuzzyLink: false }).tlds("onion", true); + if (scanner.match(megacliteUrl)) {} + if (linkifyit().match(megacliteUrl)) {} }); diff --git a/javascript/ql/test/query-tests/Security/CWE-020/MissingRegExpAnchor/tst.js b/javascript/ql/test/query-tests/Security/CWE-020/MissingRegExpAnchor/tst.js index d4e4d97b651e..2a7d19cc076e 100644 --- a/javascript/ql/test/query-tests/Security/CWE-020/MissingRegExpAnchor/tst.js +++ b/javascript/ql/test/query-tests/Security/CWE-020/MissingRegExpAnchor/tst.js @@ -1,6 +1,10 @@ const sinon = require('sinon'); +const { LinkifyIt } = require("linkify-it"); +const legacyLinkifyIt = require("linkify-it"); function testFunction() { const megacliteUrl = "https://a.b.com"; sinon.assert.calledWith(postStub.firstCall, sinon.match(megacliteUrl)); + console.log(new LinkifyIt().set({ fuzzyLink: false }).match(megacliteUrl)); + console.log(legacyLinkifyIt().match(megacliteUrl)); } 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..1f715abc2825 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,13 @@ 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 }); + // OK - LinkifyIt scans text; its argument is not a regular expression. + var matches = scanner.match(text); + res.json(matches); +});