2026-08-21

S3 の Pre-signed POST は違反した condition の種類によってステータスコードが変わる

ブラウザから S3 へ直接ファイルをアップロードさせるとき、Pre-signed URL (PUT) だとファイルサイズやファイル形式に対する制限を柔軟にかけることができません。一方 Pre-signed POST であれば policy として条件を埋め込めるという話を聞いたので、実際に動くものを作って確認してみました。

結論としては期待通りに制限をかけられたのですが、その過程で 違反した condition の種類によって、S3 が返すエラーレスポンスのステータスコードが変わる という挙動に気づきました。これが公式に定義された仕様なのかどうかを調べたので、その記録を残しておきます。

検証に使った実装は下記に置いてあります。

https://github.com/michimani/misc/tree/main/aws/s3-presigned-url

Pre-signed PUT と Pre-signed POST

まず前提として、ブラウザからの直接アップロードに使える署名済み URL には 2 種類あります。

Pre-signed PUT (PresignPutObject) は、PutObject という API 呼び出しそのものに署名する方式です。署名対象は基本的にメソッド・バケット・キー・有効期限といったリクエストの構成要素であり、「アップロードされるファイルが何バイトまでか」「Content-Type は何か」といった内容に対する条件を、URL 側から柔軟に指定する仕組みが用意されていません。Content-LengthContent-Type を署名対象のヘッダに含める形で固定値を強制することはできますが、「0〜500MB の範囲であればよい」といった範囲指定はできず、実質的にはアップロード後にサーバー側で検証するしかなくなります。

Pre-signed POST (PresignPostObject) は、HTML フォームからの multipart/form-data POST 用に policy ドキュメント を作り、その policy に対して署名する方式です。policy には conditions という配列を持たせられて、ここにサイズの範囲や各フォームフィールドの値に対する制約を書けます。S3 は POST を受け取った時点で policy の条件を評価し、満たさなければ オブジェクトを作らずに 4xx を返す ので、アプリケーションサーバーを一切経由せずに入口で弾けます。

今回試したかったのは後者です。

実装

バックエンド (Go) 側で、一時バケットへのアップロード用に Pre-signed POST を発行しています。PresignPostOptions.Conditions に、サイズ制限と Content-Type 制限の 2 つを付与しました。

const maxUploadBytes = 500 * 1024 * 1024
const allowedContentType = "application/pdf"

out, err := h.s3.Presign.PresignPostObject(r.Context(), &s3.PutObjectInput{
	Bucket: aws.String(h.cfg.TmpBucket),
	Key:    aws.String(id),
}, func(o *s3.PresignPostOptions) {
	o.Expires = presignExpiry
	o.Conditions = []any{
		[]any{"content-length-range", 0, maxUploadBytes},
		map[string]string{"Content-Type": allowedContentType},
	}
})

返ってくる out.Values (fields) の中には policy という Base64 エンコードされた JSON が含まれていて、デコードすると次のような内容になっています。bucketX-Amz-*key は SDK が自動で付与するものです。

{
  "conditions": [
    { "X-Amz-Algorithm": "AWS4-HMAC-SHA256" },
    { "bucket": "presigned-tmp" },
    { "X-Amz-Credential": "..." },
    { "X-Amz-Date": "..." },
    ["content-length-range", 0, 524288000],
    { "Content-Type": "application/pdf" },
    { "key": "..." }
  ],
  "expiration": "..."
}

フロントエンド側は、返ってきた fields をそのまま FormData に詰め、選択されたファイルの file.typeContent-Type フィールドとして追加し、最後に file フィールドを追加して POST するだけです。POST ポリシーの仕様上、file フィールドは必ず最後に置く必要があります。

const form = new FormData();
for (const [k, v] of Object.entries(fields)) {
  form.append(k, v);
}
form.append("Content-Type", file.type);
form.append("file", file); // file は必ず最後
await fetch(uploadUrl, { method: "POST", body: form });

気づいた挙動

この状態で、条件に違反するファイルをアップロードしてみたところ、意図通りエラーになりました。ただ、エラーの返り方が条件によって違いました

違反した condition ステータスコード エラーコード
content-length-range (サイズ超過) 400 Bad Request EntityTooLarge
Content-Type (形式不一致) 403 Forbidden AccessDenied

レスポンスボディの XML はそれぞれこんな内容です。

