今回は static な Nuxt2 のお話。

yarn start ではなく yarn generate で生成した dist を静的に web サーバーに置いて別オリジンの API に axios を叩くお話し。

static だと nuxtjs/proxy が動かないので色々めんどくさい。

今まで CORS や CSRF とかよく分からないままネットからコピペで対策したり、場当たり的な対応してきたので整理しておく。

0-1. サーバーの準備

apache2 の virtualサーバーで axios-test1.webinlet.com と axios-test2.webinlet.com を用意することにした。

サブドメインが違うので当然オリジンも異なる。テストするのはこれで十分。

axios-test1 に Nuxt を置いて、axios-test2 に PHP ファイルを置く。

Nuxt から PHP に axios を投げて応答を確認することにする。

0-2. Nuxt2 の準備

npx で nuxt のインストール。設定は以下。

$ npx create-nuxt-app axios-test

create-nuxt-app v3.7.1
✨  Generating Nuxt.js project in axios-test
? Project name: axios-test
? Programming language: TypeScript
? Package manager: Yarn
? UI framework: None
? Nuxt.js modules: Axios - Promise based HTTP client
? Linting tools: ESLint, StyleLint
? Testing framework: None
? Rendering mode: Single Page App
? Deployment target: Static (Static/Jamstack hosting)
? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Continuous integration: None
? Version control system: Git

package.json はこんな感じ。

{
  "name": "axios-test",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "nuxt",
    "build": "nuxt build",
    "start": "nuxt start",
    "generate": "nuxt generate",
    "lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .",
    "lint:style": "stylelint \"**/*.{vue,css}\" --ignore-path .gitignore",
    "lint": "yarn lint:js && yarn lint:style"
  },
  "dependencies": {
    "@nuxtjs/axios": "^5.13.6",
    "core-js": "^3.15.1",
    "nuxt": "^2.15.7"
  },
  "devDependencies": {
    "@babel/eslint-parser": "^7.14.7",
    "@nuxt/types": "^2.15.7",
    "@nuxt/typescript-build": "^2.1.0",
    "@nuxtjs/eslint-config-typescript": "^6.0.1",
    "@nuxtjs/eslint-module": "^3.0.2",
    "@nuxtjs/stylelint-module": "^4.0.0",
    "eslint": "^7.29.0",
    "eslint-plugin-nuxt": "^2.0.0",
    "eslint-plugin-vue": "^7.12.1",
    "stylelint": "^13.13.1",
    "stylelint-config-standard": "^22.0.0"
  }
}

今回はテストするだけなので pages/index.vue をとりあえず以下のような感じにする。

<template>
  <div>
    <button @click="axiosTest('get', { name: 'tanaka', age: 31 })">
      get
    </button>
    <button @click="axiosTest('post', { name: 'tanaka', age: 31 })">
      post
    </button>
    <button @click="axiosTest('put', { name: 'tanaka', age: 31 })">
      put
    </button>
    <button @click="axiosTest('patch', { name: 'tanaka', age: 31 })">
      patch
    </button>
    <button @click="axiosTest('delete', { name: 'tanaka', age: 31 })">
      delete
    </button>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import { AxiosRequestConfig } from 'axios'

export default Vue.extend({
  methods: {
    axiosTest (
      method: 'get' | 'post' | 'put' | 'patch' | 'delete',
      data: {[key: string]: any}
    ): Promise<void> {
      const axiosConfig: AxiosRequestConfig = {
        url: 'https://axios-test2.webinlet.com',
        method
      }
      if (['get', 'delete'].includes(method)) {
        axiosConfig.params = data
      }
      if (['post', 'put', 'patch'].includes(method)) {
        axiosConfig.data = data
      }
      return this.$axios(axiosConfig)
        .then((result: any) => {
          // eslint-disable-next-line no-console
          console.log(result)
        })
        .catch((e: any) => {
          // eslint-disable-next-line no-console
          console.log(e.message)
        })
    }
  }
})
</script>

そしたら yarn generate して axios-test1.webinlet.com のドキュメントルートに置く。

1. CORS対策

1-0. まず結論

長くなったので先に CORS, CSRF対策済みの結論を書いておく。

まずNuxt側は nuxt.config.js に以下を追加

{
  ...,
  axios: {
    baseUrl: 'https://axios-test2.webinlet.com',
    credentials: true
  },
  ...
}

そして php 側は以下の index.php で成功する。

<?php
// セッションを利用する
session_start();

