mod_luaでCORSヘッダを追加する

# はじめに

クロスオリジンのAjaxを行う際に必要になる各種CORSヘッダですが、簡単なパターンを雑に対応するなら `Header` や `mod_rewrite` で出力しておけば十分なんだけど、一度ロジックの確認を兼ねてもうすこし丁寧に対応して見ようと思い、しかしそうなると `mod_rewrite` でやるには大分煩雑になってしまうのでこの機に `mod_lua` でヘッダ追加を実装してみることにした。

# 実装例
とりあえずこんな感じで出来ました。

まず httpd.conf に以下を追記します。

```apache:httpd.conf
LuaHookFixups /etc/httpd/conf.d/add_cors.lua add_cors
```

- これは `/etc/httpd/conf.d/add_cors.lua` 内の `add_cors` という名前の関数をフックとして登録するという意味です。
- `LuaHook*` 系のディレクティブはいくつかありますが、`Fixups` はレスポンスを返す直前の一番最後にフックされるので他で上書きされたくなければここでいいかなと。逆に他のプラグラムや設定等で上書き可能にしておきたければもっと早い段階のHookにしておけばよい筈です。

で、httpd.conf で指定している `lua` ファイルの内容は以下のとおりです。頭の辺りで要件に合わせて許可範囲を変えられるよう変数定義してるので適宜変更して下さい。

```lua:/etc/httpd/conf.d/add_cors.lua
require 'apache2'

-- CORSを許可したいOriginホスト名を列挙します, サブドメインも許可されます, "*"が指定された場合全てのOriginを許可します
AllowOrigins = {
  "example.com",
  "example.jp",
  "localhost" -- for development, ci, etc...
}
-- GET,POST,HEAD以外のメソッドを利用する場合はtrueにします
AllowRequestMethod = true
-- 追加のリクエストヘッダを利用する場合はtrueにします
AllowRequestHeaders = true
-- CookieまたはAuthorizationヘッダを利用する場合はtrueにします
AllowCredentials = false
-- preflightリクエストの結果をクライアントキャッシュ可能なら時間を秒で指定します
AccessControlMaxAge = 3600

function add_cors(r)
  local allowOrigin = getAllowOrigin(r)
  if allowOrigin then
    r.headers_out["Access-Control-Allow-Origin"] = allowOrigin
    -- Access-Control-Allow-Origin 以外はpreflightリクエストのレスポンス時のみ出力すればよい
    if r.method == "OPTIONS" then
      if AllowRequestMethod then
        local allowMethods = getAllowMethods(r)
        if allowMethods then
          r.headers_out["Access-Control-Allow-Methods"] = allowMethods
        end
      end
      if AllowRequestHeaders then
        local allowHeaders = getAllowHeaders(r)
        if allowHeaders then
          r.headers_out["Access-Control-Allow-Headers"] = allowHeaders
        end
      end
      if AllowCredentials then
        r.headers_out["Access-Control-Allow-Credentials"] = "true"
      end
      if AccessControlMaxAge and 0 < AccessControlMaxAge then
        r.headers_out["Access-Control-Max-Age"] = AccessControlMaxAge
      end
    end
  end
  return apache2.DECLINED
end

function getAllowHeaders(r)
  local requestHeaders = r.headers_in["Access-Control-Request-Headers"]
  if requestHeaders and r:regex(requestHeaders, "^[a-zA-Z0-9-]+(?=, ?[a-zA-Z0-9-]+)*") then
    return requestHeaders
  end
  return nil
end

function getAllowMethods(r)
  local requestMethod = r.headers_in["Access-Control-Request-Method"]
  -- 仕様上はGET,HEAD,POSTは常に許可されるので含める必要がない筈だし、んだがバグったクライアントが居るらしいので列挙、OPTIONSも要バグクライアント対応で入れといたほうが良いらしい
  if requestMethod and requestMethod ~= "GET" and requestMethod ~= "POST" and requestMethod ~= "HEAD" and requestMethod ~= "OPTIONS" and requestMethod:find("^[A-Z]+$") then
    return "GET, POST, HEAD, OPTIONS, " .. requestMethod
  end
  return nil
end

function getAllowOrigin(r)
  local origin = r.headers_in["Origin"]
  if origin then
    local m = r:regex(origin, "^(https?)://([a-zA-Z0-9%.-]+)(?=:([0-9]+))?")
    if m then
      local origin_scheme = m[1]
      local origin_host = m[2]
      local origin_port = m[3]
      if r:regex(origin_host, getAllowOriginHostPattern()) then
        return origin
      end
    end
  end
  return nil
end

function getAllowOriginHostPattern()
  if not getAllowOriginHostPattern_cache then
    local i, v, p
    p = "^(.+\\.)?("
    for i,v in ipairs(AllowOrigins) do
      if v == "*" then
        getAllowOriginHostPattern_cache = "."
        return getAllowOriginHostPattern_cache
      end
      if i ~= 0 then
        p = p .. "|"
      end
      p = p .. v:gsub("%.", "\\.")
    end
    p = p .. ")$"
    getAllowOriginHostPattern_cache = p
  end
  return getAllowOriginHostPattern_cache
end
```

