Commit 8123d24

mo khan <mo@mokhan.ca>
2026-08-18 02:14:16
Require and bind signatures on saml documents
An unsigned Response or Assertion was valid whenever its issuer was registered, and a signature was verified without any check on which element it covered. Either flaw on its own lets an attacker forge an assertion for any user, so both are fixed together. Response and Assertion now require a signature, and a bare Assertion delivered as the whole document requires its own, since no enclosing Response vouches for it. A signature is trusted only when one of its references resolves, by node identity in the live document, to the element the signature hangs off. Configuration#signature_required, defaulting to true, restores the 1.4.1 behaviour for an identity provider that signs nothing. Fixes GHSA-hx7c-98jm-7r55 Fixes GHSA-q726-45rx-qvrw
1 parent 35b2592
lib/saml/kit/concerns/trustable.rb
@@ -11,9 +11,15 @@ module Saml
       included do
         validate :must_have_valid_signature, unless: :signature_verified
         validate :must_be_registered
+        validate :must_be_signed, if: :signature_required?
         validate :must_be_trusted
       end
 
+      # Returns true when this document must carry a signature to be valid.
+      def signature_required?
+        configuration.signature_required && signature_required_by_type?
+      end
+
       # Returns true when the document has an embedded XML Signature or has
       # been verified externally.
       def signed?
@@ -65,9 +71,40 @@ module Saml
         errors.add(:provider, error_message(:unregistered))
       end
 
+      # Whether a document of this type is worthless unless signed.
+      #
+      # A request may legitimately arrive unsigned, which is what
+      # `WantAuthnRequestsSigned="false"` means, so the default is false. A
+      # document that asserts an identity carries no authenticity guarantee at
+      # all when unsigned, so Response and Assertion override this.
+      def signature_required_by_type?
+        false
+      end
+
+      def must_be_signed
+        return unless expected_type?
+        return if signature_covers_document?
+
+        errors.add(:base, error_message(:unsigned))
+      end
+
+      # Whether a signature exists over the content this document asserts.
+      # Response overrides this, because a signature on either the Response or
+      # the Assertion covers the identity being asserted.
+      def signature_covers_document?
+        signed?
+      end
+
+      # The trust counterpart of `signature_covers_document?`. Kept separate
+      # from the public `trusted?`, which answers the narrower question of
+      # whether *this* element's own signature is trusted.
+      def signature_trusted?
+        trusted?
+      end
+
       def must_be_trusted
-        return if trusted?
-        return if provider.present? && !signed?
+        return if signature_trusted?
+        return if !signature_required? && provider.present? && !signed?
 
         errors.add(:fingerprint, error_message(:invalid_fingerprint))
       end
lib/saml/kit/locales/en.yml
@@ -11,6 +11,7 @@ en:
         must_contain_single_assertion: "must contain single Assertion."
         must_match_issuer: "must match entityId."
         unregistered: "is unregistered."
+        unsigned: "must be signed."
       AuthnRequest:
         invalid: "must contain AuthnRequest."
         invalid_fingerprint: "is not registered."
@@ -34,10 +35,12 @@ en:
         invalid_version: "must be 2.0."
         unregistered: "must originate from registered identity provider."
         must_contain_single_assertion: "must contain a single Assertion."
+        unsigned: "must be signed."
       Signature:
         certificate: "Not valid before %{not_before}. Not valid after %{not_after}."
         digest_value: "is invalid."
         empty: "is missing."
+        reference: "must reference the element it is attached to."
         signature: "is invalid."
       SPSSODescriptor:
         invalid: "must contain SPSSODescriptor."
lib/saml/kit/assertion.rb
@@ -89,9 +89,7 @@ module Saml
       end
 
       def decryptable?
-        return true unless encrypted?
-
-        !@cannot_decrypt
+        !encrypted? || !@cannot_decrypt
       end
 
       def to_s
@@ -100,6 +98,12 @@ module Saml
 
       private
 
