Skip to content

Commit bcc5941

Browse files
leaanthonyclaude
andauthored
fix(v2): prevent origin bypass via suffix match in wildcardPatternToRegex (#5074)
* fix(v2): prevent origin bypass via suffix match in wildcardPatternToRegex Wildcards in BindingsAllowedOrigins patterns are now only expanded when they represent a full domain component (after "://", ".", or ":"). A wildcard appended to a partial label (e.g. "com*") is stripped, preventing attacker-controlled domains like "myapp.community" from bypassing origin checks when "https://myapp.com*" is configured. Fixes GHSA-47hv-j4px-h3c9 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(v2): require both-side component boundary for origin wildcards Addresses review feedback on GHSA-47hv-j4px-h3c9: the previous fix checked only the left boundary of '*', so partial-label wildcards (e.g. "https://*myapp.com", "https://myapp.*com") still expanded and could match across host boundaries. A '*' is now expanded only when it forms a complete origin component — a separator ("://", "." or ":") on the left AND a separator (".", ":", "/" or end) on the right. It expands to [^.:/@]+ (one non-empty component that cannot absorb a userinfo '@' delimiter), and the pattern is anchored with \A...\z. Any other '*' is treated as a literal, so misused trailing/partial wildcards fail closed. Adds regression tests for nested-suffix, partial-label, port, and userinfo (user@host) bypass attempts. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 279c2e3 commit bcc5941

2 files changed

Lines changed: 205 additions & 12 deletions

File tree

v2/internal/frontend/originvalidator/originValidator.go

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -77,21 +77,65 @@ func (v *OriginValidator) matchesOriginPattern(pattern, origin string) bool {
7777
return false
7878
}
7979

80-
// wildcardPatternToRegex converts wildcard pattern to regex
80+
// wildcardPatternToRegex converts a wildcard pattern to an anchored regex.
81+
//
82+
// A '*' is treated as a wildcard only when it spans a COMPLETE origin
83+
// component — bounded on the left by "://", "." or ":" AND on the right by
84+
// ".", ":", "/" or the end of the pattern. Such a wildcard expands to a single
85+
// non-empty component matcher ([^.:/@]+) that cannot cross a scheme/host/port
86+
// or userinfo boundary. Any other '*' (a partial-label wildcard such as "myapp.com*",
87+
// "*myapp.com" or "myapp.*com") is treated as a literal character, so it
88+
// cannot widen the match — a misused trailing/partial wildcard simply fails
89+
// closed instead of allowing suffix or cross-boundary bypasses
90+
// (GHSA-47hv-j4px-h3c9). \A and \z anchor the whole string with no
91+
// trailing-newline leniency (unlike ^...$).
8192
func (v *OriginValidator) wildcardPatternToRegex(wildcardPattern string) string {
82-
// Escape special regex characters except *
83-
specialChars := []string{"\\", ".", "+", "?", "^", "$", "{", "}", "(", ")", "|", "[", "]"}
84-
85-
escaped := wildcardPattern
86-
for _, specialChar := range specialChars {
87-
escaped = strings.ReplaceAll(escaped, specialChar, "\\"+specialChar)
93+
runes := []rune(wildcardPattern)
94+
var b strings.Builder
95+
b.WriteString(`\A`)
96+
for i, c := range runes {
97+
if c == '*' && isComponentWildcard(runes, i) {
98+
// One non-empty component. '@' is excluded alongside the . : /
99+
// separators so a wildcard component can never absorb a userinfo
100+
// delimiter (e.g. "myapp.com:*" must not match
101+
// "myapp.com:x@evilcom", whose real host is "evilcom").
102+
b.WriteString(`[^.:/@]+`)
103+
continue
104+
}
105+
b.WriteString(regexp.QuoteMeta(string(c)))
88106
}
107+
b.WriteString(`\z`)
108+
return b.String()
109+
}
89110

90-
// Replace * with .* (matches any characters)
91-
escaped = strings.ReplaceAll(escaped, "*", ".*")
92-
93-
// Anchor the pattern to match the entire string
94-
return "^" + escaped + "$"
111+
// isComponentWildcard reports whether the '*' at index i spans a complete
112+
// origin component: a separator boundary ("://", "." or ":") immediately to its
113+
// left, and a separator boundary (".", ":", "/" or end of pattern) immediately
114+
// to its right. Only such wildcards are safe to expand; any other '*' would let
115+
// the wildcard bleed across a host/port boundary and is left literal.
116+
func isComponentWildcard(runes []rune, i int) bool {
117+
// Left boundary.
118+
leftOK := false
119+
switch {
120+
case i == 0:
121+
leftOK = false
122+
case runes[i-1] == '.' || runes[i-1] == ':':
123+
leftOK = true
124+
case runes[i-1] == '/' && i >= 3 && string(runes[i-3:i]) == "://":
125+
leftOK = true
126+
}
127+
if !leftOK {
128+
return false
129+
}
130+
// Right boundary.
131+
if i == len(runes)-1 {
132+
return true
133+
}
134+
switch runes[i+1] {
135+
case '.', ':', '/':
136+
return true
137+
}
138+
return false
95139
}
96140

97141
// GetOriginFromURL extracts origin from URL string
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package originvalidator
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
)
7+
8+
func mustParseURL(rawURL string) *url.URL {
9+
u, err := url.Parse(rawURL)
10+
if err != nil {
11+
panic(err)
12+
}
13+
return u
14+
}
15+
16+
func TestWildcardPatternDoesNotCrossDomainBoundaries(t *testing.T) {
17+
tests := []originCase{
18+
// Legitimate subdomain wildcard — should still work
19+
{
20+
name: "subdomain wildcard matches subdomain",
21+
allowedOrigins: "https://*.myapp.com",
22+
origin: "https://api.myapp.com",
23+
expected: true,
24+
},
25+
{
26+
name: "subdomain wildcard matches different subdomain",
27+
allowedOrigins: "https://*.myapp.com",
28+
origin: "https://www.myapp.com",
29+
expected: true,
30+
},
31+
{
32+
name: "subdomain wildcard rejects different domain",
33+
allowedOrigins: "https://*.myapp.com",
34+
origin: "https://evil.com",
35+
expected: false,
36+
},
37+
// Trailing wildcard — the vulnerability vector
38+
{
39+
name: "trailing wildcard rejects different TLD (bypass attempt)",
40+
allowedOrigins: "https://myapp.com*",
41+
origin: "https://myapp.community",
42+
expected: false,
43+
},
44+
{
45+
name: "trailing wildcard rejects attacker subdomain (bypass attempt)",
46+
allowedOrigins: "https://myapp.com*",
47+
origin: "https://myapp.com.attacker.com",
48+
expected: false,
49+
},
50+
{
51+
name: "trailing wildcard rejects arbitrary suffix",
52+
allowedOrigins: "https://myapp.com*",
53+
origin: "https://myapp.comXXXXX",
54+
expected: false,
55+
},
56+
// Exact match still works
57+
{
58+
name: "exact match",
59+
allowedOrigins: "https://myapp.com",
60+
origin: "https://myapp.com",
61+
expected: true,
62+
},
63+
{
64+
name: "exact match rejects different origin",
65+
allowedOrigins: "https://myapp.com",
66+
origin: "https://evil.com",
67+
expected: false,
68+
},
69+
// Wildcard does not cross into path or port
70+
{
71+
name: "wildcard does not match across port separator",
72+
allowedOrigins: "https://localhost*",
73+
origin: "https://localhost:8080",
74+
expected: false,
75+
},
76+
{
77+
name: "wildcard does not match across path separator",
78+
allowedOrigins: "https://myapp*",
79+
origin: "https://myapp/evil",
80+
expected: false,
81+
},
82+
// Empty origin
83+
{
84+
name: "empty origin is rejected",
85+
allowedOrigins: "https://*.myapp.com",
86+
origin: "",
87+
expected: false,
88+
},
89+
}
90+
91+
runOriginCases(t, tests)
92+
}
93+
94+
type originCase struct {
95+
name string
96+
allowedOrigins string
97+
origin string
98+
expected bool
99+
}
100+
101+
func runOriginCases(t *testing.T, tests []originCase) {
102+
t.Helper()
103+
for _, tt := range tests {
104+
t.Run(tt.name, func(t *testing.T) {
105+
startURL := mustParseURL("https://wails.localhost")
106+
v := NewOriginValidator(startURL, tt.allowedOrigins)
107+
got := v.IsOriginAllowed(tt.origin)
108+
if got != tt.expected {
109+
t.Errorf("IsOriginAllowed(%q) with pattern %q = %v, want %v",
110+
tt.origin, tt.allowedOrigins, got, tt.expected)
111+
}
112+
})
113+
}
114+
}
115+
116+
// TestWildcardComponentBoundaries covers the regression cases requested in the
117+
// review of GHSA-47hv-j4px-h3c9: a '*' must be a complete origin component
118+
// (separator on BOTH sides) to be a wildcard; partial-label wildcards must not
119+
// expand; and wildcards must never cross host/port/path or userinfo boundaries.
120+
func TestWildcardComponentBoundaries(t *testing.T) {
121+
runOriginCases(t, []originCase{
122+
// Subdomain wildcard: matches exactly one subdomain label.
123+
{"subdomain wildcard matches one label", "https://*.myapp.com", "https://api.myapp.com", true},
124+
{"subdomain wildcard rejects nested suffix bypass", "https://*.myapp.com", "https://api.myapp.com.evil.com", false},
125+
{"subdomain wildcard rejects userinfo bypass", "https://*.myapp.com", "https://api.myapp.com@evil.com", false},
126+
{"subdomain wildcard rejects apex", "https://*.myapp.com", "https://myapp.com", false},
127+
{"subdomain wildcard rejects multi-level", "https://*.myapp.com", "https://a.b.myapp.com", false},
128+
{"subdomain wildcard rejects lookalike domain", "https://*.myapp.com", "https://api.notmyapp.com", false},
129+
130+
// Partial-label wildcards must NOT expand (treated literally => fail closed).
131+
{"prefix partial wildcard rejects lookalike", "https://*myapp.com", "https://evilmyapp.com", false},
132+
{"prefix partial wildcard rejects apex", "https://*myapp.com", "https://myapp.com", false},
133+
{"infix partial wildcard rejects fill", "https://myapp.*com", "https://myapp.evilcom", false},
134+
{"infix partial wildcard rejects single label", "https://myapp.*com", "https://myapp.xcom", false},
135+
{"trailing partial wildcard rejects suffix", "https://myapp.com*", "https://myapp.community", false},
136+
{"trailing partial wildcard rejects apex (fails closed)", "https://myapp.com*", "https://myapp.com", false},
137+
138+
// Port wildcard: complete component after ':'.
139+
{"port wildcard matches a port", "https://myapp.com:*", "https://myapp.com:8080", true},
140+
{"port wildcard does not cross into host", "https://myapp.com:*", "https://myapp.com:8080.evil.com", false},
141+
{"port wildcard does not cross into path", "https://myapp.com:*", "https://myapp.com:8080/evil", false},
142+
{"port wildcard requires a port", "https://myapp.com:*", "https://myapp.com", false},
143+
{"port wildcard rejects userinfo bypass", "https://myapp.com:*", "https://myapp.com:x@evilcom", false},
144+
145+
// Multiple complete-component wildcards.
146+
{"sub+tld wildcard matches", "https://*.myapp.*", "https://api.myapp.com", true},
147+
{"sub+tld wildcard rejects nested suffix", "https://*.myapp.*", "https://api.myapp.com.evil.com", false},
148+
})
149+
}

0 commit comments

Comments
 (0)