SAMLでのSSOを実現するGem「ruby-saml」のソースコードを追ってみる(SamlResponse編)

こんにちは!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のようでした。

github.com

samlのbytesizeを巨大にすることでDos攻撃が可能になっていたため、上記のバリデーションが追加されたようです。

また、元々はOneLogin::RubySaml::SamlMessageクラスに定数として定義されていたバリデーションの数値が、OneLogin::RubySaml::Settingsインスタンスから参照可能なように書き換えられたようでした。

github.com



次は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はこちらです。

github.com

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記事書けるんじゃないかと思っているので、また別の機会に書いてみようと思います。。。



参考にさせていただいたサイト

素晴らしいコンテンツの提供、誠にありがとうございます。

ruby-saml を使ったOneLogin での SAML 認証の処理シーケンスを追う - Qiita