1. 記事一覧 >
  2. ブログ記事
category logo

OpenResty(Nginx)LuaでCORS用に条件付きでレスポンスヘッダーを書き換え

(更新) (公開)

はじめに

OpenResty(Nginx), Lua が使えるようにしたサーバーで、条件付きで CORS レスポンスヘッダーを書き換えることが必要になりました。

OpenResty(Nginx), Lua の環境については、別記事「OpenResty lua-resty-openidc php で SSO Web アプリ環境作成」を参照してください。

また、今回、SSO(シングルサインオン)有効で、GitLab が同居している Web アプリ環境で検証しました。「OpenResty lua-resty-openidc php で SSO Web アプリ環境作成」→「GitLab バンドル nginx 廃止 →OpenResty - GitLab - Web アプリ同居手順」の順に行ったら、その環境になる感じです。


CORS レスポンスヘッダーを書き換える理由は、以下のようにブラウザから直接 GitLab API へリクエストする場合、エラーになるからです。(private-token ヘッダーが許されず、CORS エラー)

CORSエラー

また、Web アプリが認証有り、かつ、相手が GitLab API の場合、GitLab API 自身が、Access-Control-Allow-Origin: * を返しているため、nginx のadd_headerでは、対応できませんでした。

認証が有る場合、Access-Control-Allow-Origin: https://webapp のように具体的に URL を指定しないといけなく、
nginx のadd_headerを使うと、ヘッダーの置き換えではなく、常に追加の動作のため、レスポンスヘッダーが
Access-Control-Allow-Origin: *
Access-Control-Allow-Origin: https://webapp...(略)
と二重になり、
動作しませんでした。


● 認証情報有り & add_header 利用の場合
CORSエラー2


● 認証情報有り & Lua(or more_set_headers)利用の場合
CORS OK


ヘッダー書き換え方法

いきなり、書き換え方法を書きますと、以下になります。(Lua でのヘッダー書き換え方法だけ知りたいという方は、このセクションで終わりです。)

  location /users/logout {
    proxy_cache off;
    proxy_pass  http://gitlab-workhorse;
    header_filter_by_lua_block {
      ngx.header['Set-Cookie'] = '_gitlab_session=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;'
      ngx.header['Content-Type'] = 'text/plain charset=UTF-8'
      ngx.header['Content-Length'] = '0'
      local origin = ngx.var.http_origin
      if not origin then return end
      ngx.header['Access-Control-Allow-Origin'] = origin
      ngx.header['Access-Control-Allow-Credentials'] = 'true'
    }
    return 200;
  }

  location ~ /api/v\d/ {
    proxy_cache off;
    proxy_pass  http://gitlab-workhorse;
    header_filter_by_lua_block {
      local origin = ngx.var.http_origin
      if not origin then return end
      ngx.header['Access-Control-Allow-Origin'] = origin
      ngx.header['Access-Control-Allow-Credentials'] = 'true'
      if ngx.req.get_method() == 'OPTIONS' then
        ngx.header['Access-Control-Allow-Credentials'] = 'true'
        ngx.header['Access-Control-Allow-Headers'] = 'Authorization, Content-Type, Accept, Origin, User-Agent, DNT, Cache-Control, X-Mx-ReqToken, Keep-Alive, X-Requested-With, If-Modified-Since, private-token'
        ngx.header['Access-Control-Allow-Methods'] = 'GET, DELETE, OPTIONS, POST, PUT'
        ngx.header['Access-Control-Allow-Origin'] = origin
        ngx.header['Access-Control-Max-Age'] = '2592000'
        ngx.header['Content-Length'] = '0'
        ngx.header['Content-Type'] = 'text/plain charset=UTF-8'
        ngx.status = 204
        return ngx.exit(ngx.status)
      end
    }
  }

proxy_cache off;
proxy_pass http://gitlab-workhorse;
は、nginx の設定です。
http://gitlab-workhorse は、別の行に upstream gitlab-workhorse { が書かれていて、GitLab へ右から左に流すというような意味です。