<Error>
  <Code>EntityTooLarge</Code>
  <Message>Your proposed upload exceeds the maximum allowed size</Message>
  ...
</Error>
<Error>
  <Code>AccessDenied</Code>
  <Message>Invalid according to Policy: Policy Condition failed: ["eq", "$Content-Type", "application/pdf"]</Message>
  ...
</Error>

同じ policy の中に並べて書いた 2 つの条件なのに、片方は 400 で片方は 403 です。「サイズ超過は 400 で、それ以外は 403」なのか、それとも条件ごとに個別のステータスコードが定義されているのか。ここが気になったので調べてみることにしました。

公式ドキュメントを探してみる

まず「POST policy の condition 種別ごとのステータスコード対応表」のようなものが公式にあるかを探しました。当たったのは主に以下のページです。

結果として、「どの condition がどのエラーコード/ステータスコードになるか」を明記したページは見つけられませんでした

書かれていることは、

の 2 つで、この 2 つを結びつける「POST policy の condition 種別 → エラーコード」のマッピングは、少なくとも自分が確認できた範囲の公式ページには存在しませんでした。

では、この境界は何なのか

公式に書かれていない以上、観測された挙動から推測するしかありません。挙動を見る限り、この 2 つは そもそも別のチェック機構を通っている と考えるのが自然です。

実際、AccessDenied 側のメッセージには失敗した条件が ["eq", "$Content-Type", "application/pdf"] という形でそのまま出てきます。汎用の条件評価器が「policy のこの条件に合致しなかった」と言っているわけで、サイズ側の「アップロードが上限を超えている」という専用メッセージとは明らかに毛色が違います。

この読み方は、AWS SDK や S3 互換実装のリポジトリに上がっている Issue の報告内容とも一致していました。

複数の SDK・実装のリポジトリで一貫して同じ挙動が報告されているので、実運用上は安定して再現する挙動として扱ってよさそうです。ただし AWS がこれを契約として保証している わけではなく、あくまで実装構造に起因する経験則である、というのが正確なところだと思います。

実務上どう扱うか

以上を踏まえると、クライアント側でアップロードエラーをハンドリングするときは、

という作りにしておくのが無難です。ステータスコードの割り当てがドキュメント化されていない以上、将来内部実装が変わって 400/403 の割り当てが変わる可能性はゼロではありません。エラーコードの文字列のほうが、意味が明確な分まだ安定して依存できます。

const res = await fetch(uploadUrl, { method: "POST", body: form });
if (!res.ok) {
  const xml = await res.text();
  const code = xml.match(/<Code>(.*?)<\/Code>/)?.[1] ?? "";
  switch (code) {
    case "EntityTooLarge":
      // ファイルサイズ超過
      break;
    case "AccessDenied":
      // policy の condition 違反 (Content-Type 不一致など)
      break;
    default:
      // 想定外はまとめて汎用エラー扱い
      break;
  }
}

補足: Content-Type 条件だけでは形式チェックとして不十分

これは調べる過程で改めて認識した点ですが、Content-Type の condition が見ているのは クライアントが送ってきたフォームフィールドの値 でしかありません。ファイルの中身までは検証されないので、画像ファイルの Content-Typeapplication/pdf と偽って送れば、この条件だけでは素通りします。

なので今回の実装でも、一時バケットから登録済みバケットへ移す commit の処理で、Range: bytes=0-4 を指定した GetObject でファイル先頭 5 バイトだけを取得し、PDF のマジックナンバー (%PDF-) を確認する検証を残しています。ファイル全体を読み込む必要がないので、この検証自体は十分軽量です。

Pre-signed POST の policy はあくまで「入口で明らかに条件に合わないものを弾いて、無駄なアップロードとストレージ書き込みを防ぐ」ためのもので、実データに対する検証の代わりにはならない、という整理になります。

まとめ


フリーランスHubでこのブログを紹介していただきました

レバレジーズ株式会社が運営するフリーランスHubにて、当ブログを紹介していただきました。

こういった形で取り上げていただけるのはありがたいですし、今回のような「調べてみたけど公式には書かれていなかった」という記録も含めて、これからも学んだことを書き残していければと思います。

フリーランスHubについて

フリーランスHubは、全国のフリーランスエージェントの保有案件をまとめて掲載している、エンジニア・クリエイター向けのフリーランス案件・求人メディアです。