背景
サービスで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_type
がnilの時か、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 · rails/marcel@3d06a60 · GitHub
なので、次はこちらのソースを読みにいきましょう。
Marcel::MimeType.for
marcel/mime_type.rb at 3d06a6043c1acee4b1ed29283cbafdf34078a137 · rails/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
このコードを見てみると、まずデータからファイルタイプを取りだそうとします。 それと並行して
- 指定したファイルタイプ
- 名前から判断されるファイルタイプ
- 拡張子から判断されるタイプ
- 何もみつからなければ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"が設定されるようになっており、 このタイプには親タイプはありません。
そのため、ファイルタイプがバイナリから判定されていれば、すくなくとも全く違う実行ファイルとして返される事はありません。 しかし、バイナリからファイルタイプが判断できなかったが、画像ファイルとして指定されている場合では、画像ファイルという扱いになってしまいそうです。
ここのセキュリティをどう見るかですが、バイナリ判定が適切に実行可能な形式等について判断できるならば、バイナリから画像と判断されなかった時点で、 すくなくとも画像ファイルでもなければ実行ファイルでも無いという風に考えてしまう事も可能だとおもいます。
2021-03-31 更新
mimemagicがGPLライセンスのファイルを利用していたという事から、 このファイルに依存しない形の暫定的なRailsのバージョンがリリースされており、若干ですが判定の根拠となるデータベースがかわったようです。 ロジックに大きな変更はないかとおもいますが、一部のファイル形式に依存していた方はご注意ください。