Skip to content

Commit 9c9d7d8

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). Closes #462, Closes #463
1 parent ed7dcbb commit 9c9d7d8

File tree

2 files changed

+268
-30
lines changed

2 files changed

+268
-30
lines changed

protocol/client.go

Lines changed: 138 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,138 @@ 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 includes the '://' character sequence and can be parsed as URL's;
202+
// we check each item in the haystack to see if it matches the same rules, and then if the scheme and host components
203+
// match case-insensitively and check all other components with simple string comparison. The query arguments have a
204+
// special match condition where we check if the values are equal in length, then iteratively check if each key value
205+
// pair are matches. This allows the query arguments to have a different order.
206+
//
207+
// If the needle value does not include the '://' character sequence or can't be parsed as a URL equality is determined
208+
// using simple string comparison.
209+
//
210+
// See (Origin Definition): https://www.w3.org/TR/2011/WD-html5-20110525/origin-0.html
211+
//
212+
// See (Simple String Comparison Definition): https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.1
213+
func IsOriginInHaystack(needle string, haystack []string) bool {
214+
needleURI := parseNormalizedURI(needle)
215+
216+
if needleURI != nil {
217+
for _, hay := range haystack {
218+
if hayURI := parseNormalizedURI(hay); hayURI != nil {
219+
if isOriginEqual(needleURI, hayURI) {
220+
return true
221+
}
222+
}
223+
}
224+
} else {
225+
for _, hay := range haystack {
226+
if needle == hay {
227+
return true
228+
}
229+
}
230+
}
231+
232+
return false
233+
}
234+
235+
func isOriginEqual(a *url.URL, b *url.URL) bool {
236+
if !strings.EqualFold(a.Scheme, b.Scheme) {
237+
return false
238+
}
239+
240+
if !strings.EqualFold(a.Host, b.Host) {
241+
return false
242+
}
243+
244+
if a.Path != b.Path || a.RawPath != b.RawPath {
245+
return false
246+
}
247+
248+
if a.Fragment != b.Fragment || a.RawFragment != b.RawFragment {
249+
return false
250+
}
251+
252+
if a.Opaque != b.Opaque {
253+
return false
254+
}
255+
256+
if (len(a.RawQuery) != 0 || len(b.RawQuery) != 0) && !isURLValuesEqual(a.Query(), b.Query()) {
257+
return false
258+
}
259+
260+
if !isURLUserEqual(a.User, b.User) {
261+
return false
262+
}
263+
264+
return true
265+
}
266+
267+
func isURLUserEqual(a, b *url.Userinfo) bool {
268+
if a == nil && b == nil {
269+
return true
270+
}
271+
272+
if a.Username() != b.Username() {
273+
return false
274+
}
275+
276+
ap, aok := a.Password()
277+
bp, bok := b.Password()
278+
279+
if !aok && !bok {
280+
return true
281+
}
282+
283+
if aok != bok {
284+
return false
285+
}
286+
287+
if ap != bp {
288+
return false
289+
}
290+
291+
return true
292+
}
293+
294+
func isURLValuesEqual(a, b url.Values) bool {
295+
if len(a) != len(b) {
296+
return false
297+
}
298+
299+
for k := range a {
300+
if a.Get(k) != b.Get(k) {
301+
return false
302+
}
303+
}
304+
305+
return true
306+
}
307+
308+
func parseNormalizedURI(raw string) *url.URL {
309+
if !strings.Contains(raw, "://") {
310+
return nil
311+
}
312+
313+
// We can ignore the error here because it's effectively not a FQDN if this fails.
314+
uri, _ := url.Parse(raw)
315+
316+
if uri == nil {
317+
return nil
318+
}
319+
320+
// Perform Path Normalization.
321+
if uri.Path == "/" || uri.RawPath == "/" {
322+
uri.Path = ""
323+
uri.RawPath = ""
324+
}
325+
326+
// Perform Scheme/Host Normalization.
327+
uri.Scheme = strings.ToLower(uri.Scheme)
328+
uri.Host = strings.ToLower(uri.Host)
329+
330+
return uri
331+
}

protocol/client_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,133 @@ 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+
false,
152+
},
153+
{
154+
"ShouldHandleFullyQualifiedOriginDifferentPath",
155+
"https://app.example.com/abc",
156+
[]string{"https://app.example.com"},
157+
false,
158+
},
159+
{
160+
"ShouldHandleFullyQualifiedOriginDifferentQuery",
161+
"https://app.example.com/?abc=123",
162+
[]string{"https://app.example.com"},
163+
false,
164+
},
165+
{
166+
"ShouldHandleFullyQualifiedOriginDifferentQueryCount",
167+
"https://app.example.com/?abc=123",
168+
[]string{"https://app.example.com/?zyz=123&abc=123"},
169+
false,
170+
},
171+
{
172+
"ShouldHandleFullyQualifiedOriginDifferentQueryOrder",
173+
"https://app.example.com/?abc=123&xyz=123",
174+
[]string{"https://app.example.com/?xyz=123&abc=123"},
175+
true,
176+
},
177+
{
178+
"ShouldHandleFullyQualifiedOriginDifferentQueryValue",
179+
"https://app.example.com/?abc=123&xyz=123",
180+
[]string{"https://app.example.com/?xyz=1234&abc=123"},
181+
false,
182+
},
183+
{
184+
"ShouldHandleFullyQualifiedOriginFragment",
185+
"https://app.example.com/#abc",
186+
[]string{"https://app.example.com/#abc"},
187+
true,
188+
},
189+
{
190+
"ShouldHandleFullyQualifiedOriginFragmentDifferent",
191+
"https://app.example.com/#abc",
192+
[]string{"https://app.example.com/#abc2"},
193+
false,
194+
},
195+
{
196+
"ShouldHandleFullyQualifiedOriginWithoutAllowed",
197+
"https://app.example.com",
198+
nil,
199+
false,
200+
},
201+
{
202+
"ShouldHandleFullyQualifiedOriginWithTrailingSlashes",
203+
"https://app.example.com/",
204+
[]string{"https://app.example.com"},
205+
true,
206+
},
207+
{
208+
"ShouldHandleNativeAppAndroid",
209+
"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0",
210+
[]string{"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0"},
211+
true,
212+
},
213+
{
214+
"ShouldHandleNativeAppAndroidCaseSensitive",
215+
"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69F0",
216+
[]string{"android:apk-key-hash:7d1043473d55bfa90e8530d35801d4e381bc69f0"},
217+
false,
218+
},
219+
{
220+
"ShouldHandleNonFQDNOrigin",
221+
"https://user:[email protected]/",
222+
[]string{"https://app.example.com/"},
223+
false,
224+
},
225+
{
226+
"ShouldHandleNonFQDNOriginExactStringMatch",
227+
"https://user:[email protected]/",
228+
[]string{"https://user:[email protected]/"},
229+
true,
230+
},
231+
}
232+
233+
for _, tc := range testCases {
234+
t.Run(tc.name, func(t *testing.T) {
235+
assert.Equal(t, tc.expected, IsOriginInHaystack(tc.origin, tc.haystack))
236+
})
237+
}
238+
}

0 commit comments

Comments
 (0)