Skip to content

Commit d57f060

Browse files
feat: verify top_origin when authenticating and registering a credential
1 parent 342e4ff commit d57f060

File tree

9 files changed

+408
-1
lines changed

9 files changed

+408
-1
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ WebAuthn.configure do |config|
104104
# Multiple origins can be used when needed. Using more than one will imply you MUST configure rp_id explicitely. If you need your credentials to be bound to a single origin but you have more than one tenant, please see [our Advanced Configuration section](https://github.com/cedarcode/webauthn-ruby/blob/master/docs/advanced_configuration.md) instead of adding multiple origins.
105105
config.allowed_origins = ["https://auth.example.com"]
106106

107+
# When operating within iframes or embedded contexts, you may need to restrict
108+
# which top-level origins are permitted to host WebAuthn ceremonies.
109+
#
110+
# Each entry in this list must match the `topOrigin` reported by the browser
111+
# during registration and authentication.
112+
#
113+
# config.allowed_top_origins = ["https://app.example.com"]
114+
107115
# Relying Party name for display purposes
108116
config.rp_name = "Example Inc."
109117

lib/webauthn/authenticator_response.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class ChallengeVerificationError < VerificationError; end
1414
class OriginVerificationError < VerificationError; end
1515
class RpIdVerificationError < VerificationError; end
1616
class TokenBindingVerificationError < VerificationError; end
17+
class TopOriginVerificationError < VerificationError; end
1718
class TypeVerificationError < VerificationError; end
1819
class UserPresenceVerificationError < VerificationError; end
1920
class UserVerifiedVerificationError < VerificationError; end
@@ -33,6 +34,7 @@ def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_v
3334
verify_item(:token_binding)
3435
verify_item(:challenge, expected_challenge)
3536
verify_item(:origin, expected_origin)
37+
verify_item(:top_origin) if needs_top_origin_verification?
3638
verify_item(:authenticator_data)
3739