+      # A nested Assertion is covered by the enclosing Response's policy; a
+      # bare one is the whole document and has nothing else vouching for it.
+      def signature_required_by_type?
+        to_nokogiri == to_nokogiri.document.root
+      end
+
       def decrypt(decryptor)
         encrypted_assertion = at_xpath('./xmlenc:EncryptedData')
         @encrypted = encrypted_assertion.present?
lib/saml/kit/configuration.rb
@@ -46,6 +46,13 @@ module Saml
       attr_accessor :logger
       # The total allowable clock drift for session timeout validation.
       attr_accessor :clock_drift
+      # Whether a document that asserts an identity must carry a signature.
+      #
+      # An unsigned Response or Assertion proves nothing about who issued it,
+      # so this defaults to true and should stay that way. Set it to false only
+      # to keep an identity provider that signs nothing working while it is
+      # being fixed, and understand that doing so accepts forged assertions.
+      attr_accessor :signature_required
 
       def initialize
         @clock_drift = 30.seconds
@@ -55,6 +62,7 @@ module Saml
         @registry = DefaultRegistry.new
         @session_timeout = 3.hours
         @signature_method = :SHA256
+        @signature_required = true
         yield self if block_given?
       end
 
lib/saml/kit/response.rb
@@ -41,10 +41,26 @@ module Saml
 
       private
 
+      def signature_required_by_type?
+        true
+      end
+
+      # `signed?` and `trusted?` deliberately report only this element's own
+      # signature, but signing only the Assertion is a common identity provider
+      # configuration, and that signature covers the identity being asserted.
+      def signature_covers_document?
+        signed? || assertion.signed?
+      end
+
+      def signature_trusted?
+        super || (assertion.signed? && assertion.trusted?)
+      end
+
       def must_be_valid_assertion
         assertion.valid?
         assertion.each_error do |attribute, error|
-          errors.add(attribute == :base ? :assertion : attribute, error)
+          attribute = :assertion if attribute == :base
+          errors.add(attribute, error) unless errors.added?(attribute, error)
         end
       end
 
lib/saml/kit/signature.rb
@@ -11,6 +11,7 @@ module Saml
 
       validate :validate_signature
       validate :validate_certificate
+      validate :validate_reference
 
       attr_reader :name
 
@@ -20,18 +21,24 @@ module Saml
       end
 
       # Returns the embedded X509 Certificate
+      #
+      # Memoized with a `defined?` guard rather than `||=` because a signature
+      # without KeyInfo legitimately has no certificate, and callers depend on
+      # the nil.
       def certificate
-        xpath = './ds:KeyInfo/ds:X509Data/ds:X509Certificate'
-        value = at_xpath(xpath).try(:text)
-        return if value.nil?
+        return @certificate if defined?(@certificate)
 
-        ::Xml::Kit::Certificate.new(value, use: :signing)
+        value = at_xpath('./ds:KeyInfo/ds:X509Data/ds:X509Certificate').try(:text)
+        @certificate =
+          value.nil? ? nil : ::Xml::Kit::Certificate.new(value, use: :signing)
       end
 
       # Returns true when the fingerprint of the certificate matches one of
       # the certificates registered in the metadata.
       def trusted?(metadata)
         return false if metadata.nil?
+        return false if certificate.nil?
+        return false unless reference.bound?
 
         metadata.matches?(certificate.fingerprint, use: :signing).present?
       end
@@ -66,15 +73,9 @@ module Saml
       end
 
       def transforms
-        xpath = xpath_for([
-          '.',
-          'ds:SignedInfo',
-          'ds:Reference',
-          'ds:Transforms',
-          'ds:Transform',
-          '@Algorithm',
-        ])
-        node.search(xpath, Saml::Kit::Document::NAMESPACES).try(:map, &:value)
+        xpath = './ds:SignedInfo/ds:Reference/ds:Transforms/ds:Transform'
+        node.search("#{xpath}/@Algorithm", Saml::Kit::Document::NAMESPACES)
+          .try(:map, &:value)
       end
 
       # Returns the XML Hash.
@@ -109,6 +110,17 @@ module Saml
         errors.add(:base, error.message)
       end
 
