Skip to content

Commit 11efc20

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

File tree

9 files changed

+372
-1
lines changed

9 files changed

+372
-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: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,165 @@
501501
end
502502
end
503503

504+
describe "top_origin validation" do
505+
let(:client) { WebAuthn::FakeClient.new(origin, encoding: false, 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(assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)).to be_falsy
537+
end
538+
539+
it "doesn't verify" do
540+
expect {
541+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
542+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
543+
end
544+
end
545+
end
546+
547+
context "when top_origin is not set" do
548+
let(:client_top_origin) { nil }
549+
550+
it "is invalid" do
551+
expect(assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)).to be_falsy
552+
end
553+
554+
it "doesn't verify" do
555+
expect {
556+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
557+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
558+
end
559+
end
560+
end
561+
562+
context "when cross_origin is false" do
563+
let(:cross_origin) { "false" }
564+
565+
context "when top_origin is set" do
566+
context "when top_origin matches client top_origin" do
567+
let(:client_top_origin) { top_origin }
568+
569+
it "is invalid" do
570+
expect(assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)).to be_falsy
571+
end
572+
573+
it "doesn't verify" do
574+
expect {
575+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
576+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
577+
end
578+
end
579+
580+
context "when top_origin does not match client top_origin" do
581+
let(:client_top_origin) { "https://malicious.example.com" }
582+
583+
it "is invalid" do
584+
expect(assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)).to be_falsy
585+
end
586+
587+
it "doesn't verify" do
588+
expect {
589+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
590+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
591+
end
592+
end
593+
594+
context "when top_origin is not set" do
595+
let(:client_top_origin) { nil }
596+
597+
it "verifies" do
598+
expect(
599+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
600+
).to be_truthy
601+
end
602+
603+
it "is valid" do
604+
expect(
605+
assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)
606+
).to be_truthy
607+
end
608+
end
609+
end
610+
end
611+
612+
context "when cross_origin is not set" do
613+
let(:cross_origin) { nil }
614+
615+
context "when top_origin is set" do
616+
context "when top_origin matches client top_origin" do
617+
let(:client_top_origin) { top_origin }
618+
619+
it "is invalid" do
620+
expect(assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)).to be_falsy
621+
end
622+
623+
it "doesn't verify" do
624+
expect {
625+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
626+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
627+
end
628+
end
629+
630+
context "when top_origin does not match client top_origin" do
631+
let(:client_top_origin) { "https://malicious.example.com" }
632+
633+
it "is invalid" do
634+
expect(assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)).to be_falsy
635+
end
636+
637+
it "doesn't verify" do
638+
expect {
639+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
640+
}.to raise_exception(WebAuthn::TopOriginVerificationError)
641+
end
642+
end
643+
644+
context "when top_origin is not set" do
645+
let(:client_top_origin) { nil }
646+
647+
it "verifies" do
648+
expect(
649+
assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0)
650+
).to be_truthy
651+
end
652+
653+
it "is valid" do
654+
expect(
655+
assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0)
656+
).to be_truthy
657+
end
658+
end
659+
end
660+
end
661+
end
662+
504663
describe "migrated U2F credential" do
505664
let(:origin) { "https://example.org" }
506665
let(:app_id) { "#{origin}/appid" }

0 commit comments

Comments
 (0)