0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

S3の署名付きURLでアップロードが403になったときの切り分け手順

0
Posted at

概要と結論

ブラウザから S3 へ署名付きURL(presigned URL)で画像をアップロードしようとしたところ、PUT が 403 で失敗した。

結論から言うと、最終的な原因は、バックエンドのコンテナが有効ではないアクセスキーで動いていたことでした。あわせて、署名付きURLが古い署名方式(SigV2)の形式で発行されていたため、SigV4 を明示指定する修正も入れています。

ただし、次の2点は留意点。

  • SigV4 に直したこと自体が、今回の失敗を直したのかどうかは検証できていません(認証情報が無効なままでは、どの署名方式でも失敗するため)
  • 「なぜコンテナが古いキーで動いていたのか」は、.env の更新がコンテナに反映されていなかっためだが、なぜそれが起きたかは厳密には特定できていません

この記事は、原因そのものよりも、403 の原因を層ごとに切り分けていった手順にあると思っています。

環境

  • フロントエンド: Next.js 15
  • バックエンド: FastAPI / Python 3.12(Docker Compose)
  • boto3 1.34.0 / botocore 1.34.162
  • S3 バケット・パブリックアクセスはすべてブロック
  • 閲覧は CloudFront(OAC)経由、アップロードはブラウザから S3 へ直接 PUT
  • 開発環境: macOS + Docker Desktop

構成

[アップロード]  ブラウザ ──(署名付きURLで直接PUT)──▶ S3
[閲覧]        ブラウザ ──▶ CloudFront ──(OACで読み取り)──▶ S3
  1. ブラウザがバックエンドの POST /example/photo を呼ぶ
  2. バックエンドが boto3 で署名付きURLを発行して返す
  3. ブラウザがそのURLに画像を PUT する

バックエンドの発行部分は次のような形です。

import boto3

s3 = boto3.client(
    "s3",
    region_name="example-reagion",
    aws_access_key_id=ACCESS_KEY_ID,
    aws_secret_access_key=SECRET_ACCESS_KEY,
)

upload_url = s3.generate_presigned_url(
    "put_object",
    Params={"Bucket": "example-bucket", "Key": key, "ContentType": content_type},
    ExpiresIn=300,
)

症状

  • POST /example/photo(URL発行)は 200 OK
  • その後、S3 への PUT が 403 Forbidden
  • ブラウザの開発者ツールでは、レスポンス本文が Failed to load response data と表示されてしまい、エラーの理由が読めない

原因

原因1: 署名付きURLが SigV2 形式だった

最初に発行されたURLはこのような形でした。

https://example-bucket.s3.amazonaws.com/bottles/xxxx?AWSAccessKeyId=AKIAXXXXXXXXXXXXXXXX&Signature=...&Expires=...

AWSAccessKeyId / Signature / Expires などのクエリは、旧署名方式(Signature Version 2)。現在は SigV4 が前提なので、クライアント作成時に署名バージョンを明示して対応。

from botocore.client import Config

s3 = boto3.client(
    "s3",
    region_name="example-reagion",
    aws_access_key_id=ACCESS_KEY_ID,
    aws_secret_access_key=SECRET_ACCESS_KEY,
    config=Config(signature_version="s3v4"),
)

これでURLは X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=... という SigV4 形式に。

原因2: コンテナが有効ではないアクセスキーで動いていた

SigV4 にしても 403 は消えず。最終的に、次の節の切り分けにより InvalidAccessKeyId(アクセスキーがAWSに存在しない)にたどり着きました。

切り分けの手順

1. curl で PUT する(ブラウザと CORS を除外)

CORS はブラウザだけのチェックなので、curl は無視できる。

curl -i -X PUT \
  -H "Content-Type: image/jpeg" \
  --data-binary @/path/to/test.jpeg \
  "<upload_url>"

※-i を付けると、開発者ツールでは見えないレスポンス本文が読めます。

2. 送信内容の確認

SignatureDoesNotMatch のエラー XML には、S3 側が再計算した CanonicalRequest が入っています。ここでメソッドやヘッダーを確認できます。

PUT
/bottles/xxxx
X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-SignedHeaders=content-type%3Bhost
content-type:image/jpeg
host:example-bucket.s3.amazonaws.com

content-type;host
UNSIGNED-PAYLOAD
  • メソッドは PUT、content-type も署名時の値と一致
    ⇨リクエスト内容は正しく、署名の計算に使う値(キー)側が怪しいのでは?

3. boto3 で直接 put_object する(認証情報そのものを確認)

署名付きURLの仕組みを外し、同じ認証情報で直接書き込めるかを検証。

docker compose exec backend python -c "
import boto3
from config import settings as s
c = boto3.client('s3', region_name=s.AWS_S3_BUCKET_REGION,
                 aws_access_key_id=s.AWS_S3_UPLOADER_ACCESS_KEY_ID,
                 aws_secret_access_key=s.AWS_S3_UPLOADER_SECRET_ACCESS_KEY)
c.put_object(Bucket=s.AWS_S3_BUCKET, Key='test/hello.txt', Body=b'hello')
print('OK')
"

結果は次の通り。

botocore.exceptions.ClientError: An error occurred (InvalidAccessKeyId) when calling the PutObject operation:
The AWS Access Key Id you provided does not exist in our records.

これによって「アプリが使っているアクセスキーが、AWS上に存在しない」と確定しました。

4. 認証情報を読み込み直す

有効なキーがコンテナに読み込まれるようにして、もう一度 put_object を試すと OK になり、その後アプリからのアップロードも通りました。

まとめ・教訓

  • 403 は「リクエスト内容」「署名の計算」「認証情報」のどこでも起きる可能性がある。外側から順に層を外して切り分けていくと、適当に推測するより確実です。
  • 認証情報を差し替えた時、アプリが実際に読んでいる値を確認する習慣をつけると、同じ罠を避けらる
  • SigV2 の URL が発行されたら、SigV4 の明示指定を入れておくのが無難。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?