TL;DR
- 署名付き URL は CORS 設定が必要
- CORS はヘッダーに気をつけろ
- 公式ドキュメントを読め
はじめに
GAE にデプロイした Web アプリで、署名付き URL を使って GCS にファイルをアップロードしようとしたら、CORS エラーが一生出続け解決に手間取ったのでこの記事にまとめます。
GCS の署名付き URL については、同じアドベントカレンダーのこちらの記事にも紹介があります。ということで、なんで署名付き URL なの?とか設定のお話とかは丸投げさせてもらって本題に。
ちなみに、先ほど挙げさせてもらった記事にも CORS エラーの注意喚起と回避手順が記載されていますが、本記事ではより具体的な内容と、axios を使っていた時に発生した特殊なケースについて紹介したいと思います。
構成
- クライアントから GAE へ何らかのリクエスト
- GAE からクライアントへ何らかのレスポンス
- クライアントからの要求に応じて DB への CRUD 操作
- セッション情報の管理
- 署名付きURLからのダウンロード or アップロード
システム構成はざっくりこんな感じです。
システムのマスタデータやトランザクションデータは Cloud SQL に格納し、ファイル管理用のオブジェクトストレージとして GCS を利用しています。
Firestore については、GAE は"フルマネージド"PaaSであり、つまり、Web アプリを実行しているインスタンスをこちらでコントロールできないため、オンメモリでセッション管理とかやっていると、裏で勝手にインスタンス移動とかが起きてセッション情報が消えてしまう可能性があります。(というか昔ハマった)
そこで Google の公式ドキュメントでも説明されている Firestore でのセッション処理の形態を選択しています。
ファイルアップロード処理
今回の Web アプリは、Express(Node.js) フレームワークを利用して作成しています。また、フロント側は EJS テンプレートを使っています。
このシステムではファイルの保存と、ファイルに付随するメタ情報を DB に保存という要件だったため、下記の処理フローで実装しています。
- ファイルの種別と GCS の URI をレコードとして Cloud SQL に格納
- ファイルを GCS にアップロード
また、CSRF (IPA の解説) 対策として、バックエンドからランダムトークンを発行しフロント側に埋め込み、でフロント-バックエンド間の通信を行っています。
さらに、フロントエンドでの非同期通信には axios を使っています。
<!DOCTYPE html>
<html lang="ja">
<head>
<title>サンプル</title>
</head>
<body>
<!-- 本文 -->
<!-- CSRF 対策のトークンを埋め込み -->
<input type="hidden" id="_csrf" name="_csrf" value="<%= common.csrfToken %>">
<!-- 入力フォーム -->
<label>種別</label>
<select name="fileKind" id="fileKind">
<option value="hoge">hoge</option>
<option value="fuga">fuga</option>
<option value="piyo">piyo</option>
</select>
<br>
<input type="file" name="uploadFile" id="uploadFile">
<br>――――――――――――――<br>
<button id="submitBtn">保存</button>
<!-- スクリプト -->
<!-- 非同期処理に axios を使用 -->
<script type='text/javascript' src='/axios/axios.min.js'></script>
<script>
const bucketName = '■■■■■■■■■■■■■■■■'; // バケット名
// 送信ボタン処理
document.getElementById('submitBtn').addEventListener('click', async () => {
// セレクト値を取得
const fileKind = document.getElementById('fileKind').value;
// ファイル情報を取得
const file = document.getElementById('uploadFile').files[0];
const gcsUri = `${fileKind}/${file.name}`; // バケット下のパスを設定
const contentType = file.type; // コンテンツタイプ
try {
// DB にレコードを登録
const insertResult = await insertRecord(fileKind, gcsUri, contentType);
if (insertResult) {
console.log('DB insert: done');
// 署名付き URL を取得
const signedUrl = await getUploadSignedUrl(gcsUri, contentType, bucketName);
// ファイルをアップロード
await fileUpload(signedUrl, file, contentType);
console.log('GCS upload: done');
}
} catch (e) {
alert ('error!!!');
}
});
// 以下、関数
// バックエンドにレコードを追加する処理をリクエスト
const insertRecord = async (fileKind, gcsUri, contentType) => {
const data = {
fileKind: fileKind,
gcsUri: gcsUri,
contentType: contentType,
};
const options = {
headers: {
'X-CSRF-TOKEN': document.getElementById('_csrf').value,
'Content-Type': 'application/json',
}
};
const result = await axios.post('/sample-gcs-upload/insert-record', JSON.stringify(data), options);
return result // boolean
}
// バックエンドに署名付き URL を取得する処理をリクエスト
const getUploadSignedUrl = async (gcsUri, contentType, bucketName) => {
const data = {
gcsUri: gcsUri,
contentType: contentType,
bucketName: bucketName,
};
const options = {
headers: {
'X-CSRF-TOKEN': document.getElementById('_csrf').value,
'Content-Type': 'application/json',
}
};
const res = await axios.post('/sample-gcs-upload/get-upload-signedurl', JSON.stringify(data), options);
return res.data; // 署名付き URL
}
// GCS にファイルをアップロード(PUT)
const fileUpload = async (signedUrl, file, contentType) => {
const options = {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': contentType
}
};
await axios.put(signedUrl, file, options);
}
</script>
</body>
</html>
const { Storage } = require('@google-cloud/storage');
app.post('/sample-gcs-upload/get-upload-signedurl', async (req, res) => {
// POST パラメータを取得
const gcsUri = req.body.gcsUri;
const contentType = req.body.contentType;
const bucketName = req.body.bucketName;
// GCS オブジェクト情報の取得
const storage = new Storage();
const bucket = storage.bucket(bucketName);
const file = bucket.file(gcsUri);
// 署名付き URL 取得
const config = {
action: 'write',
version: 'v4',
expires: Date.now() + 60 * 60 * 1000, // 60 minutes
contentType: contentType,
};
const result = await file.getSignedUrl(config);
// フロントに署名付き URL を返送
res.send(result[0]);
});
(DB への保存は今回の記事では本筋ではないためレコード登録処理は割愛)
動作確認(403 エラー)
さて、これで Web アプリの実装は完了したのでテストしてみます。
サンプルなのでちゃっちいのは置いておき、、とりあえず実行してみます。
これは、先の紹介記事にもある通り、オリジン間リソース違反によって PUT リクエストが拒否されているためです。
なので、GCS バケット側に CORS 設定を入れてやりましょう。
GCS バケットに CORS を設定する
設定方法とパラメータは公式ドキュメントを参照ください。今回は GCP コンソールから gsutil cors set
を使って設定します。
オリジンは今回はローカル環境のため localhost:3306
から、HTTP メソッドは GET, POST, PUT, DELETE
を許可します。
また、許可するヘッダーは Content-Type
と Access-Control-Allow-Origin
を設定します。
[
{
"maxAgeSeconds": 3600, "method": ["GET", "POST", "PUT", "DELETE"],
"origin": ["http://localhost:3306"],
"responseHeader": ["Content-Type", "Access-Control-Allow-Origin"]
}
]
gsutil cors set cors_setting.json gs://$BUCKET_NAME
実行結果は以下のコマンドで確認できます。
gsutil cors get gs://$BUCKET_NAME
[{"maxAgeSeconds": 3600, "method": ["GET", "POST", "PUT", "DELETE"], "origin": ["http://localhost:3306"], "responseHeader": ["Content-Type", "Access-Control-Allow-Origin"]}]
動作確認(200 OK)
もう一度画面からファイルアップロードを試してみると、ステータスが 200 で問題なくアップロードをできるようになったことが確認できます。
(オマケ) axios 併用で沼った件
上に書いたところまででひとまずはめでたしめでたしで終わりなのですが、axios を併用したことで思わぬ落とし穴にハマり時間と精神をすり減らした話について書きます。
(そもそも CORS をちゃんと理解できていなかったという点も大きいですが、、)
axios って何?
axios とは簡単に言えば、非同期処理(XMLHttpRequests)を使いやすくまとめてくれているライブラリです。フロントエンドだけでなくNode.js のパッケージとしても提供されており、バックエンドでも HTTP リクエストに利用することができます。
いままでは Fetch API をラップして非同期処理を書いていたりしたのですが、それをするならすごい人達が用意してくれた、より洗練されたものを使うべきということで導入しました。
どこが落とし穴だったの?
結論から言うと、ヘッダーです。
この節にも書いたのですが、このシステムでは CSRF 対策として、HTML にバックエンドで発行されたトークンを埋め込んでいます。そして、バックエンドと通信を行う際は、必ずヘッダーに CSRF トークンを入れて通信する必要があります。
このとき、非同期通信を行うならば当然 axios を使うのですが、このシステムでは他システムとの連携は特になかったので、基本的に axios のヘッダーはデフォルトで CSRF トークンが埋め込まれる状態で問題ありませんでした。
そして、axios にはそういう場合に便利な axios.defaults.headers.common
というコンフィグが用意されており(公式ドキュメント)、下記の設定により一々ヘッダーを設定する必要がなくなりました。
axios.defaults.headers.common['X-CSRF-TOKEN'] = document.getElementById('_csrf').value;
しかし、この設定を行ったことによって、署名付き URL の CORS 設定を違反することになります。
CORS はこちらの記事に詳しいですが、特定の条件に収まらない場合、実際のリクエストを送信する前に、プリフライトリクエストと呼ばれる通信を行います。このとき、リクエストヘッダーが許可されているものかチェックを事前に行い、本リクエストを許可するか判定が行われます。
今回 GCS へ行うリクエストは PUT
メソッドかつ、許可するヘッダーも Content-Type
と Access-Control-Allow-Origin
となるためプリフライトリクエストによる確認が行われ、許容されていない X-CSRF-TOKEN
が含まれているのでアップロードを拒否されるという事象に陥りました。
キツかったこと
原因解明ができた今となってはすごく簡単なことに思えますが、当時は原因が全く分からず四苦八苦していました。
デフォルトヘッダー設定部分については共通処理として別ファイルに分離していたため、ファイルアップロード処理で設定していたヘッダーは問題ないと錯覚してしまっていました。
// GCS にファイルをアップロード(PUT)
const fileUpload = async (signedUrl, file, contentType) => {
const options = {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': contentType
}
};
// 共通処理化した axios.defaults.headers.common['X-CSRF-TOKEN'] によって見えない内に含まれてしまっている
await axios.put(signedUrl, file, options);
}
何よりも、発生したエラー内容から X-CSRF-TOKEN
が含まれていることが原因と判別することが非常に難しかったことが最大の原因になったと思います。
実際のエラー
許可している Access-Control-Allow-Origin
, Content-Type
がエラーメッセージに表示され、原因の X-CSRF-TOKEN
に関するメッセージが一切表示されていない...
エラーメッセージでググっても Access-Control-Allow-Origin
, Content-Type
を CORS の許可ヘッダーに設定しろとかしかヒットしない...(当然)
結局、postman をつかってヘッダー見たら X-CSRF-TOKEN
が入ってんじゃんってことで解消しました。
総括
CORS
CORS について何となくの理解でとどめてしまっていたので、ヘッダーが原因であることに気付くのが遅れてしまいました。
使う技術、特に導入するライブラリなんかはできる限りドキュメントを読まないとだめですね。GCS の CORS 設定も署名付き URL も方法だけ確認して、問題なく動いたから OK としてしまっていたが、当然だけど公式ドキュメントに GCS の CORS リクエストの仕様が詳しく載っています。エラー表示は何とかしてほしいが
axios のデフォルトヘッダー
当初は他システムと連携しないからという理由でデフォルトヘッダーに CSRF トークンを入れ込んでいましたが、GCS との通信といった部分が後から出てきたため、使い方はよく改めなければいけないです。
でなければ知らずのうちに適さないデータを所かまわず送ってしまうリスクがあったわけで...
なんだかんだで問題の調査と解決を単独で行えましたし、CORS の理解も(前よりは)深まったので結果的に良しとしたいと思います。
ただ痛い思いをするからこそ身につくというのもありますが。。。