2018-03-07

Rack CORSコードリーディング

rack-cors (1.0.2)のコードリーディングをしました。

その名の通りRackミドルウェアになっていてRack::Corsを明示的にミドルウェアとして設定する必要があります。

module YourApp
  class Application < Rails::Application
    # ...
    # Rails 5
    config.middleware.insert_before 0, Rack::Cors do
      allow do
        origins '*'
        resource '*', :headers => :any, :methods => [:get, :post, :options]
      end
    end

ということでRack::Corsの中身を見ていきます。lib/rack/cors.rbの1ファイルで全て構成されているので分割して説明します。

まず初期化時にblockを渡すとRack::Corsのコンテキストでinstance_evalされます。

module Rack
  class Cors
    def initialize(app, opts={}, &block)
      @app = app
      @debug_mode = !!opts[:debug]
      @logger = @logger_proc = nil

      if logger = opts[:logger]
        if logger.respond_to? :call
          @logger_proc = opts[:logger]
        else
          @logger = logger
        end
      end

      if block_given?
        if block.arity == 1
          block.call(self)
        else
          instance_eval(&block)
        end
      end
    end

arityなしの場合、allowを呼び出すとResourcesオブジェクトのコンテキストでinstance_evalします。

    def allow(&block)
      all_resources << (resources = Resources.new)

      if block.arity == 1
        block.call(resources)
      else
        resources.instance_eval(&block)
      end
    end

Resources#originsは以下のように定義されています。例えば example.comを定義した場合は@originsに /^[a-z][a-z0-9.+-]*:\/\/example\.com$/ がセットされます。

      class Resources
# ...
        def origins(*args, &blk)
          @origins = args.flatten.reject{ |s| s == '' }.map do |n|
            case n
            when Proc,
                 Regexp,
                 /^https?:\/\//,
                 'file://'        then n
            when '*'              then @public_resources = true; n
            else                  Regexp.compile("^[a-z][a-z0-9.+-]*:\\\/\\\/#{Regexp.quote(n)}$")
            end
          end.flatten
          @origins.push(blk) if blk
        end

        def resource(path, opts={})
          @resources << Resource.new(public_resources?, path, opts)
        end

#resourceはResourceのオブジェクトを@resourcesに追加しています。設定は以上になります。

次にHTTPリクエスト時のミドルウェアの動きを追っていきます。Originヘッダの中身はenv[HTTP_ORIGIN]にセットされます。リクエストメソッドがOPTIONSでAccess-Control-Request-Methodヘッダが設定されている場合(=preflight request)、process_preflightでレスポンスヘッダを生成してステータスコード200で返します。

    def call(env)
      env[HTTP_ORIGIN] ||= env[HTTP_X_ORIGIN] if env[HTTP_X_ORIGIN]

      add_headers = nil
      if env[HTTP_ORIGIN]
        debug(env) do
          [ 'Incoming Headers:',
            "  Origin: #{env[HTTP_ORIGIN]}",
            "  Access-Control-Request-Method: #{env[HTTP_ACCESS_CONTROL_REQUEST_METHOD]}",
            "  Access-Control-Request-Headers: #{env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS]}"
            ].join("\n")
        end
        if env[REQUEST_METHOD] == OPTIONS and env[HTTP_ACCESS_CONTROL_REQUEST_METHOD]
          headers = process_preflight(env)
          debug(env) do
            "Preflight Headers:\n" +
                headers.collect{|kv| "  #{kv.join(': ')}"}.join("\n")
          end
          return [200, headers, []]
        else
          add_headers = process_cors(env)
        end
      else
        Result.miss(env, Result::MISS_NO_ORIGIN)
      end

#process_preflightは#match_resourceでResourceのインスタンスを取得し、Resource#process_preflightを呼び出します。

      def process_preflight(env)
        result = Result.preflight(env)

        resource, error = match_resource(env)
        unless resource
          result.miss(error)
          return {}
        end

        return resource.process_preflight(env, result)
      end