+      def validate_reference
+        return unless present?
+        return if reference.bound?
+
+        errors.add(:reference, error_message(:reference))
+      end
+
+      def reference
+        @reference ||= SignatureReference.new(node)
+      end
+
       def validate_certificate(now = Time.now.utc)
         return unless certificate.present?
         return if certificate.active?(now)
@@ -128,11 +140,7 @@ module Saml
       end
 
       def dsignature
-        @dsignature ||= Xmldsig::Signature.new(node, 'ID=$uri or @Id')
-      end
-
-      def xpath_for(segments)
-        segments.join('/')
+        @dsignature ||= Xmldsig::Signature.new(node, SignatureReference::ID_ATTR)
       end
     end
   end
lib/saml/kit/signature_reference.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module Saml
+  module Kit
+    # This class is responsible for deciding whether an xml digital signature
+    # actually covers the element it is attached to.
+    #
+    # A cryptographically intact signature only proves that *some* element in
+    # the document is unmodified. xmldsig resolves a Reference by searching the
+    # whole document for a matching ID, so without this check an attacker can
+    # copy a valid signature onto a forged element and hide the element it
+    # really covers somewhere the schema still permits, e.g. saml:Advice or
+    # ds:Object.
+    class SignatureReference
+      # The id_attr handed to Xmldsig::Signature, which expands it into
+      # "//*[@ID=$uri or @Id=$uri]". Both resolvers must agree on this or the
+      # element we check is not the element that was digested.
+      ID_ATTR = 'ID=$uri or @Id'
+      XPATH = './ds:SignedInfo/ds:Reference'
+
+      def initialize(node)
+        @node = node
+      end
+
+      # Returns true when some Reference resolves to the element the signature
+      # hangs off, and resolves to nothing else.
+      #
+      # Every Reference lives inside ds:SignedInfo, which ds:SignatureValue
+      # covers, so an attacker replaying a signature cannot add a Reference or
+      # retarget an existing one. Finding our element among them is therefore
+      # enough. SAML 2.0 core 5.4.2 permits only one, but documents carrying
+      # more exist, and each extra Reference only widens what the signature
+      # already commits to.
+      def bound?
+        return @bound if defined?(@bound)
+
+        @bound = binds_parent_element?
+      end
+
+      private
+
+      attr_reader :node
+
+      def binds_parent_element?
+        return false if node.nil? || signed.nil?
+
+        references.any? { |reference| covers_signed_element?(reference) }
+      end
+
+      def covers_signed_element?(reference)
+        uri = reference.attribute('URI').try(:value).to_s
+        # An empty URI digests the whole document, so it covers every element in
+        # it and leaves nowhere to hide an original.
+        return true if uri.empty?
+
+        id.present? && uri == "##{id}" && resolves_to_signed_element?
+      end
+
+      # `bound?` memoizes the one answer this class exists to give, so only the
+      # XPath search below is worth caching; the rest are O(1) node lookups.
+      def signed
+        node.parent
+      end
+
+      def references
+        @references ||= node.search(XPATH, Saml::Kit::Document::NAMESPACES)
+      end
+
+      def id
+        signed.attribute('ID').try(:value) || signed.attribute('Id').try(:value)
+      end
+
+      # Duplicate IDs are only rejected when schema validation runs, and an
+      # XPath lookup silently returns the first match, so require exactly one.
+      def resolves_to_signed_element?
+        matches = node.document.xpath(
+          "//*[@#{ID_ATTR}=$uri]",
+          Saml::Kit::Document::NAMESPACES,
+          'uri' => id
+        )
+        matches.count == 1 && matches.first == signed
+      end
+    end
+  end
+end
lib/saml/kit/version.rb
@@ -2,6 +2,6 @@
 
 module Saml
   module Kit
-    VERSION = '1.4.1'
+    VERSION = '1.5.0'
   end
 end
lib/saml/kit.rb
@@ -44,6 +44,7 @@ require 'saml/kit/logout_request'
 require 'saml/kit/metadata'
 require 'saml/kit/deprecated/metadata'
 require 'saml/kit/null_assertion'
