AWS 2026年9月7日

「静的サイトだから安全」のつもりが、S3のURLを直で叩くと中身が返っていた。OAC+バケットポリシー+WAFで多層防御にした記録

XECIN CloudFrontWAFセキュリティ技術検証

社内のセキュリティ棚卸しで、自社サイトの欄に「静的サイトのため、アプリケーション層の対策は不要」と自分で書きました。

その翌週です。別件で S3 のコンソールを開いていて、オブジェクトの URL をなんとなくブラウザに貼ってみたら、普通にトップページが表示されました。CloudFront を経由していない、https://<bucket>.s3.ap-northeast-1.amazonaws.com/index.html の方です。

情報としては公開ページなので、漏れて困る中身ではありません。ただ、CloudFront の手前で何をしても素通りできる経路が残っているのは、設計として筋が悪い。同じ構成(Astro でビルド → S3 → CloudFront)は受託の案件でも使っているので、これは自社で先に潰しておくべきだと考えました。

作業自体は半日で終わる想定でしたが、実際には 3.5 時間かかりました。そのうち 2 時間が、後述するサブディレクトリの 404 の切り分けです。

出発点:オリジンが公開されたままだった

構成を確認すると、こうなっていました。

  • S3 バケットはパブリック読み取り可(s3:GetObject を Principal: "*" で許可)
  • CloudFront のオリジンは S3 の静的ウェブサイトホスティングのエンドポイント(<bucket>.s3-website-ap-northeast-1.amazonaws.com)
  • したがって CloudFront を経由しないアクセスも普通に通る

要するに、CloudFront は「速くするための箱」としてしか機能していなかったわけです。WAF を後から足そうが、Cache-Control を丁寧に設計しようが、S3 の URL を知っている相手には全部迂回されます。

ここを閉じる標準的な手段が OAC(Origin Access Control)です。

以前からある OAI(Origin Access Identity)との違いで実務上効いてくるのは、次の 2 点でした。

ひとつは、OAI が CloudFront 用の擬似的なユーザー(Canonical User ID)としてバケットポリシーに現れるのに対して、OAC はサービスプリンシパル cloudfront.amazonaws.com + 条件に配布 ARN という、他の AWS サービス間連携と同じ書き方になること。ポリシーを読んだときに「どのディストリビューションが読んでいるのか」が一目で分かります。

もうひとつは、OAC が SigV4 でオリジンへのリクエストに署名する点です。OAI では扱いづらかった SSE-KMS 暗号化バケットにも対応できます。今回は SSE-S3 のままなので恩恵はありませんが、案件によっては効いてくるところです。

新規で作るなら OAC を選ばない理由は、運用を考えるとほぼ無いと思っています。

バケットポリシーを「このディストリビューションだけ」に絞る

OAC を作成して、ディストリビューションのオリジンに紐付けます。ここで大事なのは、OAC を作っただけでは何も変わらないことです。オリジンへの紐付けと、バケットポリシーの書き換えの両方をやって初めて成立します。

バケットポリシーはこうしました。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowOnlyThisDistribution",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::xecin-site-prod/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1EXAMPLEDIST"
        }
      }
    }
  ]
}

Condition を省いて Principal だけにすると、「CloudFront というサービスからなら読める」という意味になってしまいます。他人のアカウントのディストリビューションからでも読める余地が残るので、AWS:SourceArn は必須だと考えています。ドキュメントにもそう書いてありますが、サンプルをコピーして条件だけ落とす、というのは普通に起こりうるミスなんですよね。

あわせて、バケットの「パブリックアクセスをすべてブロック」を 4 項目とも有効に戻しました。

検証は curl でステータスコードを見るだけです。手順として残せる粒度にしておくと、あとで別案件に流用できます。

# 1) CloudFront 経由:200 が返ること
curl -o /dev/null -s -w "%{http_code}\n" https://xecin.jp/

# 2) S3 の REST エンドポイント直:403 になること
curl -o /dev/null -s -w "%{http_code}\n" \
  https://xecin-site-prod.s3.ap-northeast-1.amazonaws.com/index.html

# 3) S3 の website エンドポイント直:403 になること
curl -o /dev/null -s -w "%{http_code}\n" \
  http://xecin-site-prod.s3-website-ap-northeast-1.amazonaws.com/

2 と 3 が 403 になっていれば、経路は CloudFront に一本化できています。ここまでは、正直なところ手順どおりでした。

想定外だったのは、記事ページが全部 404 になったこと

問題は次でした。OAC は S3 のREST エンドポイント(<bucket>.s3.<region>.amazonaws.com)に対して使うもので、website エンドポイントには使えません。なのでオリジンを REST エンドポイントに差し替えたのですが、その瞬間にトップ以外が落ちました。

  • https://xecin.jp/ → 200
  • https://xecin.jp/news/ → 404
  • https://xecin.jp/news/astro-dogfooding-1year/ → 404

最初はキャッシュを疑って、無効化を 3 回ほど流しました。変わりません。今思えば、ここで 40 分ほど無駄にしています。

原因は、website エンドポイントが持っていた「ディレクトリを叩かれたら配下の index.html を返す」という暗黙のふるまいでした。REST エンドポイントにはこれがありません。/news/ というキーのオブジェクトは存在しないので、素直に無いと返ってくる。

Astro のビルド成果物は dist/news/index.html のような形なので、この差がそのまま全滅につながりました。CloudFront の「デフォルトルートオブジェクト」設定はディストリビューションのルートにしか効かないため、これでは解決しません。

対処として、CloudFront Functions でビューアリクエストの URI を書き換えることにしました。

