Let's write β

プログラミング中にできたことか、思ったこととか

ActiveStorageの利用時のcontent_typeが設定される流れと信頼性を調査した

背景

サービスでActiveStorageを利用するにあたり、画像ファイルのみに絞りたいであったり、特定のファイルタイプにのみ絞りたいなどの要求がありました。 多くのサンプルコードで、ActiveStorageでアタッチされているファイルの<field>.blob.content_typeホワイトリストと付きあわせるような実装がされていました。 一方でhtmlのリクエスト時などにはContent-Typeは偽造して設定する事も可能なので、もし仮に信頼性のない値が読みとれるようになってしまっていた場合は、たとえばPNGファイルと証して実行ファイルが送信されてしまうリスクがあるため、このcontent_typeがどのように設定されているのかをソースコードの流れを追ってみました。

環境

TL;DR

  • HTTPでのアップロード時にはファイルタイプはバイナリのマジックナンバーから判定をしようとするよ
  • バイナリから判定できなかった時には、HTTPのリクエストで設定されたContent-Type,ファイル名、拡張子の順番で推測しようとするよ

コードリーディング

has_one_attachをモデルで実行した時

rails/model.rb at f33d52c95217212cbacc8d5e44b5a8e3cdc6f5b3 · rails/rails · GitHub

以下一部引用:

      def has_one_attached(name, dependent: :purge_later)
        generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
          def #{name}
            @active_storage_attached_#{name} ||= ActiveStorage::Attached::One.new("#{name}", self)
          end
          def #{name}=(attachable)
            attachment_changes["#{name}"] =
              if attachable.nil?
                ActiveStorage::Attached::Changes::DeleteOne.new("#{name}", self)
              else
                ActiveStorage::Attached::Changes::CreateOne.new("#{name}", self, attachable)
              end
          end
        CODE

モデル内でhas_one_attachedを実施すると、class_evalを通して、readerとwriterのメソッドが定義されている事がわかります。 writerではattachment_changesというフィルドの中に、アタッチメントのフィールド名をキーにActiveStorage::Attached::Changes::CreateOneが追加されているようです。 readerメソッドでは、ActiveStorage::Attached::Oneのインスタンスがフィールド名とモデルのクラス自身を引数に生成されて返されるようです。

実際にコードを書いてdebuggerで確認してみたところ

(byebug) front_side
#<ActiveStorage::Attached::One:0x00007f837dc27880 @name="front_side", @record=#<SampleModel id: nil, created_at: nil, updated_at: nil>>

といった形でインスタンスが取得できました。 という事は、このフィールドに対して <field>.blobを呼びだした結果に対してcontent_typeを呼んでいる事になります。

ActiveStorage::Attached::One#blobはどこにある?

rails/one.rb at f33d52c95217212cbacc8d5e44b5a8e3cdc6f5b3 · rails/rails · GitHub

次に、見てみると、どうやらblobはOneの中に定義はされておらず

delegate_missing_to :attachment

を通して、attachmentの結果に委譲されてそうです。

このattchmentフィールドを見ると

    def attachment
      change.present? ? change.attachment : record.public_send("#{name}_attachment")
    end

という風に呼んでいます。このchangeはどこに定義されているかというと

ActiveStorage::Attached::Oneが敬称しているActiveStorage::Attachedの中に定義されています。

rails/attached.rb at f33d52c95217212cbacc8d5e44b5a8e3cdc6f5b3 · rails/rails · GitHub

ここを見ると冒頭のwriterメソッドの中で保存されていた、attachment_changesのattachnentを取りだしてきているようです。

という事はActiveStorage::Attached::Changes::CreateOneを見にいけばよさそうですね。

ActiveStorage::Attached::Changes::CreateOne

rails/create_one.rb at f33d52c95217212cbacc8d5e44b5a8e3cdc6f5b3 · rails/rails · GitHub

さて、ようやくattachmentの定義にいきつきました。 これを見ると、find_or_build_attachimentの結果をキャッシュしているだけのようですね。

そちらを見にいきますと、find_attachmentの結果がnilだったら、build_attachmentをしているようです。

      def find_attachment
        if record.public_send("#{name}_blob") == blob
          record.public_send("#{name}_attachment")
        end
      end

これを見ると、レコードに<フィールド名>_blobというメッセージを送信して、その結果が別のメソッドblobの結果と同じだったらレコードに<フィールド名>_attachmentを送った結果を返しているようですね。

では、じゃあこの<レコード名>_blobというのが何か見ないといけません。 こちらは何かというと、冒頭のhas_one_attachedの処理の中で

rails/model.rb at f33d52c95217212cbacc8d5e44b5a8e3cdc6f5b3 · rails/rails · GitHub

        has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record, inverse_of: :record, dependent: :destroy
        has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob", source: :blob

の様に定義されています、ようするに保存ずみのデータがあったら検索しているようです。 存在する場合には結果のActiveStorage::Attachmentがそのまま利用されますが、

存在しない場合にもbuild_attachmentの中でActiveStorage::Attachmentのインスタンスがnewされて返されます

find_attachimentで発見された場合にも、build_ataachmentで生成される場合にも、共に、blobメソッドは呼びだされるため、次はそちらを見てみましょう.

ActiveStorage::Attached::Changes::CreateOne#blob

blobの中身はfind_or_build_blobの結果をキャッシュしているだけなので、 そちらを見ていきます。

rails/create_one.rb at f33d52c95217212cbacc8d5e44b5a8e3cdc6f5b3 · rails/rails · GitHub

すると、attachableの型をベースに判定しています。 今回想定しているWebからのアップロードの場合には、ActionDispatch::Http::UploadedFileのcaseにあたる事になります。

        when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
          ActiveStorage::Blob.build_after_unfurling \
            io: attachable.open,
            filename: attachable.original_filename,
            content_type: attachable.content_type