+require 'saml/kit/signature_reference'
 require 'saml/kit/organization'
 require 'saml/kit/parser'
 require 'saml/kit/composite_metadata'
spec/saml/kit/response_spec.rb
@@ -585,26 +585,28 @@ RSpec.describe Saml::Kit::Response do
   end
 
   describe '#build' do
-    it 'can build a response without a request' do
-      configuration = Saml::Kit::Configuration.new do |config|
+    let(:configuration) do
+      Saml::Kit::Configuration.new do |config|
         config.entity_id = FFaker::Internet.uri('https')
+        config.generate_key_pair_for(use: :signing)
       end
-      sp = Saml::Kit::Metadata.build(&:build_service_provider)
+    end
+    let(:sp) do
+      Saml::Kit::Metadata.build(configuration: configuration, &:build_service_provider)
+    end
+
+    before do
       allow(configuration.registry).to receive(:metadata_for).with(configuration.entity_id).and_return(sp)
+    end
+
+    it 'can build a response without a request' do
       result = described_class.build(user, configuration: configuration)
       expect(result).to be_instance_of(described_class)
       expect(result).to be_valid
     end
 
     it 'can build a response without the need for the user to provide attributes' do
-      configuration = Saml::Kit::Configuration.new do |config|
-        config.entity_id = FFaker::Internet.uri('https')
-      end
-      sp = Saml::Kit::Metadata.build(&:build_service_provider)
-      allow(configuration.registry).to receive(:metadata_for).with(configuration.entity_id).and_return(sp)
-      user = UserWithoutAttributes.new
-
-      result = described_class.build(user, configuration: configuration)
+      result = described_class.build(UserWithoutAttributes.new, configuration: configuration)
       expect(result).to be_instance_of(described_class)
       expect(result).to be_valid
       expect(result.attributes).to be_empty