header_filter_by_lua_block {<Luaプログラム>} は、コンテンツを生成後、レスポンスヘッダーを生成するタイミングで、 <Lua プログラム> を実行してくださいという意味です。
header_filter_by_lua の部分は、実行したいタイミングにより、 access_by_luacontent_by_lua ...といろいろあります。 _block の部分は、Lua プログラムをインラインで書くときに付ける接尾語です。
シングルクォート'で囲む場合、 _block は必要ありませんが、 _block 無しは、nginx 設定文字列とみなされるため、内容によっては、エスケープしないといけなく、_block無しは推奨されていないようです。_block無し例:header_filter_by_lua 'ngx.header.Foo = "blah"';


local origin = ngx.var.http_origin は、nginx の変数 $http_origin を取り出しています。 $http_origin は、リクエストヘッダーの Origin: の値が入っています。nginx の変数は、 $request_method$request_uri など、他に多数あります。


if not origin then return end は、リクエストヘッダーの Origin: が無かった場合、 return で以降の処理を行わないという意味になります。


ngx.status = 204 で HTTP ステータスコード 204 を指定して、 ngx.exit(ngx.status) で終了しています。 return 200; の方は、nginx の設定になります。

header_filter_by_lua*balancer_by_lua*ssl_session_store_by_lua*の中でreturn無しで、ngx.exit(ngx.status)とすると、ngx.exitが非同期で動作して、下に書かれたコードが実行されます。常にreturnと組み合わせて使うことが推奨されます。


各用語まとめ

【 CORS 】

クロスオリジンリソース共有 (Cross-Origin Resource Sharing) のことです。HTML,jsを得たサーバーと異なるオリジンからAjaxなどでデータを取り出す仕組みのことです。CORSが許可されていない場合、ブラウザがエラー判定します。

CORSエラー判定 ブラウザCORSエラー表示

【 オリジン 】

URLの「スキーム」「ホスト」「ポート」の3つの組み合わせのことです。「スキーム」「ホスト」「ポート」のいずれかが異なる場合、たとえば、https://example.comhttp://example.comは別のオリジンです。http://example.comhttp://example.com:80は表記方法が違うだけで、同一オリジンです。

【 Access-Control-Allow-Origin レスポンスヘッダー 】

指定されたオリジンからのリクエストを行うコードでレスポンスが共有できるかどうかを示します。

*または、CORSを許可するオリジン(URL)を指定します。

Cookieの送受信も許可したい場合は、ワイルドカード(*)の指定は許可されません。

【 Access-Control-Allow-Credentials レスポンスヘッダー 】

異なるオリジン間で、Cookie、認証ヘッダー、または TLS クライアント証明書のやり取りをする場合、trueにセットします。

XMLHttpRequest のwithCredentialtrueに設定( Ajaxの場合、xhrFields: { withCredentials: true })、fetchの場合、fetch(url, { credentials: 'include' }) )の場合に必要になります。

【 Access-Control-Allow-Methods レスポンスヘッダー 】

プリフライトリクエストのレスポンスの中で、リソースにアクセスするときに利用できる1つまたは複数のメソッドを指定します。

*または、メソッド(POST, GET, OPTIONS...)をカンマ区切りで指定します。

【 プリフライトリクエスト 】

サーバーがCORSに対応しているか確認するOPTIONSメソッドのリクエストです。

Content-Type に application/json や application/xml を使用、独自のヘッダーを設定などの条件のときにブラウザが確認が必要と判断して自動的に送信されます。

プリフライトリクエストの後、本来のリクエストがCORSエラーに該当する場合、本来のリクエストは送信されません。

プリフライトリクエストのイメージ

【 Access-Control-Allow-Headers レスポンスヘッダー 】

プリフライトリクエストにAccess-Control-Request-Headersヘッダーが含まれていた場合、対応しているリクエストヘッダーを返します。

・リクエストヘッダー例:

Access-Control-Request-Headers: Content-Type, Accept

・レスポンスヘッダー例:

Access-Control-Allow-Headers: Content-Type, Accept

【 Access-Control-Max-Age レスポンスヘッダー 】

プリフライトリクエストの結果 (つまり Access-Control-Allow-Methods および Access-Control-Allow-Headers ヘッダーに含まれる情報) をキャッシュすることができる時間の長さを示します。

キャッシュするのは、ブラウザなので、サーバーからブラウザに「この期間だけ覚えておいてください」と指示するニュアンスになります。ブラウザによって、最長時間が決まっています。


動作検証

プリフライトリクエスト(正常)