どうやら、ActiveStorage::Blob.build_after_unfurlingというメソッドの結果を利用しているようです。 こちらのメソッドでattachmentに指定されたcontent_typeが渡されていますね。 この値がどのようにされるのか。

次はそちらを見ていきましょう

ActiveStorage::Blob.build_after_unfurling

rails/blob.rb at f33d52c95217212cbacc8d5e44b5a8e3cdc6f5b3 · rails/rails · GitHub

    def build_after_unfurling(io:, filename:, content_type: nil, metadata: nil, identify: true) #:nodoc:
      new(filename: filename, content_type: content_type, metadata: metadata).tap do |blob|
        blob.unfurl(io, identify: identify)
      end
    end

これを見ると、まずnewして生成したデータをブロックにわたし、実際のファイルデータのストリームと共にunfurlというメソッドを呼びだしています。

build_after_unfurlingの呼び出し時にidentifyは指定されていなかったので、trueとなっているようです。

ActiveStorage::Blob.unfurl

rails/blob.rb at f33d52c95217212cbacc8d5e44b5a8e3cdc6f5b3 · rails/rails · GitHub

  def unfurl(io, identify: true) #:nodoc:
    self.checksum     = compute_checksum_in_chunks(io)
    self.content_type = extract_content_type(io) if content_type.nil? || identify
    self.byte_size    = io.size
    self.identified   = true
  end

この中で、どうやらcontent_typeを設定しているようです。 いよいよ核心に近づいてきた感じがあります。

extract_content_typeというメソッドをデータのストリームを引数に呼びだしているようですが、これはcontent_typenilの時か、identifyがtrueの時に呼びだされているようです。

build_after_unfurlingからidentifyがtrueでで設定されるので、extract_content_typeは必ず呼びだされそうです。

ActiveStorage::Blob.extract_content_type

rails/blob.rb at f33d52c95217212cbacc8d5e44b5a8e3cdc6f5b3 · rails/rails · GitHub

    def extract_content_type(io)
      Marcel::MimeType.for io, name: filename.to_s, declared_type: content_type
    end

どうやら、内部の処理はMarcelというgemに

  • ファイルのストリーム
  • 指定されたファイル名
  • ユーザー指定のcontent_type

を渡しているようです。

Marcel

このmarcelというgemはactivestorageの依存関係に含まれています。 Gemfile.lockで確認すると

  • marcel (0.3.3)

が使われているようです。

v0.3.3 · basecamp/marcel@3d06a60 · GitHub

なので、次はこちらのソースを読みにいきましょう。

Marcel::MimeType.for

marcel/mime_type.rb at 3d06a6043c1acee4b1ed29283cbafdf34078a137 · basecamp/marcel · GitHub

    def for(pathname_or_io = nil, name: nil, extension: nil, declared_type: nil)
      type_from_data = for_data(pathname_or_io)
      fallback_type = for_declared_type(declared_type) || for_name(name) || for_extension(extension) || BINARY

      if type_from_data
        most_specific_type type_from_data, fallback_type
      else
        fallback_type
      end
    end

このコードを見てみると、まずデータからファイルタイプを取りだそうとします。 それと並行して

  1. 指定したファイルタイプ
  2. 名前から判断されるファイルタイプ
  3. 拡張子から判断されるタイプ
  4. 何もみつからなければBINARYタイプ("application/octet-stream")

の順番でフォールバックする時用のファイルタイプを選びだしているようです。

ファイルからデータ型がバイナリのマジックナンバーで取りだせた場合には、フォールバック用の型と比較しますが、 ファイルから判定できなかった時には、フォールバックした結果が使われるようです。

ファイルのストリームや拡張子等からの判定は github.com

というgemに委譲されているようです。

この、どちらの型を優先するかという判断はmost_specific_typeで判断されています。

      # For some document types (notably Microsoft Office) we recognise the main content
      # type with magic, but not the specific subclass. In this situation, if we can get a more
      # specific class using either the name or declared_type, we should use that in preference
      def most_specific_type(from_magic_type, fallback_type)
        if (root_types(from_magic_type) & root_types(fallback_type)).any?
          fallback_type
        else
          from_magic_type
        end
      end

      def root_types(type)
        if MimeMagic::TYPES[type].nil? || MimeMagic::TYPES[type][1].empty?
          [ type ]
        else
          MimeMagic::TYPES[type][1].map {|t| root_types t }.flatten
        end
      end

処理を見てみると、MimeMagicのTYPESから、あるMIME Typeの親にあたるタイプというのが管理されているようで、再帰的に呼びだして最上位の親の型のセットをroot_typesで取りだしています。

バイナリから判断されたファイルタイプとフォールバックしたファイルタイプの両方に共通の親があった場合は、 フォールバックしたファイルタイプを、そうでない場合にはバイナリファイルから判定したファイルタイプを返すようになっています。 また、フォールバックしたタイプについては、判定ができなかった場合には常に"application/octet-stream"が設定されるようになっており、 このタイプには親タイプはありません。

そのため、ファイルタイプがバイナリから判定されていれば、すくなくとも全く違う実行ファイルとして返される事はありません。 しかし、バイナリからファイルタイプが判断できなかったが、画像ファイルとして指定されている場合では、画像ファイルという扱いになってしまいそうです。

ここのセキュリティをどう見るかですが、バイナリ判定が適切に実行可能な形式等について判断できるならば、バイナリから画像と判断されなかった時点で、 すくなくとも画像ファイルでもなければ実行ファイルでも無いという風に考えてしまう事も可能だとおもいます。