// CORS対策
header('Access-Control-Allow-Origin: https://axios-test1.webinlet.com');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Credentials: true');
header('Content-Type: application/json');

// CSRFトークンの一致を確認
$csrfCookieName = 'XSRF-TOKEN';
$verified =
  !empty($_SESSION['csrfToken']) &&
  !empty($_COOKIE[$csrfCookieName]) &&
  ($_SESSION['csrfToken'] === $_COOKIE[$csrfCookieName]);

// 今回はワンタイムトークンってことで新規にトークンを作り直す
$csrfToken = bin2hex(random_bytes(32));
setcookie($csrfCookieName, $csrfToken);
$_SESSION['csrfToken'] = $csrfToken;

// レスポンスを返す
echo json_encode(
  $verified ? ['name' => 'tanaka'] : [],
  JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);

では順に解説する。

とりあえずハローワールドのJSONを返すだけの php を書いてみる。

ファイル名は index.php にして axios-test2.webinlet.com のドキュメントルートに置く。

<?php
header('Content-Type: application/json');
echo json_encode(
  [
    'message' => 'hello world.'
  ],
  JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);

ブラウザから https://axios-test2.webinlet.com/ にアクセスして json が表示されているのを確認する。

ではいよいよ本題。

chrome で https://axios-test1.webinlet.com/ を開く。

Console を確認したいのでデベロッパーツールを開いておく。

画面に並ぶ get, post, put, patch, delete のボタンを押すとどうなるか?

GET → ×
POST → ×
PUT → ×
PATCH → ×
DELETE → ×

Access to XMLHttpRequest at ‘https://axios-test2.webinlet.com/’ from origin ‘https://axios-test1.webinlet.com’ has been blocked by CORS policy: Response to preflight request doesn’t pass access control check: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

はい、どれを押しても CORS にきちんと引っ掛かる。まずはこれを回避するにはどうすればいいのか?

APIサーバー側、即ちaxios-test2.webinlet.com 側が HTTPヘッダに色々書いてあげて axios-test1.webinlet.com からのアクセスを許可してあげればよい。

HTTPヘッダに追加する方法は apache で設定する方法と php が設定する方法があるが、今回は php で設定する。

1-1. Origin

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Allow-Origin

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

https://axios-test1.webinlet.com を許可したいのでそれを記述する。https:// も含める。

<?php
header('Access-Control-Allow-Origin: https://axios-test1.webinlet.com');
header('Content-Type: application/json');
echo json_encode(
  [
    'message' => 'hello world.'
  ],
  JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);

では先ほどと同様にボタンを押してアクセスしてみる。

GET → 〇
POST → ×
PUT → ×
PATCH → ×
DELETE → ×

この時点で GET メソッドによるアクセスは成功することが分かる。

ステータスは 200 を受け取るし、ハローワールドも取得している。

でも POST, PUT, PATCH, DELETE は NG。

POST の場合のエラーメッセージはこちら。

Access to XMLHttpRequest at ‘https://axios-test2.webinlet.com/’ from origin ‘https://axios-test1.webinlet.com’ has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.

PUT, PATCH, DELETE のエラーメッセージはこんな感じ。

Access to XMLHttpRequest at ‘https://axios-test2.webinlet.com/’ from origin ‘https://axios-test1.webinlet.com’ has been blocked by CORS policy: Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.

順番に対応していく。

1-2. Methods

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Allow-Methods

「リソースにアクセスするときに利用できる1つまたは複数のメソッドを指定」とのこと。

PUT, PATCH, DELETE は許可されてなかったのでエラーメッセージが POST のときと違っていたということ。

では追加してみる。

<?php
header('Access-Control-Allow-Origin: https://axios-test1.webinlet.com');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Content-Type: application/json');
echo json_encode(
  [
    'message' => 'hello world.'
  ],
  JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);

では先ほどと同様にボタンを押してアクセスしてみる。

GET → 〇
POST → ×
PUT → ×
PATCH → ×
DELETE → 〇

なんとこの時点で DELETE は通ってしまった。

おそらくリクエストボディを渡すか渡さないかで扱いが違うのだろう。

今回 delete は params のみでデータを渡しているので get と同様に通過した模様。

さて、通過しなかった者たちのエラーメッセージを見てみる。

Access to XMLHttpRequest at ‘https://axios-test2.webinlet.com/’ from origin ‘https://axios-test1.webinlet.com’ has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.