プリフライトリクエスト(OPTIONS リクエスト)を送った場合、どういうレスポンスヘッダーが得られるか Chrome の DevTools で見てみました。

ヘッダーにprivate-tokenがあるため、プリフライトリクエストが発生します。

・検証プログラム

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>CORS TEST</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script>
      $(function () {
        $('#button').click(function () {
          $.ajax({
            url: 'https://gitlab-test.itccorporation.jp/api/v4/projects/?order_by=name&simple=true&sort=asc&per_page=100&page=1&_=1641522610939',
            crossDomain: true,
            headers: {
              'private-token': 'DwDSWSBUGutGek_uFKpq',
            },
            method: 'GET',
            dataType: 'json',
            cache: false,
            //xhrFields: {//認証情報を付加
            //  withCredentials: true
            //},
          })
            .done(function (data, status, xhr) {
              var data2 = JSON.stringify(data, null, 2);
              console.log('succes: ' + data2);
              $('#text').html(data2);
            })
            .fail(function (xhr, status, error) {
              alert('error');
            });
        });
      });
    </script>
  </head>
  <body>
    <input type="button" id="button" value="button" />
    <br />
    <pre id="text"></pre>
  </body>
</html>

・リクエストヘッダー

accept: */*
accept-encoding: gzip, deflate, br
accept-language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
access-control-request-headers: private-token
access-control-request-method: GET
cache-control: no-cache
origin: https://gitlab-download-app.itccorporation.jp
pragma: no-cache
referer: https://gitlab-download-app.itccorporation.jp/
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-site
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36

・レスポンスコード
204


・レスポンスヘッダー

access-control-allow-credentials: true
access-control-allow-headers: Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since,private-token
access-control-allow-methods: GET, DELETE, OPTIONS, POST, PUT
access-control-allow-origin: https://gitlab-download-app.itccorporation.jp
access-control-max-age: 2592000
content-length: 0
date: Thu, 06 Jan 2022 13:34:27 GMT
server: openresty

プリフライトリクエスト(CORS エラー)

ngx.header['Access-Control-Allow-Headers'] =からprivate-tokenを削除した場合、CORS エラーになります。その場合のプリフライトリクエスト結果は、以下です。

通信結果は、特に変わりません。エラーになるのは、本来のリクエストですので、普通にやり取りをしている動きになります。

・リクエストヘッダー

accept: */*
accept-encoding: gzip, deflate, br
accept-language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
access-control-request-headers: private-token
access-control-request-method: GET
cache-control: no-cache
origin: https://gitlab-download-app.itccorporation.jp
pragma: no-cache
referer: https://gitlab-download-app.itccorporation.jp/
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-site
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36

・レスポンスコード
204


・レスポンスヘッダー

access-control-allow-credentials: true
access-control-allow-headers: Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since
access-control-allow-methods: GET, DELETE, OPTIONS, POST, PUT
access-control-allow-origin: https://gitlab-download-app.itccorporation.jp
access-control-max-age: 2592000
content-length: 0
date: Thu, 06 Jan 2022 13:51:27 GMT
server: openresty

本来のリクエスト

プリフライトリクエストの結果、CORS OK 判定の場合、本来のリクエストが送信されます。その結果は以下です。
プリフライトリクエストがある場合、CORS エラーは、リクエストを送信する前にエラーになります。
プリフライトリクエストが無い場合、CORS エラーは、リクエストを送信した後にエラーになります。
CORS エラーは、
"xhrFields: {withCredentials: true}で認証情報を送信しているにもかかわらず、レスポンスヘッダーがAccess-Control-Allow-Origin: *で具体的なオリジンを指定していない。"
等の場合に発生します。


・リクエストヘッダー

