From 2959ed3af91acefaec4fda170459106509f6f3dd Mon Sep 17 00:00:00 2001 From: Keshav Malik Date: Thu, 15 Oct 2026 00:53:01 +0530 Subject: [PATCH] Go: Handle quoted dots in hostname regexps --- .../CWE-020/IncompleteHostnameRegexp.ql | 35 ++++++++- .../2026-10-14-hostname-regexp-quoting.md | 4 + .../IncompleteHostnameRegexp.expected | 60 ++++++++++++++- .../quoted-hostnames.go | 77 +++++++++++++++++++ 4 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 go/ql/src/change-notes/2026-10-14-hostname-regexp-quoting.md create mode 100644 go/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegexp/quoted-hostnames.go diff --git a/go/ql/src/Security/CWE-020/IncompleteHostnameRegexp.ql b/go/ql/src/Security/CWE-020/IncompleteHostnameRegexp.ql index a6321b7d7cb3..ab10b7569bc2 100644 --- a/go/ql/src/Security/CWE-020/IncompleteHostnameRegexp.ql +++ b/go/ql/src/Security/CWE-020/IncompleteHostnameRegexp.ql @@ -14,6 +14,25 @@ import go +/** Escapes dots within quoted sections for the hostname pattern check. */ +bindingset[pattern] +private string escapeQuotedDots(string pattern) { + if not pattern.matches("%\\Q%") + then result = pattern + else + result = + concat(string part, string escapedPart, int i | + // Consume quoted sections before other escapes. Outside quoting, paired + // backslashes cannot open a section; inside, the first \E closes it. + part = pattern.regexpFind("\\\\Q[\\s\\S]*?(?:\\\\E|\\z)|\\\\[^Q]|[^\\\\]+|\\\\\\z", i, _) and + if part.prefix(2) = "\\Q" + then escapedPart = part.replaceAll(".", "\\.") + else escapedPart = part + | + escapedPart, "" order by i + ) +} + /** * Holds if `pattern` is a regular expression pattern for URLs with a host matched by `hostPart`, * and `pattern` contains a subtle mistake that allows it to match unexpected hosts. @@ -114,7 +133,21 @@ import Flow::PathGraph from Flow::PathNode source, Flow::PathNode sink, string hostPart where Flow::flowPath(source, sink) and - IncompleteHostNameRegexpConfig::isSourceString(source.getNode(), hostPart) + // A preceding concatenation can escape a quote opener in a source fragment. + // Interpret quoting only when the complete pattern is known. + if + exists(string pattern | + pattern = sink.getNode().(RegexpPattern).getPattern() and + pattern.matches("%\\Q%") + ) + then + exists(string pattern | + pattern = sink.getNode().(RegexpPattern).getPattern() and + pattern = source.getNode().asExpr().getStringValue() + | + isIncompleteHostNameRegexpPattern(escapeQuotedDots(pattern), hostPart) + ) + else IncompleteHostNameRegexpConfig::isSourceString(source.getNode(), hostPart) select source, source, sink, "This regular expression has an unescaped dot before '" + hostPart + "', " + "so it might match more hosts than expected when $@.", sink, "the regular expression is used" diff --git a/go/ql/src/change-notes/2026-10-14-hostname-regexp-quoting.md b/go/ql/src/change-notes/2026-10-14-hostname-regexp-quoting.md new file mode 100644 index 000000000000..45701fa0a2d7 --- /dev/null +++ b/go/ql/src/change-notes/2026-10-14-hostname-regexp-quoting.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* The `go/incomplete-hostname-regexp` query now recognizes `\Q...\E` quoted text in constant regular expression patterns, avoiding false positives for literal hostname dots. diff --git a/go/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegexp/IncompleteHostnameRegexp.expected b/go/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegexp/IncompleteHostnameRegexp.expected index 1b984aeedee4..643df4017f11 100644 --- a/go/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegexp/IncompleteHostnameRegexp.expected +++ b/go/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegexp/IncompleteHostnameRegexp.expected @@ -5,13 +5,37 @@ | main.go:49:21:49:45 | `https://www.example.com` | main.go:49:21:49:45 | `https://www.example.com` | main.go:65:15:65:23 | localVar3 | This regular expression has an unescaped dot before 'example.com', so it might match more hosts than expected when $@. | main.go:65:15:65:23 | localVar3 | the regular expression is used | | main.go:56:15:56:34 | ...+... | main.go:56:15:56:34 | ...+... | main.go:56:15:56:34 | ...+... | This regular expression has an unescaped dot before 'example.com', so it might match more hosts than expected when $@. | main.go:56:15:56:34 | ...+... | the regular expression is used | | main.go:58:15:58:42 | ...+... | main.go:58:15:58:42 | ...+... | main.go:58:15:58:42 | ...+... | This regular expression has an unescaped dot before 'example.com', so it might match more hosts than expected when $@. | main.go:58:15:58:42 | ...+... | the regular expression is used | +| quoted-hostnames.go:10:21:10:43 | `^https://github.com/$` | quoted-hostnames.go:10:21:10:43 | `^https://github.com/$` | quoted-hostnames.go:10:21:10:43 | `^https://github.com/$` | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:10:21:10:43 | `^https://github.com/$` | the regular expression is used | +| quoted-hostnames.go:16:21:16:47 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:16:21:16:47 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:16:21:16:47 | `^https://\\Qgithub\\E.com/$` | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:16:21:16:47 | `^https://\\Qgithub\\E.com/$` | the regular expression is used | +| quoted-hostnames.go:19:21:19:72 | `^(?:\\Qhttps://github.com/\\E\|https://example.org/)$` | quoted-hostnames.go:19:21:19:72 | `^(?:\\Qhttps://github.com/\\E\|https://example.org/)$` | quoted-hostnames.go:19:21:19:72 | `^(?:\\Qhttps://github.com/\\E\|https://example.org/)$` | This regular expression has an unescaped dot before 'org', so it might match more hosts than expected when $@. | quoted-hostnames.go:19:21:19:72 | `^(?:\\Qhttps://github.com/\\E\|https://example.org/)$` | the regular expression is used | +| quoted-hostnames.go:28:21:28:46 | `^\\\\Qhttps://github.com/$` | quoted-hostnames.go:28:21:28:46 | `^\\\\Qhttps://github.com/$` | quoted-hostnames.go:28:21:28:46 | `^\\\\Qhttps://github.com/$` | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:28:21:28:46 | `^\\\\Qhttps://github.com/$` | the regular expression is used | +| quoted-hostnames.go:34:21:34:74 | `^(?:\\Qhttps://github\\Q.com/\\E\|https://example.org/)$` | quoted-hostnames.go:34:21:34:74 | `^(?:\\Qhttps://github\\Q.com/\\E\|https://example.org/)$` | quoted-hostnames.go:34:21:34:74 | `^(?:\\Qhttps://github\\Q.com/\\E\|https://example.org/)$` | This regular expression has an unescaped dot before 'org', so it might match more hosts than expected when $@. | quoted-hostnames.go:34:21:34:74 | `^(?:\\Qhttps://github\\Q.com/\\E\|https://example.org/)$` | the regular expression is used | +| quoted-hostnames.go:37:21:37:48 | `^\\Qhttps://github\\\\E.com/$` | quoted-hostnames.go:37:21:37:48 | `^\\Qhttps://github\\\\E.com/$` | quoted-hostnames.go:37:21:37:48 | `^\\Qhttps://github\\\\E.com/$` | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:37:21:37:48 | `^\\Qhttps://github\\\\E.com/$` | the regular expression is used | +| quoted-hostnames.go:46:21:46:49 | "^https://\\\\Qgithub\\\\E.com/$" | quoted-hostnames.go:46:21:46:49 | "^https://\\\\Qgithub\\\\E.com/$" | quoted-hostnames.go:46:21:46:49 | "^https://\\\\Qgithub\\\\E.com/$" | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:46:21:46:49 | "^https://\\\\Qgithub\\\\E.com/$" | the regular expression is used | +| quoted-hostnames.go:53:28:53:54 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:53:28:53:54 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:61:21:61:38 | wildcardAfterQuote | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:61:21:61:38 | wildcardAfterQuote | the regular expression is used | +| quoted-hostnames.go:53:28:53:54 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:53:28:53:54 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:62:17:62:34 | wildcardAfterQuote | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:62:17:62:34 | wildcardAfterQuote | the regular expression is used | +| quoted-hostnames.go:53:28:53:54 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:53:28:53:54 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:63:21:63:38 | wildcardAfterQuote | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:63:21:63:38 | wildcardAfterQuote | the regular expression is used | +| quoted-hostnames.go:69:12:69:37 | `\\Q)?https://github.com/$` | quoted-hostnames.go:69:12:69:37 | `\\Q)?https://github.com/$` | quoted-hostnames.go:70:21:70:35 | ...+... | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:70:21:70:35 | ...+... | the regular expression is used | +| quoted-hostnames.go:73:30:73:55 | `\\Q)?https://github.com/$` | quoted-hostnames.go:73:30:73:55 | `\\Q)?https://github.com/$` | quoted-hostnames.go:73:21:73:55 | ...+... | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:73:21:73:55 | ...+... | the regular expression is used | +| quoted-hostnames.go:76:21:76:56 | ...+... | quoted-hostnames.go:76:21:76:56 | ...+... | quoted-hostnames.go:76:21:76:56 | ...+... | This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when $@. | quoted-hostnames.go:76:21:76:56 | ...+... | the regular expression is used | edges -| IncompleteHostnameRegexp.go:11:8:11:36 | "^((www\|beta).)?example.com/" | IncompleteHostnameRegexp.go:12:38:12:39 | re | provenance | Sink:MaD:2 | +| IncompleteHostnameRegexp.go:11:8:11:36 | "^((www\|beta).)?example.com/" | IncompleteHostnameRegexp.go:12:38:12:39 | re | provenance | Sink:MaD:3 | | main.go:49:21:49:45 | `https://www.example.com` | main.go:62:15:62:25 | sourceConst | provenance | | -| main.go:62:15:62:25 | sourceConst | main.go:65:15:65:23 | localVar3 | provenance | Sink:MaD:1 | +| main.go:62:15:62:25 | sourceConst | main.go:65:15:65:23 | localVar3 | provenance | Sink:MaD:2 | +| quoted-hostnames.go:52:24:52:50 | `^\\Qhttps://github.com/\\E$` | quoted-hostnames.go:57:21:57:34 | quotedHostname | provenance | Sink:MaD:4 | +| quoted-hostnames.go:52:24:52:50 | `^\\Qhttps://github.com/\\E$` | quoted-hostnames.go:58:17:58:30 | quotedHostname | provenance | Sink:MaD:1 | +| quoted-hostnames.go:52:24:52:50 | `^\\Qhttps://github.com/\\E$` | quoted-hostnames.go:59:21:59:34 | quotedHostname | provenance | Sink:MaD:3 | +| quoted-hostnames.go:53:28:53:54 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:61:21:61:38 | wildcardAfterQuote | provenance | Sink:MaD:4 | +| quoted-hostnames.go:53:28:53:54 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:62:17:62:34 | wildcardAfterQuote | provenance | Sink:MaD:1 | +| quoted-hostnames.go:53:28:53:54 | `^https://\\Qgithub\\E.com/$` | quoted-hostnames.go:63:21:63:38 | wildcardAfterQuote | provenance | Sink:MaD:3 | +| quoted-hostnames.go:69:12:69:37 | `\\Q)?https://github.com/$` | quoted-hostnames.go:70:30:70:35 | suffix | provenance | | +| quoted-hostnames.go:70:30:70:35 | suffix | quoted-hostnames.go:70:21:70:35 | ...+... | provenance | Config Sink:MaD:4 | +| quoted-hostnames.go:73:30:73:55 | `\\Q)?https://github.com/$` | quoted-hostnames.go:73:21:73:55 | ...+... | provenance | Config Sink:MaD:4 | models -| 1 | Sink: regexp; ; false; Match; ; ; Argument[0]; regex-use[1]; manual | -| 2 | Sink: regexp; ; false; MatchString; ; ; Argument[0]; regex-use[1]; manual | +| 1 | Sink: regexp; ; false; Compile; ; ; Argument[0]; regex-use[c]; manual | +| 2 | Sink: regexp; ; false; Match; ; ; Argument[0]; regex-use[1]; manual | +| 3 | Sink: regexp; ; false; MatchString; ; ; Argument[0]; regex-use[1]; manual | +| 4 | Sink: regexp; ; false; MustCompile; ; ; Argument[0]; regex-use[c]; manual | nodes | IncompleteHostnameRegexp.go:11:8:11:36 | "^((www\|beta).)?example.com/" | semmle.label | "^((www\|beta).)?example.com/" | | IncompleteHostnameRegexp.go:12:38:12:39 | re | semmle.label | re | @@ -22,4 +46,32 @@ nodes | main.go:58:15:58:42 | ...+... | semmle.label | ...+... | | main.go:62:15:62:25 | sourceConst | semmle.label | sourceConst | | main.go:65:15:65:23 | localVar3 | semmle.label | localVar3 | +| quoted-hostnames.go:7:21:7:87 | `^// TODO (\\Qhttps://github.com/FerretDB/\\E([-\\w]+)/issues/(\\d+))$` | semmle.label | `^// TODO (\\Qhttps://github.com/FerretDB/\\E([-\\w]+)/issues/(\\d+))$` | +| quoted-hostnames.go:10:21:10:43 | `^https://github.com/$` | semmle.label | `^https://github.com/$` | +| quoted-hostnames.go:16:21:16:47 | `^https://\\Qgithub\\E.com/$` | semmle.label | `^https://\\Qgithub\\E.com/$` | +| quoted-hostnames.go:19:21:19:72 | `^(?:\\Qhttps://github.com/\\E\|https://example.org/)$` | semmle.label | `^(?:\\Qhttps://github.com/\\E\|https://example.org/)$` | +| quoted-hostnames.go:22:21:22:76 | `^(?:\\Qhttps://github.com/\\E\|\\Qhttps://example.org/\\E)$` | semmle.label | `^(?:\\Qhttps://github.com/\\E\|\\Qhttps://example.org/\\E)$` | +| quoted-hostnames.go:25:21:25:44 | `^\\Qhttps://github.com/` | semmle.label | `^\\Qhttps://github.com/` | +| quoted-hostnames.go:28:21:28:46 | `^\\\\Qhttps://github.com/$` | semmle.label | `^\\\\Qhttps://github.com/$` | +| quoted-hostnames.go:31:21:31:49 | `^\\\\\\Qhttps://github.com/\\E$` | semmle.label | `^\\\\\\Qhttps://github.com/\\E$` | +| quoted-hostnames.go:34:21:34:74 | `^(?:\\Qhttps://github\\Q.com/\\E\|https://example.org/)$` | semmle.label | `^(?:\\Qhttps://github\\Q.com/\\E\|https://example.org/)$` | +| quoted-hostnames.go:37:21:37:48 | `^\\Qhttps://github\\\\E.com/$` | semmle.label | `^\\Qhttps://github\\\\E.com/$` | +| quoted-hostnames.go:40:21:40:66 | `^\\Qhttps://github.com/\\ehttps://example.org/` | semmle.label | `^\\Qhttps://github.com/\\ehttps://example.org/` | +| quoted-hostnames.go:43:21:43:49 | "^\\\\Qhttps://github.com/\\\\E$" | semmle.label | "^\\\\Qhttps://github.com/\\\\E$" | +| quoted-hostnames.go:46:21:46:49 | "^https://\\\\Qgithub\\\\E.com/$" | semmle.label | "^https://\\\\Qgithub\\\\E.com/$" | +| quoted-hostnames.go:49:21:49:63 | `^\\Qhttps://github.com/\\E\\Qexample.org/\\E$` | semmle.label | `^\\Qhttps://github.com/\\E\\Qexample.org/\\E$` | +| quoted-hostnames.go:52:24:52:50 | `^\\Qhttps://github.com/\\E$` | semmle.label | `^\\Qhttps://github.com/\\E$` | +| quoted-hostnames.go:53:28:53:54 | `^https://\\Qgithub\\E.com/$` | semmle.label | `^https://\\Qgithub\\E.com/$` | +| quoted-hostnames.go:57:21:57:34 | quotedHostname | semmle.label | quotedHostname | +| quoted-hostnames.go:58:17:58:30 | quotedHostname | semmle.label | quotedHostname | +| quoted-hostnames.go:59:21:59:34 | quotedHostname | semmle.label | quotedHostname | +| quoted-hostnames.go:61:21:61:38 | wildcardAfterQuote | semmle.label | wildcardAfterQuote | +| quoted-hostnames.go:62:17:62:34 | wildcardAfterQuote | semmle.label | wildcardAfterQuote | +| quoted-hostnames.go:63:21:63:38 | wildcardAfterQuote | semmle.label | wildcardAfterQuote | +| quoted-hostnames.go:69:12:69:37 | `\\Q)?https://github.com/$` | semmle.label | `\\Q)?https://github.com/$` | +| quoted-hostnames.go:70:21:70:35 | ...+... | semmle.label | ...+... | +| quoted-hostnames.go:70:30:70:35 | suffix | semmle.label | suffix | +| quoted-hostnames.go:73:21:73:55 | ...+... | semmle.label | ...+... | +| quoted-hostnames.go:73:30:73:55 | `\\Q)?https://github.com/$` | semmle.label | `\\Q)?https://github.com/$` | +| quoted-hostnames.go:76:21:76:56 | ...+... | semmle.label | ...+... | subpaths diff --git a/go/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegexp/quoted-hostnames.go b/go/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegexp/quoted-hostnames.go new file mode 100644 index 000000000000..3bf4467c3a55 --- /dev/null +++ b/go/ql/test/query-tests/Security/CWE-020/IncompleteHostnameRegexp/quoted-hostnames.go @@ -0,0 +1,77 @@ +package main + +import "regexp" + +func quotedHostnames() { + // The hostname in the original issue is literal text. + regexp.MustCompile(`^// TODO (\Qhttps://github.com/FerretDB/\E([-\w]+)/issues/(\d+))$`) + + // An ordinary wildcard dot remains unsafe. + regexp.MustCompile(`^https://github.com/$`) // $ Alert + + // An explicitly escaped dot is literal. + regexp.MustCompile(`^https://github\.com/$`) + + // A dot after the quoted region remains a wildcard. + regexp.MustCompile(`^https://\Qgithub\E.com/$`) // $ Alert + + // A quoted hostname must not hide a later unsafe alternative. + regexp.MustCompile(`^(?:\Qhttps://github.com/\E|https://example.org/)$`) // $ Alert + + // Both alternatives are literal, in separate quoted regions. + regexp.MustCompile(`^(?:\Qhttps://github.com/\E|\Qhttps://example.org/\E)$`) + + // An unterminated quoted region extends to the end of the pattern. + regexp.MustCompile(`^\Qhttps://github.com/`) + + // Two backslashes escape the opener, leaving the dot as a wildcard. + regexp.MustCompile(`^\\Qhttps://github.com/$`) // $ Alert + + // Three backslashes leave a real opener after a literal backslash. + regexp.MustCompile(`^\\\Qhttps://github.com/\E$`) + + // Another opener is literal text; the first closing marker still ends quoting. + regexp.MustCompile(`^(?:\Qhttps://github\Q.com/\E|https://example.org/)$`) // $ Alert + + // The first literal closing marker ends quoting even after another backslash. + regexp.MustCompile(`^\Qhttps://github\\E.com/$`) // $ Alert + + // A lowercase closing marker is literal and leaves the region open. + regexp.MustCompile(`^\Qhttps://github.com/\ehttps://example.org/`) + + // Interpreted Go strings decode to the same quoted pattern. + regexp.MustCompile("^\\Qhttps://github.com/\\E$") + + // Decoding an interpreted string does not quote the following wildcard. + regexp.MustCompile("^https://\\Qgithub\\E.com/$") // $ Alert + + // Adjacent quoted sections remain literal. + regexp.MustCompile(`^\Qhttps://github.com/\E\Qexample.org/\E$`) +} + +const quotedHostname = `^\Qhttps://github.com/\E$` +const wildcardAfterQuote = `^https://\Qgithub\E.com/$` // $ Alert + +func constantQuotePatterns() { + // Constant aliases supply complete patterns to compilation and matching APIs. + regexp.MustCompile(quotedHostname) + regexp.Compile(quotedHostname) + regexp.MatchString(quotedHostname, "https://github.com/") + + regexp.MustCompile(wildcardAfterQuote) // $ Sink + regexp.Compile(wildcardAfterQuote) // $ Sink + regexp.MatchString(wildcardAfterQuote, "https://github.com/") // $ Sink +} + +func concatenatedQuoteOpeners() { + // The prefix escapes the suffix's opener, leaving its hostname dot unsafe. + prefix := `^(?:\` + suffix := `\Q)?https://github.com/$` // $ Alert + regexp.MustCompile(prefix + suffix) // $ Sink + + // The same context matters when the suffix is inline. + regexp.MustCompile(prefix + `\Q)?https://github.com/$`) // $ Alert + + // A constant concatenation supplies the complete pattern. + regexp.MustCompile(`^(?:\` + `\Q)?https://github.com/$`) // $ Alert +}