Skip to content

Commit c9d252a

Browse files
feat(protocol): validate native app origins
This enables matching native application origins using simple string comparison. Effective web origins are still matched using case-insensitive matches provided they are valid web origins per HTML5 Section 5.3 (https://www.w3.org/TR/2011/WD-html5-20110525/origin-0.html) and/or simple string comparison per RFC3986 Section 6.2.1 (https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.1). It is important to note that this function completely ignores Apple Associated Domains entirely as Apple is using an unassigned Well-Known URI in breech of Well-Known Uniform Resource Identifiers Section 3 (https://datatracker.ietf.org/doc/html/rfc8615#section-3). Closes #462, Closes #463
1 parent ed7dcbb commit c9d252a

4 files changed

Lines changed: 318 additions & 39 deletions

File tree

protocol/client.go

Lines changed: 92 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -108,25 +108,11 @@ func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyTy
108108

109109
// Registration Step 5 & Assertion Step 9. Verify that the value of C.origin matches
110110
// the Relying Party's origin.
111-
var fqOrigin string
112111

113-
if fqOrigin, err = FullyQualifiedOrigin(c.Origin); err != nil {
114-
return ErrParsingData.WithDetails("Error decoding clientData origin as URL").WithError(err)
115-
}
116-
117-
found := false
118-
119-
for _, origin := range rpOrigins {
120-
if strings.EqualFold(fqOrigin, origin) {
121-
found = true
122-
break
123-
}
124-
}
125-
126-
if !found {
112+
if !IsOriginInHaystack(c.Origin, rpOrigins) {
127113
return ErrVerification.
128114
WithDetails("Error validating origin").
129-
WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", rpOrigins, fqOrigin))
115+
WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", rpOrigins, c.Origin))
130116
}
131117

132118
if rpTopOriginsVerify != TopOriginIgnoreVerificationMode {
@@ -145,10 +131,6 @@ func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyTy
145131
possibleTopOrigins []string
146132
)
147133

148-
if fqTopOrigin, err = FullyQualifiedOrigin(c.TopOrigin); err != nil {
149-
return ErrParsingData.WithDetails("Error decoding clientData topOrigin as URL").WithError(err)
150-
}
151-
152134
switch rpTopOriginsVerify {
153135
case TopOriginExplicitVerificationMode:
154136
possibleTopOrigins = rpTopOrigins
@@ -160,16 +142,7 @@ func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyTy
160142
return ErrNotImplemented.WithDetails("Error handling unknown Top Origin verification mode")
161143
}
162144

163-
found = false
164-
165-
for _, origin := range possibleTopOrigins {
166-
if strings.EqualFold(fqTopOrigin, origin) {
167-
found = true
168-
break
169-
}
170-
}
171-
172-
if !found {
145+
if !IsOriginInHaystack(c.TopOrigin, possibleTopOrigins) {
173146
return ErrVerification.
174147
WithDetails("Error validating top origin").
175148
WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", possibleTopOrigins, fqTopOrigin))
@@ -221,3 +194,92 @@ const (
221194
// Top Origin is verified against the allowed Top Origins values.
222195
TopOriginExplicitVerificationMode
223196
)
197+
198+
// IsOriginInHaystack checks if the needle is in the haystack using the mechanism to determine origin equality defined
199+
// in HTML5 Section 5.3 and RFC3986 Section 6.2.1.
200+
//
201+
// Specifically if the needle value has the 'http://' or 'https://' prefix (case-insensitive) and can be parsed as a
202+
// URL; we check each item in the haystack to see if it matches the same rules, and then if the scheme and host (with
203+
// a normalized port) components match case-insensitively then they're considered a match.
204+
//
205+
// If the needle value does not have the 'http://' or 'https://' prefix (case-insensitive) or can't be parsed as a URL
206+
// equality is determined using simple string comparison.
207+
//
208+
// It is important to note that this function completely ignores Apple Associated Domains entirely as Apple is using
209+
// an unassigned Well-Known URI in breech of Well-Known Uniform Resource Identifiers (RFC8615).
210+
//
211+
// See (Origin Definition): https://www.w3.org/TR/2011/WD-html5-20110525/origin-0.html
212+
//
213+
// See (Simple String Comparison Definition): https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.1
214+
//
215+
// See (Apple Associated Domains): https://developer.apple.com/documentation/xcode/supporting-associated-domains
216+
//
217+
// See (IANA Well Known URI Assignments): https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml
218+
//
219+
// See (Well-Known Uniform Resource Identifiers): https://datatracker.ietf.org/doc/html/rfc8615
220+
func IsOriginInHaystack(needle string, haystack []string) bool {
221+
needleURI := parseOriginURI(needle)
222+
223+
if needleURI != nil {
224+
for _, hay := range haystack {
225+
if hayURI := parseOriginURI(hay); hayURI != nil {
226+
if isOriginEqual(needleURI, hayURI) {
227+
return true
228+
}
229+
}
230+
}
231+
} else {
232+
for _, hay := range haystack {
233+
if needle == hay {
234+
return true
235+
}
236+
}
237+
}
238+
239+
return false
240+
}
241+
242+
func isOriginEqual(a *url.URL, b *url.URL) bool {
243+
if !strings.EqualFold(a.Scheme, b.Scheme) {
244+
return false
245+
}
246+
247+
if !strings.EqualFold(a.Host, b.Host) {
248+
return false
249+
}
250+
251+
return true
252+
}
253+
254+
func parseOriginURI(raw string) *url.URL {
255+
if !isPossibleFQDN(raw) {
256+
return nil
257+
}
258+
259+
// We can ignore the error here because it's effectively not a FQDN if this fails.
260+
uri, _ := url.Parse(raw)
261+
262+
if uri == nil {
263+
return nil
264+
}
265+
266+
// Normalize the port if necessary.
267+
switch uri.Scheme {
268+
case "http":
269+
if uri.Port() == "80" {
270+
uri.Host = uri.Hostname()
271+
}
272+
case "https":
273+
if uri.Port() == "443" {
274+
uri.Host = uri.Hostname()
275+
}
276+
}
277+
278+
return uri
279+
}
280+
281+
func isPossibleFQDN(raw string) bool {
282+
normalized := strings.ToLower(raw)
283+
284+
return strings.HasPrefix(normalized, "http://") || strings.HasPrefix(normalized, "https://")
285+
}

protocol/client_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,151 @@ func TestFullyQualifiedOrigin(t *testing.T) {
106106
})
107107
}
108108
}
109+
110+
func TestIsOriginInHaystack(t *testing.T) {
111+
testCases := []struct {
112+
name string
113+
origin string
114+
haystack []string
115+
expected bool
116+
}{
117+
{
118+
"ShouldHandleFullyQualifiedOrigin",
119+
"https://app.example.com",
120+
[]string{"https://app.example.com"},
121+
true,
122+
},
123+
{
124+
"ShouldHandleFullyQualifiedOriginCaseInsensitiveScheme",
125+
"https://app.example.com",
126+
[]string{"HTTPS://app.example.com"},
127+
true,
128+
},
129+
{
130+
"ShouldHandleFullyQualifiedOriginCaseInsensitiveHost",
131+
"https://app.EXAMPLE.com",
132+
[]string{"https://app.example.com"},
133+
true,
134+
},
135+
{
136+
"ShouldHandleFullyQualifiedOriginWithPort",
137+
"https://app.example.com:443",
138+
[]string{"https://app.example.com:443"},
139+
true,
140+
},
141+
{
142+
"ShouldHandleFullyQualifiedOriginDifferentScheme",
143+
"http://app.example.com",
144+
[]string{"https://app.example.com"},
145+
false,
146+
},
147+
{
148+
"ShouldHandleFullyQualifiedOriginDifferentPort",
149+
"https://app.example.com:443",
150+
[]string{"https://app.example.com"},
151+
true,
152+
},
153+
{
154+
"ShouldHandleFullyQualifiedOriginDifferentPortNotMatchingScheme",
155+
"https://app.example.com:80",
156+
[]string{"https://app.example.com"},
157+
false,
158+
},
159+
{
160+
"ShouldHandleFullyQualifiedOriginDifferentPath",
161+
"https://app.example.com/abc",
162+
[]string{"https://app.example.com"},
163+
true,
164+
},
165+
{
166+
"ShouldHandleFullyQualifiedOriginDifferentQuery",
167+
"https://app.example.com/?abc=123",
168+
[]string{"https://app.example.com"},
169+
true,
170+
},
171+
{
172+
"ShouldHandleFullyQualifiedOriginDifferentQueryCount",
173+
"https://app.example.com/?abc=123",
174+
[]string{"https://app.example.com/?zyz=123&abc=123"},
175+
true,
176+
},
177+
{
178+
"ShouldHandleFullyQualifiedOriginDifferentQueryOrder",
179+
"https://app.example.com/?abc=123&xyz=123",
180+
[]string{"https://app.example.com/?xyz=123&abc=123"},
181+
true,
182+
},
183+
{
184+
"ShouldHandleFullyQualifiedOriginDifferentQueryValue",
185+
"https://app.example.com/?abc=123&xyz=123",
186+
[]string{"https://app.example.com/?xyz=1234&abc=123"},
187+
true,
188+
},
189+
{
190+
"ShouldHandleFullyQualifiedOriginFragment",
191+
"https://app.example.com/#abc",
192+
[]string{"https://app.example.com/#abc"},
193+
true,
194+
},
195+
{
196+
"ShouldHandleFullyQualifiedOriginFragmentDifferent",
197+
"https://app.example.com/#abc",
198+
[]string{"https://app.example.com/#abc2"},
199+
true,
200+
},
201+
{
202+
"ShouldHandleFullyQualifiedOriginWithoutAllowed",
203+
"https://app.example.com",
204+
nil,
205+
false,
206+
},
207+
{
208+
"ShouldHandleFullyQualifiedOriginWithTrailingSlashes",
209+
"https://app.example.com/",
210+
[]string{"https://app.example.com"},
211+
true,
212+
},
213+
{
214+
"ShouldHandleNativeAppAndroid",
215+
"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0",
216+
[]string{"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0"},
217+
true,
218+
},
219+
{
220+
"ShouldHandleNativeAppAndroidCaseSensitive",
221+
"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69F0",
222+
[]string{"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0"},
223+
false,
224+
},
225+
{
226+
"ShouldHandleNonFQDNOrigin",
227+
"https://user:password@app.example.com/",
228+
[]string{"https://app.example.com/"},
229+
true,
230+
},
231+
{
232+
"ShouldHandleNonFQDNOriginExactStringMatch",
233+
"https://user:password@app.example.com/",
234+
[]string{"https://user:password@app.example.com/"},
235+
true,
236+
},
237+
{
238+
"ShouldHandleFullyQualifiedOriginDefaultPortEquivalentHTTPS",
239+
"https://app.example.com:443",
240+
[]string{"https://app.example.com"},
241+
true,
242+
},
243+
{
244+
"ShouldHandleFullyQualifiedOriginDefaultPortEquivalentHTTP",
245+
"http://app.example.com:80",
246+
[]string{"http://app.example.com"},
247+
true,
248+
},
249+
}
250+
251+
for _, tc := range testCases {
252+
t.Run(tc.name, func(t *testing.T) {
253+
assert.Equal(t, tc.expected, IsOriginInHaystack(tc.origin, tc.haystack))
254+
})
255+
}
256+
}

webauthn/types.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,18 @@ type Config struct {
3333
// RPDisplayName configures the display name for the Relying Party Server. This can be any string.
3434
RPDisplayName string
3535

36-
// RPOrigins configures the list of Relying Party Server Origins that are permitted. These should be fully
37-
// qualified origins.
36+
// RPOrigins configures the list of Relying Party Server Origins that are permitted. The provided origins can either
37+
// be fully qualified origins or strings for simple string comparison. The strings are matched using canonical
38+
// origin matching semantics specifically if they start with 'http://' or 'https://' if the provided origin has a
39+
// case-insensitive equal scheme and host component they are equal, otherwise simple string comparison is utilized
40+
// to determine equality.
3841
RPOrigins []string
3942

40-
// RPTopOrigins configures the list of Relying Party Server Top Origins that are permitted. These should be fully
41-
// qualified origins.
43+
// RPTopOrigins configures the list of Relying Party Server Top Origins that are permitted. The provided origins can
44+
// either be fully qualified origins or strings for simple string comparison. The strings are matched using
45+
// canonical origin matching semantics specifically if they start with 'http://' or 'https://' if the provided
46+
// origin has a case-insensitive equal scheme and host component they are equal, otherwise simple string comparison
47+
// is utilized to determine equality.
4248
RPTopOrigins []string
4349

4450
// RPTopOriginVerificationMode determines the verification mode for the Top Origin value. By default the
@@ -90,13 +96,11 @@ type TimeoutConfig struct {
9096
}
9197

9298
// Validate that the config flags in Config are properly set
93-
func (config *Config) validate() error {
99+
func (config *Config) validate() (err error) {
94100
if config.validated {
95101
return nil
96102
}
97103

98-
var err error
99-
100104
if len(config.RPID) != 0 {
101105
if _, err = url.Parse(config.RPID); err != nil {
102106
return fmt.Errorf(errFmtFieldNotValidURI, "RPID", err)
@@ -129,9 +133,9 @@ func (config *Config) validate() error {
129133
switch config.RPTopOriginVerificationMode {
130134
case protocol.TopOriginDefaultVerificationMode:
131135
config.RPTopOriginVerificationMode = protocol.TopOriginIgnoreVerificationMode
132-
case protocol.TopOriginImplicitVerificationMode:
136+
case protocol.TopOriginExplicitVerificationMode:
133137
if len(config.RPTopOrigins) == 0 {
134-
return fmt.Errorf("must provide at least one value to the 'RPTopOrigins' field when 'RPTopOriginVerificationMode' field is set to protocol.TopOriginImplicitVerificationMode")
138+
return fmt.Errorf("must provide at least one value to the 'RPTopOrigins' field when 'RPTopOriginVerificationMode' field is set to protocol.TopOriginExplicitVerificationMode")
135139
}
136140
}
137141

0 commit comments

Comments
 (0)