よく、CORS関連の設定例を見ると `Access-Control-Allow-Origin` の他に、`Access-Control-Allow-Methods` や `Access-Control-Allow-Headers` に使いそうなヘッダを固定で列挙してるケースが多いけど、**特にヘッダに関してはフレームワークや流派によって `X-Requested-With` だとか `X-Csrftoken` だとか好き勝手にヘッダを送ろうとしてくるので、こういった要求に対していちいちサーバ側設定を追加するのは大変なので避けたい**。

しかし、この送信を許可して欲しいリクエストヘッダ名の一覧って実はブラウザがpreflightリクエスト時にブラウザが教えてくれるのだ。例えばJSでCORSリクエストを作る時に`headers:{"X-Hoge":"hoge","X-Debug":1}` みたいな感じで追加ヘッダがある場合、`Access-Control-Request-Headers: x-hoge, x-debug` と言った感じ(lowercaseされるのは仕様らしい)のリクエストヘッダが飛んで来る。なのでOriginの許可の時と同様に`Access-Control-Allow-Headers`で値をオウム返ししてやれば良いわけだ。
こうしておけばサーバ側はクライアントが送りたいリクエストヘッダに対してイチイチ対応する必要はなくなるので保守が楽になるね！

同じく Method の利用許可も `Access-Control-Request-Method` で使いたいメソッドを教えてくれるので、それを `Access-Control-Allow-Methods` でオウム返ししてあげればOK。ただし仕様上は GET,POST,HEAD は常に許可なのでイチイチ Allow-Method してやる必要は本来無いはずなんだが、DELETEのリクエストに対して DELETE だけ許可するレスポンスを返すとGETとかも不許可だと勘違いするバグったブラウザが要るらしいので、その対応としてリクエストされたメソッド＋GET,POST,HEADとあとOPTIONS辺りも許可して負うたほうが無難らしいのでそうしておくことにした。

あとクッキーやAuthorizationヘッダ（用は認証情報）を使いたい場合は Access-Control-Allow-Headers による許可だけじゃなく、特別にそれらを送って良い許可も与えたやる必要がある。それが `Access-Control-Allow-Credentials: true` だ。この固定値のレスポンスヘッダをpreflightでもらって初めてブラウザはクレデンシャルヘッダを外部に送れるようになる。

あとこれらのプリフライトリクエストはCORSリクエストをする度に発生するので単純にリクエスト数が倍になり無駄である。同じURLに対しては許可情報をキャッシュしたくなるのが人情なので、そういうときは `Access-Control-Max-Age` でキャッシュ可能秒数を返してやればよい。１時間でも１ヶ月とかでも好きにして良い。

あと実は `Access-Control-Allow-Origin` 以外の追加のCORSヘッダはpreflightリクエスト(メソッドがOPTIONS)の時だけ返せば良いようなので、そのようにしてみた。