「CORS エラーで画像アップロードが落ちる」と思ったら AWS WAF のデフォルトルールだった話
TL;DR
- 2MB の画像を POST したら、ブラウザに
No 'Access-Control-Allow-Origin' header is present+net::ERR_FAILEDが出た - サーバー側の CORS 設定は何度見直しても正しい
- 犯人は API Gateway の前に置いていた AWS WAF の
AWSManagedRulesCommonRuleSetに含まれるSizeRestrictions_BODY(8KB 超の body を Block) - WAF が返す 403 に CORS ヘッダが付かないのでブラウザは「CORS 違反」と表示するが、実体は CORS の話ではなく WAF で弾かれている
- 解決: WAF のマネージドルールで
SizeRestrictions_BODYだけアクションをcountに上書きする
起きたこと
個人開発の web アプリ (Next.js + API Gateway + Lambda) で、ユーザーのアバター画像を POST する機能を実装。
ローカルでは動くのに、本番で 2MB の画像をアップロードするとブラウザに:
Access to fetch at 'https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/topic-image'
from origin 'https://www.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Failed to load resource: net::ERR_FAILED
TypeError: Failed to fetch
これまで正常だったもの
- CORS preflight (OPTIONS):
204で ACAO ヘッダも付いている - 小さいペイロードの POST: 正常動作 (401 だが ACAO 付き)
→ ペイロードが小さい POST は通るのに、大きい POST だけ CORS エラーになる。CORS 設定が正しいことは curl で検証済。
犯人の特定
curl で 2MB の body を送って生レスポンスを見てみた:
node -e "
const b64 = Buffer.alloc(1500000, 'A').toString('base64');
const body = JSON.stringify({imageBase64: b64, contentType: 'image/jpeg'});
fetch('https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/topic-image', {
method: 'POST',
headers: {'Origin': 'https://www.example.com', 'Content-Type': 'application/json'},
body,
}).then(r => {
console.log('Status:', r.status);
console.log('ACAO:', r.headers.get('access-control-allow-origin'));
});"
結果:
Status: 403
ACAO: null
Body: {"message":"Forbidden"}
403 Forbidden でヘッダに ACAO も無い。これは Lambda まで到達していない。API Gateway の手前、つまり WAF がブロックしている。
原因: AWSManagedRulesCommonRuleSet の SizeRestrictions_BODY
AWS Managed Rule の Common Rule Set に含まれる SizeRestrictions_BODY は、デフォルトで body が 8192 バイトを超えるとブロックする。
画像のような大きな body を想定する API にこのルールを素で当てると即死する。
しかも厄介なのは:
- WAF の Block レスポンス (403) には CORS ヘッダが付かない
- 結果、ブラウザは「クロスオリジンなのに ACAO が無い = CORS 違反」と判定
- フロントエンドの開発者は「自分の CORS 設定を見直そう」と思考が偏る
- でもサーバー側の CORS は壊れていない
典型的な「症状と原因が一致しない」トラブル。
解決: SizeRestrictions_BODY を count に上書き
AWS WAFv2 のマネージドルールは、ルール単位でアクションを override できる。CDK で書くと:
const webAcl = new wafv2.CfnWebACL(this, 'ApiWebAcl', {
scope: 'REGIONAL',
defaultAction: { allow: {} },
rules: [
{
name: 'AWSManagedCommon',
priority: 0,
overrideAction: { none: {} },
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesCommonRuleSet',
// ここで特定ルールだけアクションを上書き
ruleActionOverrides: [
{ name: 'SizeRestrictions_BODY', actionToUse: { count: {} } },
],
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedCommon',
sampledRequestsEnabled: true,
},
},
],
// ...
});
count に変更すると「ルールが発火したことは CloudWatch に記録するが、Block はしない」状態になる。監視は残しつつ通すという中庸な運用ができる。
なぜ CORS エラーに化けるのかの補足
API Gateway + Lambda Proxy Integration 構成では、Lambda が成功レスポンスに手動で CORS ヘッダを埋め込むのが普通。
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': 'https://www.example.com',
// ...
},
body: JSON.stringify(...),
};
一方、WAF の Block は Lambda より前で発生する。API Gateway の GatewayResponse 側で CORS ヘッダを埋めることも可能だが、デフォルトでは埋まっていない。
よってフロント側は:
- CORS preflight (OPTIONS) は正常 (Lambda or API Gateway mock integration で CORS 返す)
- 本番 POST: WAF block → 403 with empty headers → ブラウザ「CORS エラー」
という順序で詰まる。
根本対策の選択肢
- (採用) WAF のサイズ制限を緩める — 今回はこれで OK、Lambda 側で別途サイズ上限バリデーションあり
- API Gateway の
GatewayResponseで CORS ヘッダを埋める — WAF に弾かれても見た目のエラーがまともになる - S3 presigned PUT に切り替える — 大容量のアップロードならこれが正攻法。API Gateway の 10MB 上限も気にしなくてよい
学んだこと
- 「CORS エラー」という表示は、本当に CORS の話とは限らない。レスポンスの status code と実際のヘッダを curl で生確認する
- WAF / CloudFront / API Gateway など Lambda より前段 の何かがブロックしているとき、エラーはだいたい「CORS エラー」化する
- AWS Managed Rule はゼロチューニングで本番投入すると落とし穴に落ちる。Common Rule Set に入っている 20+ のルールをそれぞれ自サービスのユースケースで見直すべき
- ルールを削除するのではなく
countに落として様子見するのが運用的に安全
参考
LocoCount (https://lococount.com) の実装中に遭遇した話でした。地図上の話題をカウントする web アプリです。