spec/saml/kit/security_spec.rb
@@ -0,0 +1,303 @@
+# frozen_string_literal: true
+
+# Regression coverage for the signature stripping and signature wrapping
+# advisories. Each example asserts the behaviour we want, so every example in
+# this file fails against a vulnerable build.
+#
+# Nothing here is stubbed: real key pairs, a real registry, real signatures.
+# Stubbing the trust path would defeat the purpose.
+# This file spans Response, Assertion, Signature and Trustable, so there is no
+# single described class.
+# rubocop:disable RSpec/DescribeClass
+RSpec.describe 'Signature verification' do
+  subject(:response) { Saml::Kit::Response.new(xml, configuration: sp_configuration) }
+
+  let(:namespaces) { Saml::Kit::Document::NAMESPACES }
+  let(:as_xml) { Nokogiri::XML::Node::SaveOptions::AS_XML }
+
+  let(:idp_entity_id) { 'https://idp.example.com/metadata' }
+  let(:sp_entity_id) { 'https://sp.example.com/metadata' }
+
+  let(:idp_configuration) do
+    Saml::Kit::Configuration.new do |config|
+      config.entity_id = idp_entity_id
+      config.generate_key_pair_for(use: :signing)
+    end
+  end
+
+  let(:registry) do
+    Saml::Kit::DefaultRegistry.new.tap do |x|
+      x.register(Saml::Kit::IdentityProviderMetadata.new(idp_metadata_xml))
+    end
+  end
+
+  let(:idp_metadata_xml) do
+    Saml::Kit::IdentityProviderMetadata.build(configuration: idp_configuration) do |x|
+      x.entity_id = idp_entity_id
+      x.add_single_sign_on_service('https://idp.example.com/login', binding: :http_post)
+    end.to_xml
+  end
+
+  let(:sp_configuration) do
+    Saml::Kit::Configuration.new do |config|
+      config.entity_id = sp_entity_id
+      config.registry = registry
+    end
+  end
+
+  let(:user) { User.new(attributes: { id: SecureRandom.uuid }) }
+  let(:request) do
+    instance_double(
+      Saml::Kit::AuthenticationRequest,
+      id: ::Xml::Kit::Id.generate,
+      issuer: sp_entity_id,
+      assertion_consumer_service_url: "#{sp_entity_id}/acs",
+      name_id_format: Saml::Kit::Namespaces::PERSISTENT,
+      provider: nil,
+      signed?: true,
+      trusted?: true
+    )
+  end
+
+  # A Response signed at both the Response and Assertion level.
+  let(:signed_xml) do
+    Saml::Kit::Response.build(user, request, configuration: idp_configuration) do |x|
+      x.issuer = idp_entity_id
+      x.embed_signature = true
+    end.to_xml
+  end
+
+  # A Response signed only at the Response level.
+  let(:response_signed_xml) do
+    Saml::Kit::Response.build(user, request, configuration: idp_configuration) do |x|
+      x.issuer = idp_entity_id
+      x.embed_signature = true
+      x.assertion.embed_signature = false
+    end.to_xml
+  end
+
+  def described_response(xml)
+    Saml::Kit::Response.new(xml, configuration: sp_configuration)
+  end
+
+  shared_examples 'an accepted Response' do
+    it 'is valid and reports the real subject' do
+      expect(response).to be_valid
+      expect(response.name_id).to eql(user.name_id)
+    end
+  end
+
+  # Rejecting a forgery is only meaningful if the reference check still binds a
+  # signature to whichever element the identity provider chose to sign.
+  shared_examples 'a rejected forgery' do
+    it 'is invalid' do
+      expect(response).to be_invalid
+    end
+
+    it 'does not report the forged document as trusted' do
+      response.valid?
+      expect(response).not_to be_trusted
+      expect(response.assertion).not_to be_trusted
+    end
+  end
+
+  context 'when signed at both the Response and Assertion level' do
+    let(:xml) { signed_xml }
+
+    it_behaves_like 'an accepted Response'
+  end
+
+  context 'when signed only at the Response level' do
+    let(:xml) { response_signed_xml }
+
+    it_behaves_like 'an accepted Response'
+  end
+
+  # Signing only the Assertion is the default for several identity providers,
+  # so requiring a signature must not reject it.
+  context 'when signed only at the Assertion level' do
+    let(:xml) do
+      document = Nokogiri::XML(signed_xml)
+      document.at_xpath('/samlp:Response/ds:Signature', namespaces).remove
+      document.to_xml(save_with: as_xml)
+    end
+
+    it_behaves_like 'an accepted Response'
+
+    # Widening this to include the Assertion's signature would silently relax
+    # every `raise unless response.trusted?` guard written against 1.4.1.
+    it 'still reports trusted? for the Response element alone' do
+      expect(response).not_to be_trusted
+      expect(response.assertion).to be_trusted
+    end
+  end
+
+  # The opt-out exists so an operator whose identity provider signs nothing can
+  # stage the upgrade instead of pinning 1.4.1. It restores 1.4.1 behaviour
+  # exactly, forgeries included, which is why it defaults to on.
+  describe 'configuration.signature_required' do
+    let(:xml) do
+      document = Nokogiri::XML(signed_xml)
+      document.xpath('//ds:Signature', namespaces).each(&:remove)
+      document.to_xml(save_with: as_xml)
+    end
+
+    it 'defaults to requiring a signature' do
+      expect(sp_configuration.signature_required).to be(true)
+      expect(response).to be_invalid
+    end
+
+    it 'accepts an unsigned Response from a registered issuer when disabled' do
+      sp_configuration.signature_required = false
+      expect(response).to be_valid
+    end
+
+    it 'still refuses to call an unsigned Response trusted when disabled' do
+      sp_configuration.signature_required = false
+      response.valid?
+      expect(response).not_to be_trusted
+    end
+  end
+
+  describe 'error reporting' do
+    let(:xml) { signed_xml }
+
+    # The signature requirement has nothing to say about a document that is not
+    # a Response at all, and prepending to :base changes errors[:base].first.
+    it 'does not demand a signature from a document with no Response element' do
+      document = Saml::Kit::Response.new('', configuration: sp_configuration)
+      document.valid?
+      expect(document.errors[:base]).not_to include('must be signed.')
+    end
+
+    it 'reports a fingerprint mismatch once, not once per element' do
+      foreign = Saml::Kit::Configuration.new do |config|
+        config.entity_id = idp_entity_id
+        config.generate_key_pair_for(use: :signing)
+      end
+      document = Nokogiri::XML(
+        Saml::Kit::Response.build(user, request, configuration: foreign) do |x|
+          x.issuer = idp_entity_id
+          x.embed_signature = true
+        end.to_xml
+      )
+      document.at_xpath('/samlp:Response/ds:Signature', namespaces).remove
+      response = described_response(document.to_xml(save_with: as_xml))
+      response.valid?
+      expect(response.errors[:fingerprint].count).to be(1)
+    end
+  end
+
+  describe 'signature stripping' do
+    let(:xml) do
+      document = Nokogiri::XML(signed_xml)
+      document.xpath('//ds:Signature', namespaces).each(&:remove)
+      document.at_xpath('//saml:NameID', namespaces).content = 'attacker@evil.com'
+      document.to_xml(save_with: as_xml)
+    end
+
+    it_behaves_like 'a rejected forgery'
+
+    it 'reports the missing signature' do
+      response.valid?
+      expect(response.errors[:base]).to include('must be signed.')
+    end
+  end
+
+  describe 'a bare Assertion delivered as the whole document' do
+    subject(:assertion) do
+      Saml::Kit::Document.to_saml_document(xml, configuration: sp_configuration)
+    end
+
+    let(:bare) do
+      Nokogiri::XML(signed_xml)
+        .at_xpath('/samlp:Response/saml:Assertion', namespaces)
+    end
+
+    context 'when its signature is intact' do
+      let(:xml) { bare.to_xml(save_with: as_xml) }
+
+      it 'is accepted' do
+        expect(assertion).to be_a(Saml::Kit::Assertion)
+        expect(assertion).to be_valid
+      end
+    end
+
+    context 'when its signature has been stripped' do
+      let(:xml) do
+        bare.at_xpath('./ds:Signature', namespaces).remove
+        bare.at_xpath('.//saml:NameID', namespaces).content = 'attacker@evil.com'
+        bare.to_xml(save_with: as_xml)
+      end
+
+      it 'is invalid' do
+        expect(assertion).to be_invalid
+      end
+
+      # A missing locale key would surface here as "Translation missing".
+      it 'reports the missing signature' do
+        assertion.valid?
+        expect(assertion.errors[:base]).to include('must be signed.')
+      end
+    end
+  end
+
+  describe 'signature wrapping' do
+    context 'when the original assertion is hidden in saml:Advice' do
+      let(:xml) do
+        document = Nokogiri::XML(signed_xml)
+        document.at_xpath('/samlp:Response/ds:Signature', namespaces).remove
+        original = document.at_xpath('/samlp:Response/saml:Assertion', namespaces)
+
+        evil = original.dup
+        evil['ID'] = "_#{SecureRandom.uuid}"
+        evil.at_xpath('./saml:Subject/saml:NameID', namespaces).content = 'attacker@evil.com'
+
+        advice = Nokogiri::XML::Node.new('Advice', document)
+        advice.namespace = original.namespace
+        advice.add_child(original.dup)
+        evil.at_xpath('./saml:Conditions', namespaces).add_next_sibling(advice)
+
+        original.replace(evil)
+        document.to_xml(save_with: as_xml)
+      end
+
+      it_behaves_like 'a rejected forgery'
+
+      it 'reports the reference as unbound' do
+        response.valid?
+        expect(response.errors[:reference]).to be_present
+      end
+    end
+
+    context 'when the original response is hidden in ds:Object' do
+      let(:xml) do
+        document = Nokogiri::XML(response_signed_xml)
+        original = document.root
+        hidden = original.dup
+
+        evil = original.dup
+        evil['ID'] = "_#{SecureRandom.uuid}"
+        evil.at_xpath('.//saml:Assertion', namespaces)['ID'] = "_#{SecureRandom.uuid}"
+        evil.at_xpath('.//saml:NameID', namespaces).content = 'attacker@evil.com'
+
+        signature = evil.at_xpath('./ds:Signature', namespaces)
+        object = Nokogiri::XML::Node.new('Object', document)
+        object.namespace = signature.namespace
+        object.add_child(hidden)
+        signature.add_child(object)
+
+        document.root = evil
+        document.to_xml(save_with: as_xml)
+      end
+
+      it_behaves_like 'a rejected forgery'
+
+      it 'reports the reference as unbound' do
+        response.valid?
+        expect(response.errors[:reference]).to be_present
+      end
+    end
+  end
+end
+# rubocop:enable RSpec/DescribeClass
.rubocop_todo.yml
@@ -57,6 +57,11 @@ Lint/DeprecatedOpenSSLConstant:
 Metrics/AbcSize:
   Max: 19
 