Resource#match_resouceは設定されているResourcesのインスタンスに対してResources#allow_origin?を呼び出し、trueであればさらにResources#match_resourceを呼び出します。

      def match_resource(env)
        path   = env[PATH_INFO]
        origin = env[HTTP_ORIGIN]

        origin_matched = false
        all_resources.each do |r|
          if r.allow_origin?(origin, env)
            origin_matched = true
            if found = r.match_resource(path, env)
              return [found, nil]
            end
          end
        end

        [nil, origin_matched ? Result::MISS_NO_PATH : Result::MISS_NO_ORIGIN]
      end

originに*が設定されている場合はpublic_resources? = trueになるので、戻り値としてtrueを返します。そうでなければoriginsの各値とHTTP_ORIGINの値を===で比較します。ドメインを指定した場合は、ドメインに対応する正規表現との比較になります。

        def allow_origin?(source,env = {})
          return true if public_resources?

          return !! @origins.detect do |origin|
            if origin.is_a?(Proc)
              origin.call(source,env)
            else
              origin === source
            end
          end
        end

Resources#match_resourceは各リソースに対してResource#match?を呼び出します。

        def matches_path?(path)
          pattern =~ path
        end

        def match?(path, env)
          matches_path?(path) && (if_proc.nil? || if_proc.call(env))
        end

patternはResourceのpathから生成された正規表現になります。例えば /hogeのパスを指定した場合は/^\/hoge$/が正規表現として設定されます。よって正規表現のマッチングによってCORSに対応したパスかどうかを判定します。

CORに対応したオリジン、パスの場合はさらにResource#process_preflightが呼ばれ、preflightリクエストのレスポンスヘッダを生成します。

        def process_preflight(env, result)
          headers = {CONTENT_TYPE => TEXT_PLAIN}

          request_method = env[HTTP_ACCESS_CONTROL_REQUEST_METHOD]
          if request_method.nil?
            result.miss(Result::MISS_NO_METHOD) and return headers
          end
          if !methods.include?(request_method.downcase)
            result.miss(Result::MISS_DENY_METHOD) and return headers
          end

          request_headers = env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS]
          if request_headers && !allow_headers?(request_headers)
            result.miss(Result::MISS_DENY_HEADER) and return headers
          end

          result.hit = true
          headers.merge(to_preflight_headers(env))
        end

methods.include?でメソッドが対応しているかや、Access-Control-Request-Headersのヘッダが許可されているかを判定します。どちらも対応している場合は#to_preflight_headersがレスポンスヘッダに追加されて返されます。

#to_preflight_headersは#to_headersを呼び出しています。以下のようなハッシュを返しており、これがpreflight requestのレスポンスヘッダになります

        def to_headers(env)
          h = {
            'Access-Control-Allow-Origin'     => origin_for_response_header(env[HTTP_ORIGIN]),
            'Access-Control-Allow-Methods'    => methods.collect{|m| m.to_s.upcase}.join(', '),
            'Access-Control-Expose-Headers'   => expose.nil? ? '' : expose.join(', '),
            'Access-Control-Max-Age'          => max_age.to_s }
          h['Access-Control-Allow-Credentials'] = 'true' if credentials
          h
        end

          def to_preflight_headers(env)
            h = to_headers(env)
            if env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS]
              h.merge!('Access-Control-Allow-Headers' => env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS])
            end
            h
          end

preflight requestでないリクエストの場合はRack::Cors#process_corsが呼ばれます。#match_resouceでResourceを検索し、マッチした場合はResource#to_headersによってレスポンスヘッダが追加されます。

      def process_cors(env)
        resource, error = match_resource(env)
        if resource
          Result.hit(env)
          cors = resource.to_headers(env)
          cors

        else
          Result.miss(env, error)
          nil
        end
      end
このエントリーをはてなブックマークに追加