content-type が許可されてないとかぬかしておられる。

1-3. Headers

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Allow-Headers

「実際のリクエストの間に使用できる HTTP ヘッダーを示す」とのこと。

このページ読んでみると content-type はデフォルトで許可されてるとかかいてあるような??

だとしたらなんでさっきのようなエラーがでるのか。。。🤔

まあ、いいや。とりあえず許可してあげて反応を確かめよう。

(追記: Content-Type: application/json はデフォルトでは許可されないらしい。)

<?php
header('Access-Control-Allow-Origin: https://axios-test1.webinlet.com');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Access-Control-Allow-Headers: Content-Type');
header('Content-Type: application/json');
echo json_encode(
  [
    'message' => 'hello world.'
  ],
  JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);

では先ほどと同様にボタンを押してアクセスしてみる。

GET → 〇
POST → 〇
PUT → 〇
PATCH → 〇
ELETE → 〇

全部成功した!

1-4. クッキー

さて、フレームワークも何も利用していないので CSRF 対策も自前で構築してしまおう。

サーバー側がクライアント側に対してCSRFトークンを渡す手段は大きく2種。

・HTML中のformタグの中のinput type=”hidden”に値を書いた状態でレスポンスする
・Cookieで渡す

今回はAPIとして axios で叩くのでCookieで渡す方法の1択。

ではクライアントがクッキーでトークンを受け取ったとする。

そのクライアント側がサーバー側に対してCSRFトークンを渡す手段は大きく3種。

・リクエストボディ内で渡す
・Cookieで送り返す
・HTTPヘッダ内で渡す

1番目や3番目の方法はクロスオリジンのだとそもそもjavascriptが他オリジンのcookieを読めないのでaxiosでは実現できない。

(もしかしたら私が知らないだけで何か方法あるのかもしれないけど少なくともaxiosのソースコード読む限りdocument.cookieしか読みに行ってないので無理。)

よってこちらも2番目のCookieで送り返す方法の1択しかない。

※javascriptは他オリジンのCookieを読むことはできなくても単に送り返すことはできるらしい。

つまり axios でクッキーを送らなければそもそも CSRF 対策ができない。

試しに以下のような cookie.php というものを作ってみる。

<?php
print_r($_COOKIE);
setcookie('abc', '123');

何を意図しているかというと、

・現在ブラウザが保存しているクッキーをprint_rで表示。
・ブラウザに’abc’という名前で’123’という値をクッキーに保存するように指示。

というスクリプト。

ここに curl でアクセスしてみる。

$ curl -k -D - https://axios-test2.webinlet.com/cookie.php
HTTP/1.1 200 OK
Date: Sun, 26 Jun 2022 00:59:39 GMT
Server: Apache/2.4.6 (CentOS) OpenSSL/1.0.2k-fips PHP/7.1.24 Phusion_Passenger/5.1.12
X-Powered-By: PHP/7.1.24
Set-Cookie: abc=123
Content-Length: 10
Content-Type: text/html; charset=UTF-8

Array
(
)

print_rの結果は空の配列。まだブラウザは何も保存していないので当然の結果。

setcookie関数が Set-Cookie: abc=123 というヘッダをレスポンスに与えるている。

さて、同じスクリプトに再度 curl でアクセスしてみる。

ただしコマンドを叩く自分がブラウザになったつもりでヘッダにクッキーを付けてアクセスする。

$ curl -k -D - -H 'Cookie: abc=123' https://axios-test2.webinlet.com/cookie.php
HTTP/1.1 200 OK
Date: Sun, 26 Jun 2022 12:04:40 GMT
Server: Apache/2.4.6 (CentOS) OpenSSL/1.0.2k-fips PHP/7.1.24 Phusion_Passenger/5.1.12
X-Powered-By: PHP/7.1.24
Set-Cookie: abc=123
Content-Length: 27
Content-Type: text/html; charset=UTF-8

Array
(
    [abc] => 123
)

意図した結果を得ることができた。PHP側はブラウザが abc=123を持っていると判断できている。

さて、今度は curl ではなく axios でこれをやるわけだがこの辺りから話がややこしくなってくる。

まず index.php を次のように書き換える。