3840
verify_item(
@@ -84,6 +86,12 @@ def valid_token_binding?
8486
client_data.valid_token_binding_format?
8587
end
8688

89+
def valid_top_origin?
90+
return false unless client_data.cross_origin
91+
92+
relying_party.allowed_top_origins.include?(client_data.top_origin)
93+
end
94+
8795
def valid_challenge?(expected_challenge)
8896
OpenSSL.secure_compare(client_data.challenge, expected_challenge)
8997
end
@@ -121,5 +129,9 @@ def rp_id_from_origin(expected_origin)
121129
def type
122130
raise NotImplementedError, "Please define #type method in subclass"
123131
end
132+
133+
def needs_top_origin_verification?
134+
client_data.cross_origin || client_data.top_origin
135+
end
124136
end
125137
end

lib/webauthn/client_data.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ def token_binding
3131
data["tokenBinding"]
3232
end
3333

34+
def cross_origin
35+
case data["crossOrigin"]
36+
when "true" then true
37+
when "false" then false
38+
end
39+
end
40+
41+
def top_origin
42+
data["topOrigin"]
43+
end
44+
3445
def valid_token_binding_format?
3546
if token_binding
3647
token_binding.is_a?(Hash) && VALID_TOKEN_BINDING_STATUSES.include?(token_binding["status"])

lib/webauthn/configuration.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class Configuration
2424
:origin=,
2525
:allowed_origins,
2626
:allowed_origins=,
27+
:allowed_top_origins,
28+
:allowed_top_origins=,
2729
:verify_attestation_statement,
2830
:verify_attestation_statement=,
2931
:credential_options_timeout,

lib/webauthn/fake_client.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,19 @@ module WebAuthn
1010
class FakeClient
1111
TYPES = { create: "webauthn.create", get: "webauthn.get" }.freeze
1212

13-
attr_reader :origin, :token_binding, :encoding
13+
attr_reader :origin, :cross_origin, :top_origin, :token_binding, :encoding
1414

1515
def initialize(
1616
origin = fake_origin,
17+
cross_origin: nil,
18+
top_origin: nil,
1719
token_binding: nil,
1820
authenticator: WebAuthn::FakeAuthenticator.new,
1921
encoding: WebAuthn.configuration.encoding
2022
)
2123
@origin = origin
24+
@cross_origin = cross_origin
25+
@top_origin = top_origin
2226
@token_binding = token_binding
2327
@authenticator = authenticator
2428
@encoding = encoding
@@ -137,6 +141,14 @@ def data_json_for(method, challenge)
137141
data[:tokenBinding] = token_binding
138142
end
139143

144+
if cross_origin
145+
data[:crossOrigin] = cross_origin
146+
end
147+
148+
if top_origin
149+
data[:topOrigin] = top_origin
150+
end
151+
140152
data.to_json
141153
end
142154

lib/webauthn/relying_party.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def initialize(
1919
algorithms: DEFAULT_ALGORITHMS.dup,
2020
encoding: WebAuthn::Encoder::STANDARD_ENCODING,
2121
allowed_origins: nil,
22+
allowed_top_origins: nil,
2223
origin: nil,
2324
id: nil,
2425
name: nil,
@@ -32,6 +33,7 @@ def initialize(
3233
@algorithms = algorithms
3334
@encoding = encoding
3435
@allowed_origins = allowed_origins
36+
@allowed_top_origins = allowed_top_origins
3537
@id = id
3638
@name = name
3739
@verify_attestation_statement = verify_attestation_statement
@@ -46,6 +48,7 @@ def initialize(
4648
attr_accessor :algorithms,
4749
:encoding,
4850
:allowed_origins,
51+
:allowed_top_origins,
4952
:id,
5053
:name,
5154
:verify_attestation_statement,

spec/spec_helper.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ def fake_origin
6363
"http://localhost"
6464
end
6565

66+
def fake_top_origin
67+
"http://localhost.org"
68+
end
69+
6670
def fake_challenge
6771
SecureRandom.random_bytes(32)
6872
end

spec/webauthn/authenticator_assertion_response_spec.rb

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,201 @@
501501
end
502502
end
503503

504+
describe "top_origin validation" do
505+
let(:client) { WebAuthn::FakeClient.new(origin, encoding: false, cross_origin: cross_origin, top_origin: client_top_origin) }
506+
let(:top_origin) { fake_top_origin }
507+
508+
before do
509+
WebAuthn.configuration.allowed_top_origins = [top_origin]
510+
end
511+
512+
context "when cross_origin is true" do
513+
let(:cross_origin) { "true" }
514+
515+
context "when top_origin is set" do
516+
context "when top_origin matches client top_origin" do
517+
let(:client_top_origin) { top_origin }
518+
519+
it "verifies" do
520+
expect(
521+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
522+
).to be_truthy
523+
end
524+
525+
it "is valid" do
526+
expect(
527+
assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)
528+
).to be_truthy
529+
end
530+
end
531+
532+
context "when top_origin does not match client top_origin" do
533+
let(:client_top_origin) { "https://malicious.example.com" }
534+
535+
it "is invalid" do
536+
expect(
537+
assertion_response.valid?(
538+
original_challenge,
539+
public_key: credential_public_key,
540+
sign_count: 0
541+
)
542+
).to be_falsy
543+
end
544+
545+
it "doesn't verify" do
546+
expect {
547+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
548+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
549+
end
550+
end
551+
end
552+
553+
context "when top_origin is not set" do
554+
let(:client_top_origin) { nil }
555+
556+
it "is invalid" do
557+
expect(
558+
assertion_response.valid?(
559+
original_challenge,
560+
public_key: credential_public_key,
561+
sign_count: 0
562+
)
563+
).to be_falsy
564+
end
565+
566+
it "doesn't verify" do
567+
expect {
568+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
569+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
570+
end
571+
end
572+
end
573+
574+
context "when cross_origin is false" do
575+
let(:cross_origin) { "false" }
576+
577+
context "when top_origin is set" do
578+
context "when top_origin matches client top_origin" do
579+
let(:client_top_origin) { top_origin }
580+
581+
it "is invalid" do
582+
expect(
583+
assertion_response.valid?(
584+
original_challenge,
585+
public_key: credential_public_key,
586+
sign_count: 0
587+
)
588+
).to be_falsy
589+
end
590+
591+
it "doesn't verify" do
592+
expect {
593+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
594+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
595+
end
596+
end
597+
598+
context "when top_origin does not match client top_origin" do
599+
let(:client_top_origin) { "https://malicious.example.com" }
600+
601+
it "is invalid" do
602+
expect(
603+
assertion_response.valid?(
604+
original_challenge,
605+
public_key: credential_public_key,
606+
sign_count: 0
607+
)
608+
).to be_falsy
609+
end
610+
611+
it "doesn't verify" do
612+
expect {
613+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
614+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
615+
end
616+
end
617+
618+
context "when top_origin is not set" do
619+
let(:client_top_origin) { nil }
620+
621+
it "verifies" do
622+
expect(
623+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
624+
).to be_truthy
625+
end
626+
627+
it "is valid" do
628+
expect(
629+
assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)
630+
).to be_truthy
631+
end
632+
end
633+
end
634+
end
635+
636+
context "when cross_origin is not set" do
637+
let(:cross_origin) { nil }
638+
639+
context "when top_origin is set" do
640+
context "when top_origin matches client top_origin" do
641+
let(:client_top_origin) { top_origin }
642+
643+
it "is invalid" do
644+
expect(
645+
assertion_response.valid?(
646+
original_challenge,
647+
public_key: credential_public_key,
648+
sign_count: 0
649+
)
650+
).to be_falsy
651+
end
652+
653+
it "doesn't verify" do
654+
expect {
655+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
656+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
657+
end
658+
end
659+
660+
context "when top_origin does not match client top_origin" do
661+
let(:client_top_origin) { "https://malicious.example.com" }
662+
663+
it "is invalid" do
664+
expect(
665+
assertion_response.valid?(
666+
original_challenge,
667+
public_key: credential_public_key,
668+
sign_count: 0
669+
)
670+
).to be_falsy
671+
end
672+
673+
it "doesn't verify" do
674+
expect {
675+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
676+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
677+
end
678+
end
679+
680+
context "when top_origin is not set" do
681+
let(:client_top_origin) { nil }
682+
683+
it "verifies" do
684+
expect(
685+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
686+
).to be_truthy
687+
end
688+
689+
it "is valid" do
690+
expect(
691+
assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)
692+
).to be_truthy
693+
end
694+
end
695+
end
696+
end
697+
end
698+
504699
describe "migrated U2F credential" do
505700
let(:origin) { "https://example.org" }
506701
let(:app_id) { "#{origin}/appid" }

0 commit comments

Comments
 (0)