// viewer-request で、末尾スラッシュのパスに index.html を補う
function handler(event) {
  var req = event.request;
  var uri = req.uri;

  if (uri.endsWith('/')) {
    req.uri = uri + 'index.html';
  } else if (!uri.includes('.')) {
    // 拡張子が無いパスはディレクトリ扱いにする
    req.uri = uri + '/index.html';
  }

  return req;
}

これで復旧はしましたが、この関数には改善の余地があります。「ドットが含まれていなければディレクトリ」という判定が雑で、たとえば将来 /downloads/2026.09 のような拡張子なしに見えるファイルを置いた瞬間に壊れます。本来はビルド時に生成されるパスの一覧を持たせるか、少なくとも既知の拡張子リストで判定すべきところです。今は自社サイトのパス構成が単純なので許容していますが、案件に持っていくならここは書き直す前提で考えています。

なお、CloudFront Functions は 100 万リクエストあたり 0.10 USD です。自社サイトの規模(月 40 万リクエスト前後)なら月 0.04 USD 程度で、コスト面は判断材料になりませんでした。

WAF をどこまで入れるか

オリジンを閉じただけで、当初の目的はほぼ達成しています。そのうえで WAF を足すかどうかは、コストとの兼ね合いで少し悩みました。

静的サイトなので、SQL インジェクションのような攻撃が刺さる先がそもそもありません。守る価値があるのは、問い合わせフォームが叩く API と、単純な帯域・リクエストの浪費だけです。

そこで「入れるルールを最小にする」方針で選び直しました。判断は次のとおりです。

ルール採否判断の理由月額
AWSManagedRulesCommonRuleSet入れたフォーム送信の入口を守る最低ライン。SizeRestrictions_BODY だけ Count に落とした1.00 USD
AWSManagedRulesKnownBadInputsRuleSet入れた既知の攻撃文字列を落とすだけで誤検知が起きにくく、費用対効果が読める1.00 USD
レートベースルール(自作)入れた実質これが本命。1 IP あたりの短時間の集中を止める0.00 USD
AWSManagedRulesSQLiRuleSet切ったオリジンに DB が無く、守る対象が存在しない-
AWSManagedRulesAnonymousIpList切ったVPN 経由の正規の閲覧者を弾く懸念があり、コーポレートサイトでは損の方が大きい-
Bot Control切った単体で月 10 USD。守る資産の価値に対して割高で、検索クローラの調整コストも乗る-

レートベースルールはこれだけです。

{
  "Name": "RateLimitPerIp",
  "Priority": 10,
  "Action": { "Block": {} },
  "Statement": {
    "RateBasedStatement": {
      "Limit": 2000,
      "AggregateKeyType": "IP"
    }
  },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "RateLimitPerIp"
  }
}

Limit は 5 分間あたりの上限です。最初は 1000 で入れたのですが、社内から複数人が同時に検証していた時間帯に Count が跳ねたので 2000 にしました。ここは好みが分かれるところですが、ブロックに切り替える前に必ず Count モードで 1 週間流すことを、社内のルールにしています。

WAF の月額は、WebACL 5.00 USD + マネージドルールグループ 2 本で 2.00 USD + リクエスト課金(100 万あたり 0.60 USD で月 40 万リクエストなら 0.24 USD)で、約 7.2 USD に着地しました。

配信そのもの(CloudFront + S3)が月 3 USD 前後なので、WAF を足すとインフラ費用は 3 倍以上になります。金額の絶対値は小さいものの、比率で見ると無視できる話ではありません。それでも入れたのは、フォームの送信先を守る手段が他に無かったからです。逆に言えば、フォームが無い純粋な静的サイトなら、OAC までで止めるという判断も十分ありえると考えています。

入れてみて分かった、直アクセスとボットの実態

導入前後で、7 日間のログを比較しました。

まず S3 側です。OAC を入れる直前の 7 日間で、CloudFront を経由しない直アクセスが 312 件 ありました。User-Agent を見ると、8 割以上がクラウドストレージを走査する類のスキャナで、残りは検索エンジンのクローラです。人が意図して叩いた形跡は見当たりませんでした。

正直なところ、実害があったとは言えない数字です。ただ、公開バケットを機械的に探して回る通信が常時来ているという事実は、可視化できてよかったと思っています。

WAF 側は、Count モードで 1 週間流したところ、全リクエストの 約 4.1% が何らかのルールに一致しました。内訳で多かったのは、存在しない /wp-login.php や /.env を探しにくるアクセスです。WordPress を使っていないサイトに WordPress の管理画面を探しにくるわけで、無差別に投げているだけなのがよく分かります。

レートベースルールに引っかかったのは 1 週間で 2 IP だけでした。閾値の設定が妥当だったのか、単に静かな週だったのかは、もう少し観測期間が要ります。

振り返って

作業時間 3.5 時間のうち 2 時間がサブディレクトリの 404 だったというのが、この件でいちばん記録に残しておきたいところです。OAC の設定手順そのものはドキュメントどおりで詰まりません。詰まるのは、website エンドポイントが暗黙に肩代わりしていたふるまいが消えたときです。

同じ構成を案件に入れるなら、次はこうしたいと考えています。

  • オリジン切り替えと URI 書き換え関数を同じ手順書の中でセットにする。片方だけ先に適用しない
  • CloudFront Functions の判定ロジックを、拡張子リスト方式に書き直してから持っていく
  • WAF は Count モードで 1 週間、を発注前の見積もりに最初から含めておく(後出しにすると調整期間が取れない)

あと、棚卸しの資料に「静的サイトなので対策不要」と書くのはやめました。静的かどうかは配信の形式の話であって、経路が閉じているかどうかとは別の問題なので。