こんにちは!kossyです!
今回は、SAMLでのSSOを実現するGem「ruby-saml」のソースコードを追ってみた(SamlResponseだけ)ので、
備忘録としてブログに残してみたいと思います。
環境
Ruby 2.6.8
Rails 6.0.4.1
MacOS Catalina
なお、IdPにはLINE WORKSを用いていると仮定してコードリーディングしてみます。
そして、リーディング元のレポジトリは拙著で作成したアプリケーションとします。
kossy-web-engineer.hatenablog.com
acsアクション
SAMLRequestを送った後、IdP側からacsアクションが叩かれます。
def acs settings = Account.get_saml_settings(get_url_base) response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], settings: settings) if response.is_valid? session[:nameid] = response.nameid session[:attributes] = response.attributes @attrs = session[:attributes] logger.info "Sucessfully logged" logger.info "NAMEID: #{response.nameid}" render :action => :index else logger.info "Response Invalid. Errors: #{response.errors}" @errors = response.errors render :action => :fail end end
OneLogin::RubySaml::Responseクラスのinitializeメソッドから読んでいきます。
OneLogin::RubySaml::Response#initialize
def initialize(response, options = {}) raise ArgumentError.new("Response cannot be nil") if response.nil? @errors = [] @options = options @soft = true unless options[:settings].nil? @settings = options[:settings] unless @settings.soft.nil? @soft = @settings.soft end end @response = decode_raw_saml(response, settings) @document = XMLSecurity::SignedDocument.new(@response, @errors) if assertion_encrypted? @decrypted_document = generate_decrypted_document end end
まず、response(= params[:SAMLResponse])がnilの場合は例外を発生させて処理を終了させています。
次に、options[:settings](= saml_settings)がnilでなければ、@settingsとしてインスタンス変数化し、さらに@settingsのsoft属性がnilでなければ、@settings.softの値を@softとして定義しています。
decode_raw_samlメソッドを見てみます。
decode_raw_saml
# https://github.com/onelogin/ruby-saml/blob/master/lib/onelogin/ruby-saml/saml_message.rb def decode_raw_saml(saml, settings = nil) return saml unless base64_encoded?(saml) settings = OneLogin::RubySaml::Settings.new if settings.nil? if saml.bytesize > settings.message_max_bytesize raise ValidationError.new("Encoded SAML Message exceeds " + settings.message_max_bytesize.to_s + " bytes, so was rejected") end decoded = decode(saml) begin inflate(decoded) rescue decoded end end # base64_encoded?メソッドの定義はこちら # BASE64_FORMAT = %r(\A([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\Z) def base64_encoded?(string) !!string.gsub(/[\r\n]|\\r|\\n|\s/, "").match(BASE64_FORMAT) end # decodeメソッドの定義はこちら def decode(string) Base64.decode64(string) end # inflateメソッドの定義はこちら def inflate(deflated) Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(deflated) end
早期リターンを行なっている行は、引数のsaml(= params[:SAMLResponse])がbase64でエンコードできる文字だけで構成されているかを確認しています。
settingsがnilの場合は新規でOneLogin::RubySaml::Settingsインスタンスを生成していますね。
次の行のif文では、samlのbytesizeの方がsettingsのmessage_max_bytesize属性よりも大きかった場合に、例外を発生させています。
このバリデーションが追加された経緯はこちらのPRのようでした。
samlのbytesizeを巨大にすることでDos攻撃が可能になっていたため、上記のバリデーションが追加されたようです。
また、元々はOneLogin::RubySaml::SamlMessageクラスに定数として定義されていたバリデーションの数値が、OneLogin::RubySaml::Settingsインスタンスから参照可能なように書き換えられたようでした。
次はassertion_encrypted?メソッドを見てみます。
assertion_encrypted?
# Checks if the SAML Response contains or not an EncryptedAssertion element # @return [Boolean] True if the SAML Response contains an EncryptedAssertion element # SAMLResponse に EncryptedAssertion 要素が含まれているかどうかを確認します # SAMLResponse に EncryptedAssertion要素が含まれている場合はTrueが返り値となります def assertion_encrypted? ! REXML::XPath.first( document, "(/p:Response/EncryptedAssertion/)|(/p:Response/a:EncryptedAssertion/)", { "p" => PROTOCOL, "a" => ASSERTION } ).nil? end
コメントアウト部分を読めばOKなコードでした。
if assertion_encrypted? @decrypted_document = generate_decrypted_document end
この箇所は、「SAMLResponse に EncryptedAssertion要素が含まれている場合は、複合化したDocumentを生成する」と読めそうです。(generate_decrypted_documentのコードリーディングは割愛)
やっとinitializeメソッドを読み終えました。。。
OneLogin::RubySaml::Response#is_valid?
次はis_valid?メソッドを追ってみます。
# Validates the SAML Response with the default values (soft = true) # @param collect_errors [Boolean] Stop validation when first error appears or keep validating. (if soft=true) # @return [Boolean] TRUE if the SAML Response is valid # # SAMLResponseをデフォルト値で検証します(soft = true) # 最初のエラーが表示されたら検証を停止するか、検証を続けます。 (soft = trueの場合) # SAMLResponseが有効な場合は TRUE が返り値となります。 def is_valid?(collect_errors = false) validate(collect_errors) end # validateメソッドの定義はこちら # Validates the SAML Response (calls several validation methods) # @param collect_errors [Boolean] Stop validation when first error appears or keep validating. (if soft=true) # @return [Boolean] True if the SAML Response is valid, otherwise False if soft=True # @raise [ValidationError] if soft == false and validation fails # SAMLResponseを検証します(いくつかの検証メソッドを呼び出します) # 最初のエラーが表示されたら検証を停止するか、検証を続けます。 (soft = trueの場合) # SAMLResponseが有効な場合はTrue、それ以外の場合はsoft = Trueの場合はFalse # soft == falseで、検証が失敗した場合はValidationErrorを発生させます。 def validate(collect_errors = false) reset_errors! return false unless validate_response_state validations = [ :validate_version, :validate_id, :validate_success_status, :validate_num_assertion, :validate_no_duplicated_attributes, :validate_signed_elements, :validate_structure, :validate_in_response_to, :validate_one_conditions, :validate_conditions, :validate_one_authnstatement, :validate_audience, :validate_destination, :validate_issuer, :validate_session_expiration, :validate_subject_confirmation, :validate_name_id, :validate_signature ] if collect_errors validations.each { |validation| send(validation) } @errors.empty? else validations.all? { |validation| send(validation) } end end
Input/Outputはわかりましたが、内部で呼び出しているバリデーションメソッドを全て読むのは流石に骨が折れるので、いくつか気になったメソッドをピックアップして読んでいきます。
validate_issuer
def validate_issuer return true if settings.idp_entity_id.nil? begin obtained_issuers = issuers rescue ValidationError => e return append_error(e.message) end obtained_issuers.each do |issuer| unless OneLogin::RubySaml::Utils.uri_match?(issuer, settings.idp_entity_id) error_msg = "Doesn't match the issuer, expected: <#{settings.idp_entity_id}>, but was: <#{issuer}>" return append_error(error_msg) end end true end # issuersメソッドの定義はこちら # Gets the Issuers (from Response and Assertion). # (returns the first node that matches the supplied xpath from the Response and from the Assertion) # @return [Array] Array with the Issuers (REXML::Element) # issuerを取得します(SamlResponseとアサーションから)。 # SAMLResponseおよびアサーションから指定されたxpathに一致する最初のノードを返します # 返り値はissuersの配列が返ります def issuers @issuers ||= begin issuer_response_nodes = REXML::XPath.match( document, "/p:Response/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION } ) unless issuer_response_nodes.size == 1 error_msg = "Issuer of the Response not found or multiple." raise ValidationError.new(error_msg) end issuer_assertion_nodes = xpath_from_signed_assertion("/a:Issuer") unless issuer_assertion_nodes.size == 1 error_msg = "Issuer of the Assertion not found or multiple." raise ValidationError.new(error_msg) end nodes = issuer_response_nodes + issuer_assertion_nodes nodes.map { |node| Utils.element_text(node) }.compact.uniq end end
begin end と ||= を使ってSAMLのissuerを見ながらバリデーションを行い、問題なければ@issuersをインスタンス変数で定義するメソッドでした。
settingsのissuerとSAMLに記載されたissuerがマッチしない場合は、append_errorメソッドを呼び出していました。
validate_name_id
# Validates the NameID element def validate_name_id if name_id_node.nil? if settings.security[:want_name_id] return append_error("No NameID element found in the assertion of the Response") end else if name_id.nil? || name_id.empty? return append_error("An empty NameID value found") end unless settings.sp_entity_id.nil? || settings.sp_entity_id.empty? || name_id_spnamequalifier.nil? || name_id_spnamequalifier.empty? if name_id_spnamequalifier != settings.sp_entity_id return append_error("The SPNameQualifier value mistmatch the SP entityID value.") end end end true end # name_id_nodeメソッドの定義はこちら def name_id_node @name_id_node ||= begin encrypted_node = xpath_first_from_signed_assertion('/a:Subject/a:EncryptedID') if encrypted_node node = decrypt_nameid(encrypted_node) else node = xpath_first_from_signed_assertion('/a:Subject/a:NameID') end end end
もしname_id_nodeがnilで、settings.security[:want_name_id]が存在する場合、append_errorを呼び出しています。
want_name_idが追加されたcommitはこちらです。
commitメッセージを見たところ、「Several security improvements: Reject empty nameID」との文言があったため、NameIDが空でも処理が成功してしまう脆弱性を改善したんだと思われます。
以上、全てのバリデーションを実行してエラーがなければ、validとみなされます。
まとめ
残りの処理はsessionにnameidを格納したりログに「ログイン成功!」「nameidはこちら!」と書き込んで、indexアクションをレンダリングするだけです。
session[:nameid] = response.nameid session[:attributes] = response.attributes @attrs = session[:attributes] logger.info "Sucessfully logged" logger.info "NAMEID: #{response.nameid}" render :action => :index
実際にコードを読んでみて思ったことですが、PRの概要やcommitメッセージを読むと、多岐に渡る考慮が追加されているのがわかります。
特にsamlのbytesizeの方がsettingsのmessage_max_bytesize属性よりも大きかった場合に、
例外を発生させる実装についての背景は今後のプログラミングにもすぐに活かせそうだと感じています。(Dos攻撃のくだりですね)
あとがき
正直validationのところだけで1記事書けるんじゃないかと思っているので、また別の機会に書いてみようと思います。。。