先週、ある案件のレビューで「このアップロード用の URL、何を縛っているんですか」と聞かれて、即答できませんでした。
私たちが作っている書類読み取りの仕組みでは、スマホで撮った書類画像をブラウザから S3 に直接上げています。API Gateway の受け取れる大きさは 10MB、Lambda の同期呼び出しは 6MB が上限なので、画像をアプリのサーバーに通さず、サーバーは「この場所にこの種類のファイルを置いてよい」という署名付き URL を発行するだけにしています。運用を考えると、重いファイルの通り道からサーバーを外せるのは大きいと考えています。
その発行処理を、別案件向けに JavaScript の SDK(AWS SDK for JavaScript v3)で書き直していたのが今回の発端です。社内の以前の実装は Python(boto3)で、Content-Type を間違えると 403 で弾かれていました。だから「種類は縛れている」と思い込んでいたんですよね。聞かれて改めて考えると、大きさはどうなのか、中身はどうなのか、自信を持って答えられるものが一つもありませんでした。
そこで、本物のバケットに触る前に、手元の Docker で S3 互換ストレージ(MinIO)を立てて、送り方を変えながら確かめることにしました。以下の数字はすべてこのローカル環境のものです。AWS には一度も接続していないので、検証の費用はかかっていません。
署名に入るのは X-Amz-SignedHeaders に並んだものだけ
前提を一つだけ確認しておきます。署名付き URL の署名は、URL のクエリと、X-Amz-SignedHeaders に名前が並んだヘッダから計算されます。逆に言えば、ここに並んでいないヘッダはクライアントが何を付けても検証されません。何を縛っているかは、この1項目を見れば分かります。
まず、Python のときと同じつもりで、ContentType を指定して発行しました。
// presign-default.mjs(発行側。接続先は手元の S3 互換ストレージ)
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'ap-northeast-1', endpoint: process.env.S3_ENDPOINT, forcePathStyle: true });
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: 'uploads', Key: 'scan/0001.jpg', ContentType: 'image/jpeg' }),
{ expiresIn: 300 },
);
console.log(new URL(uploadUrl).searchParams.get('X-Amz-SignedHeaders'));
// 実行結果(@aws-sdk/client-s3 3.1149.0):
// host
host だけでした。ContentType: 'image/jpeg' を渡しているのに、Content-Type は署名に入っていません。同じ指定を boto3 1.42.88 で発行すると content-type;host になります。署名付き URL は「発行した側が決めた条件でしかアップロードできない」ものだと理解していましたが、その「条件」の範囲が SDK によって違う、というのが最初の想定外でした。
送り方を6通り変えて、何が通るかを並べた
次に、1本の URL に対してクライアント側の送り方を変えて、ステータスを並べました。発行方法は3つです。JavaScript の SDK の既定、boto3 の既定、そして後で説明する「Content-Type と申告された SHA-256 を署名に入れた」発行です。用意したのは 200KB のランダムなバイト列で、これを「申告どおりのファイル」とみなしています。
| クライアントが送ったもの | JS SDK の既定(host) | boto3 の既定(content-type;host) | 種類と中身を署名に入れた版 |
|---|---|---|---|
| 申告どおり(200KB・image/jpeg) | 200 | 200 | 200 |
| 50MB・image/jpeg | 200(50MB がそのまま置かれた) | 200 | 400(チェックサム不一致) |
| 同じ中身・image/png | 200(image/png で保存) | 403 | 403 |
| 同じ中身・Content-Type なし | 200(binary/octet-stream で保存) | 403 | 400 |
| 中身が HTML・text/html | 200(text/html で保存) | 403 | 403 |
| 別の中身(同じ 200KB)・image/jpeg | 200 | 200 | 400(チェックサム不一致) |
JavaScript の既定では、6通りすべてが 200 でした。しかも Content-Type はクライアントが送ったものがそのまま保存されます。text/html で置かれたファイルを、後で別の画面から署名付きの GET で開かせたら、ブラウザは HTML として描画します。画像のつもりの置き場所に、スクリプト入りのページを置けてしまうわけです。
boto3 は種類の取り違えを 403 で止めていました。ただ、これも縛っているのは「ラベル」だけです。50MB の行と別の中身の行は、boto3 でも 200 でした。image/jpeg と名乗りさえすれば、中身が何であっても、どれだけ大きくても通ります。私が「縛れている」と思っていたのは、ラベルの一致にすぎなかったんですよね。
もう一つ、表には入れていませんが気になったのが使い回しです。バージョニングを有効にしたバケットで、1本の URL に 10MB ずつ5回送ったところ、5回とも 200 で、5世代・合計 50MB が残りました。署名付き URL は使い捨てではなく、期限内なら何度でも使えます。
お金の問題ではなく、後段が前提を失うという問題
コストの面から言うと、置かれてしまうこと自体はそれほど痛くありません。東京リージョンの S3 標準の保管料は 1GB あたり月 0.025 ドルなので、50MB なら月 0.2 円にもなりません。1回の PUT で送れる上限の 5GB を置かれても、月 20 円弱です。
困るのはその先です。私たちの仕組みでは、置かれた画像を後段の処理が読み込んで縮小し、文字を読み取ります。その処理は「スマホの写真」の大きさと種類を前提にメモリと時間を見積もっているので、50MB のファイルや中身が HTML のファイルが来ると、前提ごと崩れます。保管料が月 0.2 円でも、読み取りが失敗してサポートに問い合わせが来れば、その対応のほうがずっと高くつきます。
種類・中身・大きさを、それぞれ何で縛ったか
縛りたいものごとに、効く手段が違いました。
まず種類です。JavaScript の SDK は既定で Content-Type を署名から外します。signableHeaders に content-type を入れると、boto3 と同じく署名に入りました。ブラウザやスマホの実装によって Content-Type の付き方がぶれると 403 が出るので、外したくなる気持ちは分かります。ここは好みが分かれるところですが、私たちは「縛ってから、クライアント側で付け方を揃える」ほうを選びました。
次に中身です。アップロード前にクライアントでファイルの SHA-256 を計算してもらい、その値を API に申告させて、署名に入れます。ここでもう一つ落とし穴がありました。ChecksumSHA256 を指定して普通に発行すると、x-amz-checksum-sha256 はヘッダではなくクエリに移されます。この状態で別の中身を送ると、ヘッダを付けずに 200 で通りました。チェックサムの値が URL に載っているだけで、本文と照合されていないんです。unhoistableHeaders に入れて初めて署名ヘッダになり、照合が効きました。
// presign-strict.mjs(申告された SHA-256 と Content-Type を署名に入れる)
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'ap-northeast-1', endpoint: process.env.S3_ENDPOINT, forcePathStyle: true });
// クライアントが申告した値(API 側の入力チェックは済んでいる前提)
const declared = { sha256Hex: process.argv[2], mimeType: 'image/jpeg' };
const sha256Base64 = Buffer.from(declared.sha256Hex, 'hex').toString('base64');
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: 'uploads',
Key: 'scan/0002.jpg',
ContentType: declared.mimeType,
ChecksumSHA256: sha256Base64,
}),
{
expiresIn: 300,
signableHeaders: new Set(['content-type']), // 既定では署名から外される
unhoistableHeaders: new Set(['x-amz-checksum-sha256']), // 既定ではクエリに逃がされる
},
);
console.log(new URL(uploadUrl).searchParams.get('X-Amz-SignedHeaders'));
// 実行結果:
// content-type;host;x-amz-checksum-sha256
この URL では、クライアントは Content-Type: image/jpeg と x-amz-checksum-sha256(申告値)の2つのヘッダを付けて送ります。中身が違えば 400、ヘッダの値を自分のファイルのハッシュに書き換えると署名が合わずに 403 でした。手元のストレージでは 400 のコードが XAmzContentChecksumMismatch でしたが、検証用の AWS 環境で同じ発行をしたときは BadDigest が返っていました。コードの名前は実装で違うので、判定はステータスで見るのが無難だと考えています。
正直なところ、これで大きさも実質的に縛れます。申告したハッシュと一致するファイルは1つしかないからです。ただし、申告そのものを API 側で疑う必要があります。50MB のファイルのハッシュを素直に申告されたら、それは正しい申告として通ってしまいます。申告された大きさの上限は API で弾いています。
PUT でも、ContentLength を指定して signableHeaders に content-length を足せば、長さを署名に入れられます。試すと 204,800 バイトちょうどは 200、1 バイト少ないものも 50MB も 403 でした。ただ、これは「ちょうど何バイトか」を縛るもので、「何バイト以下」という範囲は書けません。
範囲で縛りたい場面では、POST のポリシーを使いました。こちらは条件に content-length-range を書けます。
// presign-post.mjs(大きさの範囲をポリシーに書く)
import { S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
const s3 = new S3Client({ region: 'ap-northeast-1', endpoint: process.env.S3_ENDPOINT, forcePathStyle: true });
const MAX_BYTES = 10 * 1024 * 1024;
const { url, fields } = await createPresignedPost(s3, {
Bucket: 'uploads',
Key: 'scan/0003.jpg',
Expires: 300,
Fields: { 'Content-Type': 'image/jpeg' },
Conditions: [
['content-length-range', 1, MAX_BYTES],
['eq', '$Content-Type', 'image/jpeg'],
],
});
console.log(Object.keys(fields).join(' '));
// 実行結果(フォームに入れて送る項目。ファイル本体は最後に足す):
// Content-Type bucket X-Amz-Algorithm X-Amz-Credential X-Amz-Date key Policy X-Amz-Signature
5MB は 204、11MB と 50MB は 400 の EntityTooLarge、Content-Type を image/png に変えると 403 でした。上限の 10MB は、この検証で仮に置いた値です。
それでも全部を POST に寄せていないのは、既存のスマホ側の送信処理が PUT 前提で作られているからです。フォーム形式に変えると、送信部分とリトライ処理を作り直すことになります。運用を考えると一度に切り替えるのは重いので、いまは PUT にチェックサムを入れる形を基本にしています。
期限も確かめています。期限 2 秒で発行して 3 秒待つと 403 でした。7 日と 1 秒を指定すると、発行の時点で SDK が「1週間未満にせよ」という例外を投げます。私たちの仕組みはいま 1 時間で発行していますが、さきほどの使い回しの結果を見ると、送信の直前に URL を取りに来る作りにして、数分まで縮めたほうがよいと考えています。
最後は、届いたものを照合してから使う
署名でどこまで縛っても、最後の砦はサーバー側の照合だと考えています。後段の処理に渡す前に、届いたオブジェクトの大きさ・SHA-256・種類を申告と突き合わせます。
// verify-upload.mjs(届いたものを照合してから後段に渡す)
import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'ap-northeast-1', endpoint: process.env.S3_ENDPOINT, forcePathStyle: true });
export async function verifyUpload({ key, expectedBytes, expectedSha256Base64, expectedType }) {
const head = await s3.send(new HeadObjectCommand({ Bucket: 'uploads', Key: key, ChecksumMode: 'ENABLED' }));
const problems = [];
if (head.ContentLength !== expectedBytes) problems.push(`size ${head.ContentLength} != ${expectedBytes}`);
if (head.ChecksumSHA256 !== expectedSha256Base64) problems.push('sha256 mismatch');
if (head.ContentType !== expectedType) problems.push(`type ${head.ContentType}`);
// 改善の余地: 照合に落ちたオブジェクトを、ここではまだ消していない
return problems;
}
// 実行結果(JS SDK の既定の URL で置かれたもの。申告は 200KB・image/jpeg):
// 50MB が置かれた場合 → [ 'size 52428800 != 204800', 'sha256 mismatch' ]
// HTML が置かれた場合 → [ 'sha256 mismatch', 'type text/html' ]
// 別の 200KB が置かれた場合 → [ 'sha256 mismatch' ]
この照合には改善の余地があります。照合に落ちたオブジェクトを、いまはバケットに残したままにしています。後段に渡らないので実害は出ていませんが、置かれたものは置かれたままです。ライフサイクルルールで一定期間後に消すか、照合で落ちた時点で削除するか、どちらにするかを決めきれていません。それと、照合はファイルが全部届いた後なので、50MB の送信そのものは止められません。モバイル回線の利用者にとっては、弾かれるまでの通信量がそのまま無駄になります。
振り返って
今回いちばん効いた教訓は、「何を縛っているか」は SDK を替えた瞬間に変わる、ということでした。同じ ContentType の指定でも、boto3 では署名に入り、JavaScript の SDK では入りません。チェックサムも、指定しただけでは照合されません。コードの見た目が同じでも、URL の X-Amz-SignedHeaders は別物になります。
なので、発行処理のテストでは、署名に何が入るかを確かめるようにしました。チェックサムのヘッダがクエリに逃がされず署名ヘッダに入っていること、期限が想定どおりであること。これが崩れたらテストが落ちるようにしてあります。レビューで聞かれた「何を縛っているのか」に、テストの名前で答えられる状態にしておきたかったからです。
今後は、照合に落ちたオブジェクトの片付けを決めることと、URL の期限を縮めること、それから既存の画面も POST のポリシーに寄せられないかを見積もるところから進めたいと考えています。あとは、今回の数字はローカルの S3 互換ストレージで測ったものなので、エラーコードのように実装で変わる部分は、検証用のアカウントで一度並べ直しておくつもりです。