From d57f060e0abb3a113aad94d30a3d2b8f576a6ab4 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Tue, 21 Oct 2025 12:23:11 -0300 Subject: [PATCH 1/7] feat: verify top_origin when authenticating and registering a credential --- README.md | 8 + lib/webauthn/authenticator_response.rb | 12 ++ lib/webauthn/client_data.rb | 11 + lib/webauthn/configuration.rb | 2 + lib/webauthn/fake_client.rb | 14 +- lib/webauthn/relying_party.rb | 3 + spec/spec_helper.rb | 4 + .../authenticator_assertion_response_spec.rb | 195 ++++++++++++++++++ ...authenticator_attestation_response_spec.rb | 160 ++++++++++++++ 9 files changed, 408 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e36783ae..931511f3 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,14 @@ WebAuthn.configure do |config| # 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. config.allowed_origins = ["https://auth.example.com"] + # When operating within iframes or embedded contexts, you may need to restrict + # which top-level origins are permitted to host WebAuthn ceremonies. + # + # Each entry in this list must match the `topOrigin` reported by the browser + # during registration and authentication. + # + # config.allowed_top_origins = ["https://app.example.com"] + # Relying Party name for display purposes config.rp_name = "Example Inc." diff --git a/lib/webauthn/authenticator_response.rb b/lib/webauthn/authenticator_response.rb index be640b16..fe6cc535 100644 --- a/lib/webauthn/authenticator_response.rb +++ b/lib/webauthn/authenticator_response.rb @@ -14,6 +14,7 @@ class ChallengeVerificationError < VerificationError; end class OriginVerificationError < VerificationError; end class RpIdVerificationError < VerificationError; end class TokenBindingVerificationError < VerificationError; end + class TopOriginVerificationError < VerificationError; end class TypeVerificationError < VerificationError; end class UserPresenceVerificationError < VerificationError; end class UserVerifiedVerificationError < VerificationError; end @@ -33,6 +34,7 @@ def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_v verify_item(:token_binding) verify_item(:challenge, expected_challenge) verify_item(:origin, expected_origin) + verify_item(:top_origin) if needs_top_origin_verification? verify_item(:authenticator_data) verify_item( @@ -84,6 +86,12 @@ def valid_token_binding? client_data.valid_token_binding_format? end + def valid_top_origin? + return false unless client_data.cross_origin + + relying_party.allowed_top_origins.include?(client_data.top_origin) + end + def valid_challenge?(expected_challenge) OpenSSL.secure_compare(client_data.challenge, expected_challenge) end @@ -121,5 +129,9 @@ def rp_id_from_origin(expected_origin) def type raise NotImplementedError, "Please define #type method in subclass" end + + def needs_top_origin_verification? + client_data.cross_origin || client_data.top_origin + end end end diff --git a/lib/webauthn/client_data.rb b/lib/webauthn/client_data.rb index ee5aefed..5400df12 100644 --- a/lib/webauthn/client_data.rb +++ b/lib/webauthn/client_data.rb @@ -31,6 +31,17 @@ def token_binding data["tokenBinding"] end + def cross_origin + case data["crossOrigin"] + when "true" then true + when "false" then false + end + end + + def top_origin + data["topOrigin"] + end + def valid_token_binding_format? if token_binding token_binding.is_a?(Hash) && VALID_TOKEN_BINDING_STATUSES.include?(token_binding["status"]) diff --git a/lib/webauthn/configuration.rb b/lib/webauthn/configuration.rb index 522bef8a..5040925e 100644 --- a/lib/webauthn/configuration.rb +++ b/lib/webauthn/configuration.rb @@ -24,6 +24,8 @@ class Configuration :origin=, :allowed_origins, :allowed_origins=, + :allowed_top_origins, + :allowed_top_origins=, :verify_attestation_statement, :verify_attestation_statement=, :credential_options_timeout, diff --git a/lib/webauthn/fake_client.rb b/lib/webauthn/fake_client.rb index 98ae0d45..027ef704 100644 --- a/lib/webauthn/fake_client.rb +++ b/lib/webauthn/fake_client.rb @@ -10,15 +10,19 @@ module WebAuthn class FakeClient TYPES = { create: "webauthn.create", get: "webauthn.get" }.freeze - attr_reader :origin, :token_binding, :encoding + attr_reader :origin, :cross_origin, :top_origin, :token_binding, :encoding def initialize( origin = fake_origin, + cross_origin: nil, + top_origin: nil, token_binding: nil, authenticator: WebAuthn::FakeAuthenticator.new, encoding: WebAuthn.configuration.encoding ) @origin = origin + @cross_origin = cross_origin + @top_origin = top_origin @token_binding = token_binding @authenticator = authenticator @encoding = encoding @@ -137,6 +141,14 @@ def data_json_for(method, challenge) data[:tokenBinding] = token_binding end + if cross_origin + data[:crossOrigin] = cross_origin + end + + if top_origin + data[:topOrigin] = top_origin + end + data.to_json end diff --git a/lib/webauthn/relying_party.rb b/lib/webauthn/relying_party.rb index 06589d9f..4d9ca97d 100644 --- a/lib/webauthn/relying_party.rb +++ b/lib/webauthn/relying_party.rb @@ -19,6 +19,7 @@ def initialize( algorithms: DEFAULT_ALGORITHMS.dup, encoding: WebAuthn::Encoder::STANDARD_ENCODING, allowed_origins: nil, + allowed_top_origins: nil, origin: nil, id: nil, name: nil, @@ -32,6 +33,7 @@ def initialize( @algorithms = algorithms @encoding = encoding @allowed_origins = allowed_origins + @allowed_top_origins = allowed_top_origins @id = id @name = name @verify_attestation_statement = verify_attestation_statement @@ -46,6 +48,7 @@ def initialize( attr_accessor :algorithms, :encoding, :allowed_origins, + :allowed_top_origins, :id, :name, :verify_attestation_statement, diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 673037f9..9d1c4171 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -63,6 +63,10 @@ def fake_origin "http://localhost" end +def fake_top_origin + "http://localhost.org" +end + def fake_challenge SecureRandom.random_bytes(32) end diff --git a/spec/webauthn/authenticator_assertion_response_spec.rb b/spec/webauthn/authenticator_assertion_response_spec.rb index 165734b7..666dd640 100644 --- a/spec/webauthn/authenticator_assertion_response_spec.rb +++ b/spec/webauthn/authenticator_assertion_response_spec.rb @@ -501,6 +501,201 @@ end end + describe "top_origin validation" do + let(:client) { WebAuthn::FakeClient.new(origin, encoding: false, cross_origin: cross_origin, top_origin: client_top_origin) } + let(:top_origin) { fake_top_origin } + + before do + WebAuthn.configuration.allowed_top_origins = [top_origin] + end + + context "when cross_origin is true" do + let(:cross_origin) { "true" } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when cross_origin is false" do + let(:cross_origin) { "false" } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + end + describe "migrated U2F credential" do let(:origin) { "https://example.org" } let(:app_id) { "#{origin}/appid" } diff --git a/spec/webauthn/authenticator_attestation_response_spec.rb b/spec/webauthn/authenticator_attestation_response_spec.rb index 473d18d6..902d0837 100644 --- a/spec/webauthn/authenticator_attestation_response_spec.rb +++ b/spec/webauthn/authenticator_attestation_response_spec.rb @@ -643,6 +643,166 @@ end end + describe "top_origin validation" do + let(:client) { WebAuthn::FakeClient.new(origin, encoding: false, cross_origin: cross_origin, top_origin: client_top_origin) } + let(:top_origin) { fake_top_origin } + + before do + WebAuthn.configuration.allowed_top_origins = [top_origin] + WebAuthn.configuration.allowed_origins = [origin] + end + + context "when cross_origin is true" do + let(:cross_origin) { "true" } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "is invalid" do + expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when cross_origin is false" do + let(:cross_origin) { "false" } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + end + describe "user presence" do context "when UP is not set" do let(:public_key_credential) { client.create(challenge: original_challenge, user_present: false) } From 08f2b25ea1c2f51e4f939931a1f22044552b2fd2 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Thu, 23 Oct 2025 13:20:24 -0300 Subject: [PATCH 2/7] fix: expect boolean instead of string in crossOrigin --- lib/webauthn/client_data.rb | 5 +---- spec/webauthn/authenticator_assertion_response_spec.rb | 4 ++-- spec/webauthn/authenticator_attestation_response_spec.rb | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/webauthn/client_data.rb b/lib/webauthn/client_data.rb index 5400df12..ea31ca0a 100644 --- a/lib/webauthn/client_data.rb +++ b/lib/webauthn/client_data.rb @@ -32,10 +32,7 @@ def token_binding end def cross_origin - case data["crossOrigin"] - when "true" then true - when "false" then false - end + data["crossOrigin"] end def top_origin diff --git a/spec/webauthn/authenticator_assertion_response_spec.rb b/spec/webauthn/authenticator_assertion_response_spec.rb index 666dd640..f7f9810d 100644 --- a/spec/webauthn/authenticator_assertion_response_spec.rb +++ b/spec/webauthn/authenticator_assertion_response_spec.rb @@ -510,7 +510,7 @@ end context "when cross_origin is true" do - let(:cross_origin) { "true" } + let(:cross_origin) { true } context "when top_origin is set" do context "when top_origin matches client top_origin" do @@ -572,7 +572,7 @@ end context "when cross_origin is false" do - let(:cross_origin) { "false" } + let(:cross_origin) { false } context "when top_origin is set" do context "when top_origin matches client top_origin" do diff --git a/spec/webauthn/authenticator_attestation_response_spec.rb b/spec/webauthn/authenticator_attestation_response_spec.rb index 902d0837..022a99a0 100644 --- a/spec/webauthn/authenticator_attestation_response_spec.rb +++ b/spec/webauthn/authenticator_attestation_response_spec.rb @@ -653,7 +653,7 @@ end context "when cross_origin is true" do - let(:cross_origin) { "true" } + let(:cross_origin) { true } context "when top_origin is set" do context "when top_origin matches client top_origin" do @@ -703,7 +703,7 @@ end context "when cross_origin is false" do - let(:cross_origin) { "false" } + let(:cross_origin) { false } context "when top_origin is set" do context "when top_origin matches client top_origin" do From 687d6cfd25ddb6e672023e3a30dd331fc081e592 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Thu, 23 Oct 2025 13:57:13 -0300 Subject: [PATCH 3/7] test: when allowed_top_origins is nil --- lib/webauthn/authenticator_response.rb | 2 +- .../authenticator_assertion_response_spec.rb | 270 +++++++++++++----- ...authenticator_attestation_response_spec.rb | 251 ++++++++++++---- 3 files changed, 394 insertions(+), 129 deletions(-) diff --git a/lib/webauthn/authenticator_response.rb b/lib/webauthn/authenticator_response.rb index fe6cc535..9ae4e14c 100644 --- a/lib/webauthn/authenticator_response.rb +++ b/lib/webauthn/authenticator_response.rb @@ -89,7 +89,7 @@ def valid_token_binding? def valid_top_origin? return false unless client_data.cross_origin - relying_party.allowed_top_origins.include?(client_data.top_origin) + relying_party.allowed_top_origins&.include?(client_data.top_origin) end def valid_challenge?(expected_challenge) diff --git a/spec/webauthn/authenticator_assertion_response_spec.rb b/spec/webauthn/authenticator_assertion_response_spec.rb index f7f9810d..4360faac 100644 --- a/spec/webauthn/authenticator_assertion_response_spec.rb +++ b/spec/webauthn/authenticator_assertion_response_spec.rb @@ -506,31 +506,17 @@ let(:top_origin) { fake_top_origin } before do - WebAuthn.configuration.allowed_top_origins = [top_origin] + WebAuthn.configuration.allowed_top_origins = allowed_top_origins end - context "when cross_origin is true" do - let(:cross_origin) { true } + context "when allowed_top_origins is not set" do + let(:allowed_top_origins) { nil } - context "when top_origin is set" do - context "when top_origin matches client top_origin" do - let(:client_top_origin) { top_origin } - - it "verifies" do - expect( - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_truthy - end - - it "is valid" do - expect( - assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_truthy - end - end + context "when cross_origin is true" do + let(:cross_origin) { true } - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when top_origin is set" do + let(:client_top_origin) { top_origin } it "is invalid" do expect( @@ -548,35 +534,9 @@ }.to raise_exception(WebAuthn::TopOriginVerificationError) end end - end - - context "when top_origin is not set" do - let(:client_top_origin) { nil } - - it "is invalid" do - expect( - assertion_response.valid?( - original_challenge, - public_key: credential_public_key, - sign_count: 0 - ) - ).to be_falsy - end - it "doesn't verify" do - expect { - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - }.to raise_exception(WebAuthn::TopOriginVerificationError) - end - end - end - - context "when cross_origin is false" do - let(:cross_origin) { false } - - context "when top_origin is set" do - context "when top_origin matches client top_origin" do - let(:client_top_origin) { top_origin } + context "when top_origin is not set" do + let(:client_top_origin) { nil } it "is invalid" do expect( @@ -594,9 +554,13 @@ }.to raise_exception(WebAuthn::TopOriginVerificationError) end end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when top_origin is set" do + let(:client_top_origin) { top_origin } it "is invalid" do expect( @@ -631,13 +595,11 @@ end end end - end - context "when cross_origin is not set" do - let(:cross_origin) { nil } + context "when cross_origin is not set" do + let(:cross_origin) { nil } - context "when top_origin is set" do - context "when top_origin matches client top_origin" do + context "when top_origin is set" do let(:client_top_origin) { top_origin } it "is invalid" do @@ -657,8 +619,70 @@ end end - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + + context "when allowed_top_origins is set" do + let(:allowed_top_origins) { [top_origin] } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } it "is invalid" do expect( @@ -676,20 +700,128 @@ }.to raise_exception(WebAuthn::TopOriginVerificationError) end end + end - context "when top_origin is not set" do - let(:client_top_origin) { nil } - - it "verifies" do - expect( - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_truthy + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end end + end + end - it "is valid" do - expect( - assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_truthy + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end end end end diff --git a/spec/webauthn/authenticator_attestation_response_spec.rb b/spec/webauthn/authenticator_attestation_response_spec.rb index 022a99a0..2e09be7c 100644 --- a/spec/webauthn/authenticator_attestation_response_spec.rb +++ b/spec/webauthn/authenticator_attestation_response_spec.rb @@ -648,32 +648,32 @@ let(:top_origin) { fake_top_origin } before do - WebAuthn.configuration.allowed_top_origins = [top_origin] + WebAuthn.configuration.allowed_top_origins = allowed_top_origins WebAuthn.configuration.allowed_origins = [origin] end - context "when cross_origin is true" do - let(:cross_origin) { true } + context "when allowed_top_origins is not set" do + let(:allowed_top_origins) { nil } - context "when top_origin is set" do - context "when top_origin matches client top_origin" do + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do let(:client_top_origin) { top_origin } - it "verifies" do - expect( - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - ).to be_truthy + it "is invalid" do + expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy end - it "is valid" do - expect( - attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) - ).to be_truthy + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) end end - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when top_origin is not set" do + let(:client_top_origin) { nil } it "is invalid" do expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy @@ -687,26 +687,10 @@ end end - context "when top_origin is not set" do - let(:client_top_origin) { nil } - - it "is invalid" do - expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy - end - - it "doesn't verify" do - expect { - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - }.to raise_exception(WebAuthn::TopOriginVerificationError) - end - end - end - - context "when cross_origin is false" do - let(:cross_origin) { false } + context "when cross_origin is false" do + let(:cross_origin) { false } - context "when top_origin is set" do - context "when top_origin matches client top_origin" do + context "when top_origin is set" do let(:client_top_origin) { top_origin } it "is invalid" do @@ -720,8 +704,28 @@ end end - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } it "is invalid" do expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy @@ -752,26 +756,51 @@ end end - context "when cross_origin is not set" do - let(:cross_origin) { nil } + context "when allowed_top_origins is set" do + let(:allowed_top_origins) { [top_origin] } - context "when top_origin is set" do - context "when top_origin matches client top_origin" do - let(:client_top_origin) { top_origin } + context "when cross_origin is true" do + let(:cross_origin) { true } - it "is invalid" do - expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end end - it "doesn't verify" do - expect { - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - }.to raise_exception(WebAuthn::TopOriginVerificationError) + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end end end - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when top_origin is not set" do + let(:client_top_origin) { nil } it "is invalid" do expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy @@ -783,20 +812,124 @@ }.to raise_exception(WebAuthn::TopOriginVerificationError) end end + end - context "when top_origin is not set" do - let(:client_top_origin) { nil } + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end - it "verifies" do - expect( - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - ).to be_truthy + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end end - it "is valid" do - expect( - attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) - ).to be_truthy + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end end end end From e4f0877b10ab801c15b0d5da5fd05389db0eb7d2 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Thu, 23 Oct 2025 14:34:42 -0300 Subject: [PATCH 4/7] feat: add verify_top_origin flag to enable/disable topOrigin validation --- README.md | 3 + lib/webauthn/authenticator_response.rb | 2 +- lib/webauthn/configuration.rb | 2 + lib/webauthn/relying_party.rb | 3 + .../authenticator_assertion_response_spec.rb | 591 +++++++++++++----- ...authenticator_attestation_response_spec.rb | 556 ++++++++++++---- 6 files changed, 882 insertions(+), 275 deletions(-) diff --git a/README.md b/README.md index 931511f3..5841bf38 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ WebAuthn.configure do |config| # When operating within iframes or embedded contexts, you may need to restrict # which top-level origins are permitted to host WebAuthn ceremonies. # + # To enable this check, set the following configuration (disabled by default): + # config.verify_top_origin = false + # # Each entry in this list must match the `topOrigin` reported by the browser # during registration and authentication. # diff --git a/lib/webauthn/authenticator_response.rb b/lib/webauthn/authenticator_response.rb index 9ae4e14c..f54cd7e0 100644 --- a/lib/webauthn/authenticator_response.rb +++ b/lib/webauthn/authenticator_response.rb @@ -131,7 +131,7 @@ def type end def needs_top_origin_verification? - client_data.cross_origin || client_data.top_origin + relying_party.verify_top_origin && (client_data.cross_origin || client_data.top_origin) end end end diff --git a/lib/webauthn/configuration.rb b/lib/webauthn/configuration.rb index 5040925e..69a8eca3 100644 --- a/lib/webauthn/configuration.rb +++ b/lib/webauthn/configuration.rb @@ -28,6 +28,8 @@ class Configuration :allowed_top_origins=, :verify_attestation_statement, :verify_attestation_statement=, + :verify_top_origin, + :verify_top_origin=, :credential_options_timeout, :credential_options_timeout=, :silent_authentication, diff --git a/lib/webauthn/relying_party.rb b/lib/webauthn/relying_party.rb index 4d9ca97d..dd541b20 100644 --- a/lib/webauthn/relying_party.rb +++ b/lib/webauthn/relying_party.rb @@ -24,6 +24,7 @@ def initialize( id: nil, name: nil, verify_attestation_statement: true, + verify_top_origin: false, credential_options_timeout: 120000, silent_authentication: false, acceptable_attestation_types: ['None', 'Self', 'Basic', 'AttCA', 'Basic_or_AttCA', 'AnonCA'], @@ -37,6 +38,7 @@ def initialize( @id = id @name = name @verify_attestation_statement = verify_attestation_statement + @verify_top_origin = verify_top_origin @credential_options_timeout = credential_options_timeout @silent_authentication = silent_authentication @acceptable_attestation_types = acceptable_attestation_types @@ -52,6 +54,7 @@ def initialize( :id, :name, :verify_attestation_statement, + :verify_top_origin, :credential_options_timeout, :silent_authentication, :acceptable_attestation_types, diff --git a/spec/webauthn/authenticator_assertion_response_spec.rb b/spec/webauthn/authenticator_assertion_response_spec.rb index 4360faac..10cd7767 100644 --- a/spec/webauthn/authenticator_assertion_response_spec.rb +++ b/spec/webauthn/authenticator_assertion_response_spec.rb @@ -507,145 +507,166 @@ before do WebAuthn.configuration.allowed_top_origins = allowed_top_origins + WebAuthn.configuration.verify_top_origin = verify_top_origin end - context "when allowed_top_origins is not set" do - let(:allowed_top_origins) { nil } + context "when verify_top_origin is false" do + let(:verify_top_origin) { false } - context "when cross_origin is true" do - let(:cross_origin) { true } + context "when allowed_top_origins is not set" do + let(:allowed_top_origins) { nil } - context "when top_origin is set" do - let(:client_top_origin) { top_origin } + context "when cross_origin is true" do + let(:cross_origin) { true } - it "is invalid" do - expect( - assertion_response.valid?( - original_challenge, - public_key: credential_public_key, - sign_count: 0 - ) - ).to be_falsy - end + context "when top_origin is set" do + let(:client_top_origin) { top_origin } - it "doesn't verify" do - expect { - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - }.to raise_exception(WebAuthn::TopOriginVerificationError) + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end end - end - context "when top_origin is not set" do - let(:client_top_origin) { nil } + context "when top_origin is not set" do + let(:client_top_origin) { nil } - it "is invalid" do - expect( - assertion_response.valid?( - original_challenge, - public_key: credential_public_key, - sign_count: 0 - ) - ).to be_falsy - end + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end - it "doesn't verify" do - expect { - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - }.to raise_exception(WebAuthn::TopOriginVerificationError) + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end end end - end - context "when cross_origin is false" do - let(:cross_origin) { false } + context "when cross_origin is false" do + let(:cross_origin) { false } - context "when top_origin is set" do - let(:client_top_origin) { top_origin } + context "when top_origin is set" do + let(:client_top_origin) { top_origin } - it "is invalid" do - expect( - assertion_response.valid?( - original_challenge, - public_key: credential_public_key, - sign_count: 0 - ) - ).to be_falsy - end + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end - it "doesn't verify" do - expect { - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - }.to raise_exception(WebAuthn::TopOriginVerificationError) + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end end - end - context "when top_origin is not set" do - let(:client_top_origin) { nil } + context "when top_origin is not set" do + let(:client_top_origin) { nil } - it "verifies" do - expect( - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_truthy - end + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end - it "is valid" do - expect( - assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_truthy + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end end end - end - context "when cross_origin is not set" do - let(:cross_origin) { nil } + context "when cross_origin is not set" do + let(:cross_origin) { nil } - context "when top_origin is set" do - let(:client_top_origin) { top_origin } + context "when top_origin is set" do + let(:client_top_origin) { top_origin } - it "is invalid" do - expect( - assertion_response.valid?( - original_challenge, - public_key: credential_public_key, - sign_count: 0 - ) - ).to be_falsy - end + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end - it "doesn't verify" do - expect { - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - }.to raise_exception(WebAuthn::TopOriginVerificationError) + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end end - end - context "when top_origin is not set" do - let(:client_top_origin) { nil } + context "when top_origin is not set" do + let(:client_top_origin) { nil } - it "verifies" do - expect( - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_truthy - end + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end - it "is valid" do - expect( - assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_truthy + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end end end end - end - context "when allowed_top_origins is set" do - let(:allowed_top_origins) { [top_origin] } + context "when allowed_top_origins is set" do + let(:allowed_top_origins) { [top_origin] } - context "when cross_origin is true" do - let(:cross_origin) { true } + context "when cross_origin is true" do + let(:cross_origin) { true } - context "when top_origin is set" do - context "when top_origin matches client top_origin" do - let(:client_top_origin) { top_origin } + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } it "verifies" do expect( @@ -659,9 +680,129 @@ ).to be_truthy end end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + end + end + + context "when verify_top_origin is true" do + let(:verify_top_origin) { true } - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when allowed_top_origins is not set" do + let(:allowed_top_origins) { nil } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } it "is invalid" do expect( @@ -679,35 +820,9 @@ }.to raise_exception(WebAuthn::TopOriginVerificationError) end end - end - - context "when top_origin is not set" do - let(:client_top_origin) { nil } - - it "is invalid" do - expect( - assertion_response.valid?( - original_challenge, - public_key: credential_public_key, - sign_count: 0 - ) - ).to be_falsy - end - - it "doesn't verify" do - expect { - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - }.to raise_exception(WebAuthn::TopOriginVerificationError) - end - end - end - context "when cross_origin is false" do - let(:cross_origin) { false } - - context "when top_origin is set" do - context "when top_origin matches client top_origin" do - let(:client_top_origin) { top_origin } + context "when top_origin is not set" do + let(:client_top_origin) { nil } it "is invalid" do expect( @@ -725,9 +840,13 @@ }.to raise_exception(WebAuthn::TopOriginVerificationError) end end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when top_origin is set" do + let(:client_top_origin) { top_origin } it "is invalid" do expect( @@ -762,13 +881,11 @@ end end end - end - context "when cross_origin is not set" do - let(:cross_origin) { nil } + context "when cross_origin is not set" do + let(:cross_origin) { nil } - context "when top_origin is set" do - context "when top_origin matches client top_origin" do + context "when top_origin is set" do let(:client_top_origin) { top_origin } it "is invalid" do @@ -788,8 +905,70 @@ end end - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + + context "when allowed_top_origins is set" do + let(:allowed_top_origins) { [top_origin] } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } it "is invalid" do expect( @@ -807,20 +986,128 @@ }.to raise_exception(WebAuthn::TopOriginVerificationError) end end + end - context "when top_origin is not set" do - let(:client_top_origin) { nil } + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end - it "verifies" do - expect( - assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_truthy + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end end - it "is valid" do - expect( - assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) - ).to be_truthy + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end end end end diff --git a/spec/webauthn/authenticator_attestation_response_spec.rb b/spec/webauthn/authenticator_attestation_response_spec.rb index 2e09be7c..5b05df3b 100644 --- a/spec/webauthn/authenticator_attestation_response_spec.rb +++ b/spec/webauthn/authenticator_attestation_response_spec.rb @@ -648,123 +648,168 @@ let(:top_origin) { fake_top_origin } before do + WebAuthn.configuration.verify_top_origin = verify_top_origin WebAuthn.configuration.allowed_top_origins = allowed_top_origins WebAuthn.configuration.allowed_origins = [origin] end - context "when allowed_top_origins is not set" do - let(:allowed_top_origins) { nil } + context "when verify_top_origin is false" do + let(:verify_top_origin) { false } - context "when cross_origin is true" do - let(:cross_origin) { true } + context "when allowed_top_origins is not set" do + let(:allowed_top_origins) { nil } - context "when top_origin is set" do - let(:client_top_origin) { top_origin } + context "when cross_origin is true" do + let(:cross_origin) { true } - it "is invalid" do - expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy - end + context "when top_origin is set" do + let(:client_top_origin) { top_origin } - it "doesn't verify" do - expect { - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - }.to raise_exception(WebAuthn::TopOriginVerificationError) + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end end - end - context "when top_origin is not set" do - let(:client_top_origin) { nil } + context "when top_origin is not set" do + let(:client_top_origin) { nil } - it "is invalid" do - expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy - end + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end - it "doesn't verify" do - expect { - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - }.to raise_exception(WebAuthn::TopOriginVerificationError) + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end end end - end - context "when cross_origin is false" do - let(:cross_origin) { false } + context "when cross_origin is false" do + let(:cross_origin) { false } - context "when top_origin is set" do - let(:client_top_origin) { top_origin } + context "when top_origin is set" do + let(:client_top_origin) { top_origin } - it "is invalid" do - expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy - end + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end - it "doesn't verify" do - expect { - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - }.to raise_exception(WebAuthn::TopOriginVerificationError) + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end end - end - context "when top_origin is not set" do - let(:client_top_origin) { nil } + context "when top_origin is not set" do + let(:client_top_origin) { nil } - it "verifies" do - expect( - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - ).to be_truthy - end + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end - it "is valid" do - expect( - attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) - ).to be_truthy + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end end end - end - context "when cross_origin is not set" do - let(:cross_origin) { nil } + context "when cross_origin is not set" do + let(:cross_origin) { nil } - context "when top_origin is set" do - let(:client_top_origin) { top_origin } + context "when top_origin is set" do + let(:client_top_origin) { top_origin } - it "is invalid" do - expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy - end + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end - it "doesn't verify" do - expect { - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - }.to raise_exception(WebAuthn::TopOriginVerificationError) + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end end - end - context "when top_origin is not set" do - let(:client_top_origin) { nil } + context "when top_origin is not set" do + let(:client_top_origin) { nil } - it "verifies" do - expect( - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - ).to be_truthy - end + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end - it "is valid" do - expect( - attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) - ).to be_truthy + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end end end end - end - context "when allowed_top_origins is set" do - let(:allowed_top_origins) { [top_origin] } + context "when allowed_top_origins is set" do + let(:allowed_top_origins) { [top_origin] } - context "when cross_origin is true" do - let(:cross_origin) { true } + context "when cross_origin is true" do + let(:cross_origin) { true } - context "when top_origin is set" do - context "when top_origin matches client top_origin" do - let(:client_top_origin) { top_origin } + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } it "verifies" do expect( @@ -778,9 +823,129 @@ ).to be_truthy end end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + end + end + + context "when verify_top_origin is true" do + let(:verify_top_origin) { true } + + context "when allowed_top_origins is not set" do + let(:allowed_top_origins) { nil } - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } it "is invalid" do expect( @@ -797,29 +962,9 @@ }.to raise_exception(WebAuthn::TopOriginVerificationError) end end - end - context "when top_origin is not set" do - let(:client_top_origin) { nil } - - it "is invalid" do - expect(attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins)).to be_falsy - end - - it "doesn't verify" do - expect { - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - }.to raise_exception(WebAuthn::TopOriginVerificationError) - end - end - end - - context "when cross_origin is false" do - let(:cross_origin) { false } - - context "when top_origin is set" do - context "when top_origin matches client top_origin" do - let(:client_top_origin) { top_origin } + context "when top_origin is not set" do + let(:client_top_origin) { nil } it "is invalid" do expect( @@ -836,9 +981,13 @@ }.to raise_exception(WebAuthn::TopOriginVerificationError) end end + end - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } it "is invalid" do expect( @@ -872,13 +1021,11 @@ end end end - end - context "when cross_origin is not set" do - let(:cross_origin) { nil } + context "when cross_origin is not set" do + let(:cross_origin) { nil } - context "when top_origin is set" do - context "when top_origin matches client top_origin" do + context "when top_origin is set" do let(:client_top_origin) { top_origin } it "is invalid" do @@ -897,8 +1044,69 @@ end end - context "when top_origin does not match client top_origin" do - let(:client_top_origin) { "https://malicious.example.com" } + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + + context "when allowed_top_origins is set" do + let(:allowed_top_origins) { [top_origin] } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } it "is invalid" do expect( @@ -915,20 +1123,124 @@ }.to raise_exception(WebAuthn::TopOriginVerificationError) end end + end - context "when top_origin is not set" do - let(:client_top_origin) { nil } + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end - it "verifies" do - expect( - attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) - ).to be_truthy + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end end - it "is valid" do - expect( - attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) - ).to be_truthy + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end end end end From 49b642e13289b2308be54ced1686b99a6f49ece4 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Thu, 23 Oct 2025 17:44:21 -0300 Subject: [PATCH 5/7] chore: rename config to `verify_cross_origin` --- README.md | 2 +- lib/webauthn/authenticator_response.rb | 2 +- lib/webauthn/configuration.rb | 4 ++-- lib/webauthn/relying_party.rb | 6 +++--- spec/webauthn/authenticator_assertion_response_spec.rb | 10 +++++----- .../authenticator_attestation_response_spec.rb | 10 +++++----- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 5841bf38..4c30a288 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ WebAuthn.configure do |config| # which top-level origins are permitted to host WebAuthn ceremonies. # # To enable this check, set the following configuration (disabled by default): - # config.verify_top_origin = false + # config.verify_cross_origin = false # # Each entry in this list must match the `topOrigin` reported by the browser # during registration and authentication. diff --git a/lib/webauthn/authenticator_response.rb b/lib/webauthn/authenticator_response.rb index f54cd7e0..c31f45d4 100644 --- a/lib/webauthn/authenticator_response.rb +++ b/lib/webauthn/authenticator_response.rb @@ -131,7 +131,7 @@ def type end def needs_top_origin_verification? - relying_party.verify_top_origin && (client_data.cross_origin || client_data.top_origin) + relying_party.verify_cross_origin && (client_data.cross_origin || client_data.top_origin) end end end diff --git a/lib/webauthn/configuration.rb b/lib/webauthn/configuration.rb index 69a8eca3..589a05bd 100644 --- a/lib/webauthn/configuration.rb +++ b/lib/webauthn/configuration.rb @@ -28,8 +28,8 @@ class Configuration :allowed_top_origins=, :verify_attestation_statement, :verify_attestation_statement=, - :verify_top_origin, - :verify_top_origin=, + :verify_cross_origin, + :verify_cross_origin=, :credential_options_timeout, :credential_options_timeout=, :silent_authentication, diff --git a/lib/webauthn/relying_party.rb b/lib/webauthn/relying_party.rb index dd541b20..6863e336 100644 --- a/lib/webauthn/relying_party.rb +++ b/lib/webauthn/relying_party.rb @@ -24,7 +24,7 @@ def initialize( id: nil, name: nil, verify_attestation_statement: true, - verify_top_origin: false, + verify_cross_origin: false, credential_options_timeout: 120000, silent_authentication: false, acceptable_attestation_types: ['None', 'Self', 'Basic', 'AttCA', 'Basic_or_AttCA', 'AnonCA'], @@ -38,7 +38,7 @@ def initialize( @id = id @name = name @verify_attestation_statement = verify_attestation_statement - @verify_top_origin = verify_top_origin + @verify_cross_origin = verify_cross_origin @credential_options_timeout = credential_options_timeout @silent_authentication = silent_authentication @acceptable_attestation_types = acceptable_attestation_types @@ -54,7 +54,7 @@ def initialize( :id, :name, :verify_attestation_statement, - :verify_top_origin, + :verify_cross_origin, :credential_options_timeout, :silent_authentication, :acceptable_attestation_types, diff --git a/spec/webauthn/authenticator_assertion_response_spec.rb b/spec/webauthn/authenticator_assertion_response_spec.rb index 10cd7767..686668cd 100644 --- a/spec/webauthn/authenticator_assertion_response_spec.rb +++ b/spec/webauthn/authenticator_assertion_response_spec.rb @@ -507,11 +507,11 @@ before do WebAuthn.configuration.allowed_top_origins = allowed_top_origins - WebAuthn.configuration.verify_top_origin = verify_top_origin + WebAuthn.configuration.verify_cross_origin = verify_cross_origin end - context "when verify_top_origin is false" do - let(:verify_top_origin) { false } + context "when verify_cross_origin is false" do + let(:verify_cross_origin) { false } context "when allowed_top_origins is not set" do let(:allowed_top_origins) { nil } @@ -792,8 +792,8 @@ end end - context "when verify_top_origin is true" do - let(:verify_top_origin) { true } + context "when verify_cross_origin is true" do + let(:verify_cross_origin) { true } context "when allowed_top_origins is not set" do let(:allowed_top_origins) { nil } diff --git a/spec/webauthn/authenticator_attestation_response_spec.rb b/spec/webauthn/authenticator_attestation_response_spec.rb index 5b05df3b..0d382ab4 100644 --- a/spec/webauthn/authenticator_attestation_response_spec.rb +++ b/spec/webauthn/authenticator_attestation_response_spec.rb @@ -648,13 +648,13 @@ let(:top_origin) { fake_top_origin } before do - WebAuthn.configuration.verify_top_origin = verify_top_origin + WebAuthn.configuration.verify_cross_origin = verify_cross_origin WebAuthn.configuration.allowed_top_origins = allowed_top_origins WebAuthn.configuration.allowed_origins = [origin] end - context "when verify_top_origin is false" do - let(:verify_top_origin) { false } + context "when verify_cross_origin is false" do + let(:verify_cross_origin) { false } context "when allowed_top_origins is not set" do let(:allowed_top_origins) { nil } @@ -935,8 +935,8 @@ end end - context "when verify_top_origin is true" do - let(:verify_top_origin) { true } + context "when verify_cross_origin is true" do + let(:verify_cross_origin) { true } context "when allowed_top_origins is not set" do let(:allowed_top_origins) { nil } From 0f0e32487c878150ce8ba22813b99097599b93d5 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Thu, 23 Oct 2025 17:55:17 -0300 Subject: [PATCH 6/7] docs(README): explain different uses of cross origin --- README.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4c30a288..7b5f2132 100644 --- a/README.md +++ b/README.md @@ -107,13 +107,27 @@ WebAuthn.configure do |config| # When operating within iframes or embedded contexts, you may need to restrict # which top-level origins are permitted to host WebAuthn ceremonies. # - # To enable this check, set the following configuration (disabled by default): - # config.verify_cross_origin = false + # crossOrigin / topOrigin verification is DISABLED by default: + # config.verify_cross_origin = false # - # Each entry in this list must match the `topOrigin` reported by the browser - # during registration and authentication. + # When `verify_cross_origin` is false, any `crossOrigin` / `topOrigin` values reported by the browser + # are ignored. As a result, credentials created or used within a cross-origin iframe will be treated + # as valid. # - # config.allowed_top_origins = ["https://app.example.com"] + # When `verify_cross_origin` is true, you can either: + # + # (A) Allow only specific top-level origins to embed your ceremony + # (each entry must match the browser-reported `topOrigin` during registration/authentication): + # + # config.allowed_top_origins = ["https://app.example.com"] + # + # (B) Forbid ANY cross-origin iframe usage altogether + # (this rejects creation/authentication whenever `crossOrigin` is true): + # + # config.allowed_top_origins = [] + # + # Note: if `verify_cross_origin` is not enabled, any values set in `allowed_top_origins` + # will be ignored. # Relying Party name for display purposes config.rp_name = "Example Inc." From 317228e97393f2d4541afca1e3e8f6561b8b30a0 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Fri, 24 Oct 2025 11:41:12 -0300 Subject: [PATCH 7/7] docs: update CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f47b873..31d2d954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Add support for crossOrigin/topOrigin verification during credential registration and authentication. [#486](https://github.com/cedarcode/webauthn-ruby/pull/486) [@nicolastemciuc] + ## [v3.4.3] - 2025-10-23 ### Fixed @@ -494,3 +498,4 @@ Note: Both additions should help making it compatible with Chrome for Android 70 [@jdongelmans]: https://github.com/jdongelmans [@petergoldstein]: https://github.com/petergoldstein [@ClearlyClaire]: https://github.com/ClearlyClaire +[@nicolastemciuc]: https://github.com/nicolastemciuc