accept: application/json, text/javascript, */*; q=0.01
accept-encoding: gzip, deflate, br
accept-language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
cache-control: no-cache
origin: https://gitlab-download-app.itccorporation.jp
pragma: no-cache
private-token: DwDSWSBUGutGek_uFKpq
referer: https://gitlab-download-app.itccorporation.jp/
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="96", "Google Chrome";v="96"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-site
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36

・レスポンスコード
200


・レスポンスヘッダー

access-control-allow-credentials: true
access-control-allow-methods: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS
access-control-allow-origin: https://gitlab-download-app.itccorporation.jp
access-control-expose-headers: Link, X-Total, X-Total-Pages, X-Per-Page, X-Page, X-Next-Page, X-Prev-Page, X-Gitlab-Blob-Id, X-Gitlab-Commit-Id, X-Gitlab-Content-Sha256, X-Gitlab-Encoding, X-Gitlab-File-Name, X-Gitlab-File-Path, X-Gitlab-Last-Commit-Id, X-Gitlab-Ref, X-Gitlab-Size
access-control-max-age: 7200
cache-control: max-age=0, private, must-revalidate
content-type: application/json
date: Thu, 06 Jan 2022 14:18:27 GMT
etag: W/"1e9f9e9297c9bcdd2decdb97e87ba9b0"
link: <http://gitlab-test.itccorporation.jp:443/api/v4/projects?_=1641532610550&membership=false&order_by=name&owned=false&page=1&per_page=100&simple=true&sort=asc&starred=false&statistics=false&with_custom_attributes=false&with_issues_enabled=false&with_merge_requests_enabled=false>; rel="first", <http://gitlab-test.itccorporation.jp:443/api/v4/projects?_=1641532610550&membership=false&order_by=name&owned=false&page=1&per_page=100&simple=true&sort=asc&starred=false&statistics=false&with_custom_attributes=false&with_issues_enabled=false&with_merge_requests_enabled=false>; rel="last"
server: openresty
vary: Origin
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-gitlab-feature-category: projects
x-next-page
x-page: 1
x-per-page: 100
x-prev-page
x-request-id: 01FRSFJME1FS9N4S5TJ5MT7R36
x-runtime: 0.061306
x-total: 4
x-total-pages: 1

access-control-expose-headers

GitLab API からは、access-control-expose-headers レスポンスヘッダーが返ってきます。これは、何かと言うと、クライアント側でこのレスポンスヘッダーを使って良いというものです。
通信成功時処理を以下のようにして、確認してみました。

  .done(function (data, status, xhr) {
    console.log(xhr.getAllResponseHeaders());
    $("#text").html(xhr.getAllResponseHeaders());
  })

レスポンス:

cache-control: max-age=0, private, must-revalidate
content-type: application/json
link: ; rel="first", ; rel="last"
x-next-page:
x-page: 1
x-per-page: 100
x-prev-page:
x-total: 4
x-total-pages: 1

access-control-expose-headersヘッダー検証


x-content-type-optionsx-frame-optionsx-gitlab-feature-categoryxhr.getAllResponseHeaders()で取得できません。すなわち、access-control-expose-headersヘッダー記載のヘッダーだけプログラムで参照できます。


more_set_headers について

ヘッダー書き換えは、nginx 拡張モジュールmore_set_headersを使う方法もあります。nginx 標準のadd_headerの場合、常に追加になりますが、more_set_headersの場合は、無ければ追加、有れば置き換えの動作になります。
したがって、以下のような設定で、今回 Lua でやったことと同じ動作をさせることができます。

more_set_headersの導入方法の説明は、割愛します。

  location /users/logout {
    proxy_cache off;
    proxy_pass  http://gitlab-workhorse;
    if ( $http_origin != "" ) {
      more_set_headers "Access-Control-Allow-Origin: $http_origin";
      more_set_headers "Access-Control-Allow-Credentials: true";
    }
    more_set_headers "Set-Cookie:_gitlab_session=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;";
    more_set_headers "Content-Type: text/plain charset=UTF-8";
    more_set_headers "Content-Length: 0";
    return 200;
  }

  location ~ /api/v\d/ {
    proxy_cache off;
    proxy_pass  http://gitlab-workhorse;
    more_set_headers "Access-Control-Allow-Origin: $http_origin";
    more_set_headers "Access-Control-Allow-Credentials: true";
    if ($request_method = "OPTIONS") {
      more_set_headers "Access-Control-Allow-Headers: Authorization, Content-Type, Accept, Origin, User-Agent, DNT, Cache-Control, X-Mx-ReqToken, Keep-Alive, X-Requested-With, If-Modified-Since, private-token";
      more_set_headers "Access-Control-Allow-Methods: GET, DELETE, OPTIONS, POST, PUT";
      more_set_headers "Access-Control-Max-Age: 2592000";
      more_set_headers "Content-Length: 0";
      more_set_headers "Content-Type: text/plain charset=UTF-8";
      return 204;
    }
  }
loading...