|
| 1 | +package regexp |
| 2 | + |
| 3 | +import ( |
| 4 | + "regexp/syntax" |
| 5 | + "slices" |
| 6 | +) |
| 7 | + |
| 8 | +// isNegatedOrBroadClass determines whether a character class (e.g., [0-9], [^/], [a-z]) |
| 9 | +// allows a character range wide enough to act as an unrestricted wildcard. |
| 10 | +// |
| 11 | +// In Go's regex AST (regexp/syntax), character classes (syntax.OpCharClass) are represented |
| 12 | +// in the 'Rune' slice as a sequence of inclusive range pairs: [start1, end1, start2, end2, ...]. |
| 13 | +// |
| 14 | +// For example: |
| 15 | +// - [0-9] is encoded as: ['0', '9'] -> 10 total characters |
| 16 | +// - [a-zA-Z] is encoded as: ['A', 'Z', 'a', 'z'] -> 52 total characters |
| 17 | +// - [^/] (negated class) expands into two vast ranges: |
| 18 | +// [UnicodeMin, '/'-1, '/'+1, UnicodeMax] -> >1,100,000 characters |
| 19 | +// |
| 20 | +// If the total number of allowed characters exceeds the threshold (100), the class is |
| 21 | +// considered either a negated class (like [^/]) or overly permissive, and thus classified |
| 22 | +// as an unrestricted wildcard. |
| 23 | +func isNegatedOrBroadClass(runes []rune) bool { |
| 24 | + var totalChars int32 |
| 25 | + |
| 26 | + // Rune ranges are always stored in start/end pairs at even/odd indices: |
| 27 | + // runes[i] = range start |
| 28 | + // runes[i+1] = range end |
| 29 | + for i := 0; i < len(runes); i += 2 { |
| 30 | + totalChars += (runes[i+1] - runes[i] + 1) |
| 31 | + } |
| 32 | + |
| 33 | + // Safety threshold: Allows common URL character sets like [a-zA-Z0-9_-] (~65 chars), |
| 34 | + // while flagging negated classes or broad ranges that could match arbitrary subdomains. |
| 35 | + return totalChars > 100 |
| 36 | +} |
| 37 | + |
| 38 | +func inspectForWildcardSegments(node *syntax.Regexp) bool { |
| 39 | + // Inspect for child elements first |
| 40 | + // Any child element has an unrestricted wildcard |
| 41 | + if slices.ContainsFunc(node.Sub, inspectForWildcardSegments) { |
| 42 | + return true |
| 43 | + } |
| 44 | + |
| 45 | + switch node.Op { |
| 46 | + // Unrestricter char wildcard (AKA. the dot) |
| 47 | + case syntax.OpAnyChar, syntax.OpAnyCharNotNL: |
| 48 | + return true |
| 49 | + |
| 50 | + // Check for negated class chars with a wide allowed chars, marked as unrestricted wildcard. |
| 51 | + // Ex: [^/], [^#], many others... |
| 52 | + case syntax.OpCharClass: |
| 53 | + if isNegatedOrBroadClass(node.Rune) { |
| 54 | + return true |
| 55 | + } |
| 56 | + } |
| 57 | + |
| 58 | + return false |
| 59 | +} |
| 60 | + |
| 61 | +// HasArbitraryWildcard parses the regex pattern to check if it contains unrestricted wildcards. |
| 62 | +// |
| 63 | +// An unrestricted wildcard includes constructs like '.', '.*', '.+', or broad/negated character |
| 64 | +// classes like '[^/]+'. |
| 65 | +// |
| 66 | +// NOTE: Unescaped literal dots in domain names (e.g., "example.com" instead of "example\.com") |
| 67 | +// are parsed as syntax.OpAnyCharNotNL and will be flagged as wildcards. This prevents subtle |
| 68 | +// open-redirect vulnerabilities where "pr-123.example.com" could match "pr-123xexample.com" |
| 69 | +func HasArbitraryWildcard(pattern string) (bool, error) { |
| 70 | + ast, err := syntax.Parse(pattern, syntax.Perl) |
| 71 | + if err != nil { |
| 72 | + return false, err |
| 73 | + } |
| 74 | + |
| 75 | + return inspectForWildcardSegments(ast), nil |
| 76 | +} |
0 commit comments