+# Offense count: 1
+# Configuration parameters: CountComments, CountAsOne.
+Metrics/ClassLength:
+  Max: 102
+
 # Offense count: 5
 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
 Metrics/MethodLength:
CHANGELOG.md
@@ -1,4 +1,4 @@
-Version 1.4.1
+Version 1.5.0
 
 # Changelog
 All notable changes to this project will be documented in this file.
@@ -8,6 +8,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+## [1.5.0] - 2026-08-17
+### Security
+- Reject a `Response` or `Assertion` that carries no signature. A registered
+  issuer was previously enough to make an unsigned document valid, so anyone
+  who could reach the assertion consumer service could forge an assertion for
+  any user. (GHSA-hx7c-98jm-7r55)
+- Require an xml signature to reference the element it is attached to. A
+  signature was verified without checking which element it covered, so a valid
+  signature could be copied onto a forged element with the element it really
+  covered hidden in `saml:Advice` or `ds:Object`. (GHSA-q726-45rx-qvrw)
+
+### Changed
+This release rejects documents that 1.4.1 accepted. Each case below was
+exploitable or is a side effect of closing one that was.
+
+- An unsigned `Response` or `Assertion` is now invalid, even when its issuer is
+  registered. Set `configuration.signature_required = false` to restore the old
+  behaviour while an identity provider is being fixed; doing so accepts forged
+  assertions. Signing only the `Assertion` is still accepted, as is an unsigned
+  `AuthnRequest`, `LogoutRequest` or `LogoutResponse`.
+- `Response.build` on a `Configuration` with no signing key pair emits an
+  unsigned document, which now fails `valid?`. Call
+  `generate_key_pair_for(use: :signing)` or `add_key_pair`.
+- `Assertion.new` with an unsigned assertion as a `String` is now invalid,
+  because a bare assertion is the whole document and nothing else vouches for
+  it. Passing the same assertion as a node nested in a `Response` is unchanged.
+- A signature whose `ds:Reference` does not resolve to the element it hangs
+  off, or resolves to more than one element, adds an error on `:reference`.
+
+### Added
+- `Saml::Kit::Configuration#signature_required`, defaulting to `true`.
+
 ## [1.4.1] - 2026-08-14
 ### Changed
 - fix: decode encoded signature
@@ -110,7 +142,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Removed
 - Removed optional SessionNotOnOrAfter attribute from AuthnStatement.
 
-[Unreleased]: https://github.com/xlgmokha/saml-kit/compare/v1.4.1...HEAD
+[Unreleased]: https://github.com/xlgmokha/saml-kit/compare/v1.5.0...HEAD
+[1.5.0]: https://github.com/xlgmokha/saml-kit/compare/v1.4.1...v1.5.0
 [1.4.1]: https://github.com/xlgmokha/saml-kit/compare/v1.4.0...v1.4.1
 [1.4.0]: https://github.com/xlgmokha/saml-kit/compare/v1.3.0...v1.4.0
 [1.3.0]: https://github.com/xlgmokha/saml-kit/compare/v1.2.0...v1.3.0
Gemfile.lock
@@ -1,7 +1,7 @@
 PATH
   remote: .
   specs:
-    saml-kit (1.4.1)
+    saml-kit (1.5.0)
       activemodel (>= 5.1)
       base64 (~> 0.1)
       cgi (~> 0.1)