<?php
header('Access-Control-Allow-Origin: https://axios-test1.webinlet.com');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Access-Control-Allow-Headers: Content-Type');
header('Content-Type: application/json');
echo json_encode(
  $_COOKIE,
  JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
setcookie('abc', '123');

では今までと同様にボタンを押してアクセスしてみる。

GET → []
POST → []
PUT → []
PATCH → []
DELETE → []

さきほど curl は Cookie ヘッダにクッキー情報を載せたが

今回 axios は何もしていないので当然 $_COOKIE の中身は空っぽのままになっている。

axios で cookie をセットするには withCredentials というオプションをつけるだけでよい。

あとは axios が勝手にヘッダに Cookie をセットしてくれる。わざわざ abc=123 とか書かなくていいので楽。

  this.$axios({
    url: 'https://axios-test2.webinlet.com',
    method: 'get',
    withCredentials: true
  })

ただし nuxtjs/axios の場合は nuxt.config.js に以下を記載する方法もある。

{
  ...,
  axios: {
    baseUrl: 'https://axios-test2.webinlet.com',
    credentials: true
  },
  ...
}

API の投げ先が複数にならないのであればこっちに指定してあげる方が楽。

credential と baseUrl はセットで書かないとNGみたい。

この場合 axios を書くときは

  this.$axios({
    url: '/',
    method: 'get'
  })

これだけでよい。もちろん以下のままでもよい。

  this.$axios({
    url: 'https://axios-test2.webinlet.com',
    method: 'get'
  })

ではさきほどと同様にボタンを押してみると、

GET → ×
POST → ×
PUT → ×
PATCH → ×
DELETE → ×

全て CORSエラー!先ほど全て解決したと思っていた CORS エラーが再び復活。

Access to XMLHttpRequest at ‘https://axios-test2.webinlet.com/?name=tanaka&age=31’ from origin ‘https://axios-test1.webinlet.com’ has been blocked by CORS policy: The value of the ‘Access-Control-Allow-Credentials’ header in the response is ” which must be ‘true’ when the request’s credentials mode is ‘include’. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

axios で credential 使うにはサーバー側が credential を許可してやらなきゃいかんという話みたい。

1-5. Credentials

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials

header(‘Access-Control-Allow-Credentials: true’);

を index.php に追加してみる。

<?php
header('Access-Control-Allow-Origin: https://axios-test1.webinlet.com');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Credentials: true');
header('Content-Type: application/json');
echo json_encode(
  $_COOKIE,
  JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
setcookie('abc', '123');

では今までと同様にボタンを押してアクセスしてみる。

GET → {“abc”: “123”}
POST → {“abc”: “123”}
PUT → {“abc”: “123”}
PATCH → {“abc”: “123”}
DELETE → {“abc”: “123”}

クッキーを保存できていることを確認。

Cookieを保持させておく時間とかの話は脱線してしまうのでここでは割愛する。

1-6. CSRF 対策

さて、めでたくクッキーも送信できるようになったので早速CSRF対策してみる。

CSRF対策っていうのはざっくりこういう話。

(1) サーバー側(=axios-test2.webinlet.com)が1度だけ利用できるトークンを発行する。

 「貴方がウチにデータ送るときはこのトークンと一緒に投げてね。トークンが合ってないとリクエスト弾くんで。」

(2) クライアント側(=axios-test1.webinlet.com)はトークンをクッキーに記載してサーバーへリクエストする。

 「こちらがクッキーと、こちらが本来送りたかったデータです。確認お願いします。」

(3) サーバー側がクッキー中に含まれるトークンを読み取って、それが手元(セッション)のデータと一致していることを確認する。

 「確認しましたよ!一致してるので貴方は正しいクライアントですね。ではデータ受け取りますねー。」

(4) サーバー側がデータを受け取って何かしらの処理をしてクライアントに返答する。この際再度トークンを発行する。

 「データ処理したよ!これがレスポンスね。あとそれからこっちが新しいトークンね。前のは使えないからよろしく!」

ちなみに上の例は一度しか使えないワンタイムトークンで会話しているが、

考え方やフレームワークによっては永続的なトークンを利用するパターンもあるらしい。

参考 https://qiita.com/mpyw/items/0595f07736cfa5b1f50c#%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%AE%E7%94%9F%E6%88%90%E6%96%B9%E6%B3%95

いま get, post, put, patch, delete の各ボタンのどれを押しても

axios のレスポンス結果を console.log している。

中を確認してみると config という項目があり $axios() で指定しているパラメータの中身が確認できる。

url, method, data, params はコード内で指定しているがデフォルトで指定されているパラメータがいくつか見受けられる。

その中の

xsrfCookieName: “XSRF-TOKEN”

これが CSRFトークンを載せるクッキー名になっている。

(※ちなみに xsrfHeaderName というのがあるがクロスドメイン時は利用できないと考えていい。)

送信時や nuxt.config.js で任意に変更できるみたいだが、とりあえず今回はデフォルトのこのままでやってみる。

なのでサーバー側(=axios-test2.webinlet.com)に対策を追加する。

トークンは本当にどんな文字列でもよいのでとりあえず適当に bin2hex(random_bytes(32)) こんな感じにしてみる。

では index.php を次のように変更する。

<?php
// セッションを利用する
session_start();

// CORS対策
header('Access-Control-Allow-Origin: https://axios-test1.webinlet.com');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Credentials: true');
header('Content-Type: application/json');

// CSRFトークンの一致を確認
$csrfCookieName = 'XSRF-TOKEN';
$verified =
  !empty($_SESSION['csrfToken']) &&
  !empty($_COOKIE[$csrfCookieName]) &&
  ($_SESSION['csrfToken'] === $_COOKIE[$csrfCookieName]);

// 今回はワンタイムトークンってことで新規にトークンを作り直す
$csrfToken = bin2hex(random_bytes(32));
setcookie($csrfCookieName, $csrfToken);
$_SESSION['csrfToken'] = $csrfToken;

// レスポンスを返す
echo json_encode(
  $verified ? ['name' => 'tanaka'] : [],
  JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);

では今までと同様にボタンを押してアクセスしてみる。

1回目 get → [] (空っぽ)
2回目 get → {“name”: “tanaka”}
3回目 post → {“name”: “tanaka”}
4回目 put → {“name”: “tanaka”}
5回目 patch → {“name”: “tanaka”}
6回目 delete → {“name”: “tanaka”}

1回目はトークンを渡していないのでトークンの不一致とみなして空っぽの配列をレスポンスしている。

2回目以降はメソッドに関係なくトークンがきちんと渡っているので正しくレスポンスしている。

成功!

1-7. おまけ

CORS対策で Access-Control-Allow の Origin, Methods, Headers を取り扱ったが

これらはリファレンスによるとワイルドカード * 指定をサポートしているらしい。

なので今回の様に

header('Access-Control-Allow-Origin: https://axios-test1.webinlet.com');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Access-Control-Allow-Headers: Content-Type');

などと書かなくても

header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: *');
header('Access-Control-Allow-Headers: *');

これでいいじゃないかと。

セキュリティ的に良くないのは分かりつつもとりあえず動くのであれば動かないよりいいじゃないかと。

古いネットにこう書かれている記事もまだ見受けられるが・・・、これ今はアウト。

少なくとも 2022/06/27 時点の chrome だと動かない。

具体的にどうなるのかを検証してみた。

1-7-1. header(‘Access-Control-Allow-Origin: *’);

これをすると

GET → ×
POST → ×
PUT → ×
PATCH → ×
DELETE → ×

全部アウト。おそらく credential を入れる場合は * ではなくてキチンとオリジン指定する必要がある。

しかしサーバーの立場からすると不特定多数のクライアントからのリクエストを受け付けたい場合もあるだろうし、

特定済みのクライアントを複数許可したい場合もある。

ところが Access-Control-Allow-Origin は1つしかオリジンを登録できない!

なのでどうするかというとサーバー側の php が リクエストヘッダを読み取って Origin を取得し、

その Origin を許可してあげればよい。

例えば不特定多数を許可したければ

header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);

こんな感じ。

もちろん実際使うときは予め許可するリストを作るなり正規表現で絞り込むなどして利用すること。

1-7-2. header(‘Access-Control-Allow-Methods: *’);

これをすると

GET → 〇
POST → 〇
PUT → ×
PATCH → ×
DELETE → ×

PUT以下が失敗してしまう。

GET, POSTは比較的昔からあるメソッドだから通ってるとか?よくわからない。。。

おとなしくGET, POST, PUT, PATCH, DELETE全て記載しよう。

1-7-3. header(‘Access-Control-Allow-Headers: *’);

これをすると

GET → 〇
POST → ×
PUT → ×
PATCH → ×
DELETE → 〇

リクエストボディを送信している場合にCORSエラーが出る。

これは前述している application/json の場合は指定する必要がある件と関連がありそう。

というかもしかして * に対応していなくて単に * というヘッダ名と解釈してる??

おとなしくContent-Type を記載しよう。