AWS SDK for JavaScript v3 (AWS Signature V4)におけるPresigned-URLの署名対象

## Presigned-URLの発行

[S3にPUTできるPresigned-URLを発行する](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/PresignedUrlUploadObject.html)。
発行は[AWS SDK for JavaScript v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/index.html) ([AWS Signature V4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html))にて行うこととする。

```js
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const client = new S3Client({
    region: 'xxxx',
});
const command = new PutObjectCommand({
    ExpectedBucketOwner: '123456789012',
    Bucket: 'example',
    Key: 'foo/bar.png',
    ContentType: 'image/png',
    ContentLength: 1234,
    ContentMD5: 'abc123abc/123abc123abc==',
});
const url = await getSignedUrl(client, command, {
    expiresIn: 60,
});
console.log(url);
```

当然、問題無くPUTできる。

```console
curl -v \
     -X PUT \
     -H 'content-type: image/png' \
     -H 'content-length: 1234' \
     -H 'content-md5: abc123abc/123abc123abc==' \
     --upload-file 'bar.png' \
     'https://example.s3.xxxx.amazonaws.com/foo/bar.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIXXX&X-Amz-Date=20230101T123456Z&X-Amz-Expires=60&X-Amz-Security-Token=XXXXX&X-Amz-Signature=abc123&X-Amz-SignedHeaders=content-length%3Bcontent-md5%3Bhost&x-amz-expected-bucket-owner=123456789012&x-id=PutObject'
```

## Presgined-URLの署名対象

Presgined-URLのQUERY_STRINGに注目してみると、

```text
X-Amz-SignedHeaders=content-length%3Bcontent-md5%3Bhost
```

となっている。
ヘッダーにおいては`content-length`、`content-md5`、`host`に対してしか署名されていない雰囲気が伝わってくる……。

## PUTヘッダー値の操作

ということで、

* その他ヘッダーを付与する
* `content-type` 値を任意のものにする

ことが可能であるため、

```console
curl ... \
     -H 'cache-control: no-cache' \
     -H 'content-type: image/jpeg' \
     ...
```

などとヘッダー情報を書き換えても問題無くPUTでき、PUTされたS3オブジェクトのメタデータは実際にその通りになる。

※注：`x-amz-*`ヘッダーは署名しなければ追加できない。

## 特に影響が大きいケース

このS3バケットをCloudFront経由もしくは直接的に公開している場合は、

* [CloudFrontのキャッシュ挙動](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/Expiration.html#ExpirationDownloadDist)が、CloudFront側キャッシュポリシーとS3オリジン（当S3オブジェクト）側キャッシュ関連ヘッダーの状態に合わせて変わること
* 閲覧ユーザー（クライアント）へのレスポンスにそのまま適用されること

などの理由により、例示のヘッダー操作影響は特に大きくなる。

## 調整方法

### パターン1

ヘッダーと値をセットしつつ、[署名対象ヘッダーを指定する](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/interfaces/_aws_sdk_types.requestpresigningarguments-1.html)。

これにより、ヘッダー情報を書き換えてPUTしようとすると失敗することになる。

```js
...;
const command = new PutObjectCommand({
    ...,
    CacheControl: 'max-age=60',
    ContentType: 'image/png',
    ...,
});
const url = await getSignedUrl(client, command, {
    ...,
    signableHeaders: new Set([
        'cache-control',
        'content-type',
    ]),
});
...;
```

```text
X-Amz-SignedHeaders=cache-control%3Bcontent-length%3Bcontent-md5%3Bcontent-type%3Bhost
```

### パターン2

ヘッダー情報を書き換えたPUTは許容するが、PUTされた後のS3オブジェクトを、[メタデータを上書き（別キー or 同キーでのオブジェクトコピー）](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/interfaces/copyobjectcommandinput.html)してから利用する。

`MetadataDirective.REPLACE`が文字通りメタデータ全体を「REPLACE」するため、他にも独自メタデータがあった場合は、それらも全て列挙する必要がある（そう言われると`MetadataDirective.COPY`を使いたくなるかもしれないが、強制上書きにならないので力不足）。

```js
import { S3Client, CopyObjectCommand } from '@aws-sdk/client-s3';
const client = new S3Client({
    region: 'xxxx',
});
const command = new CopyObjectCommand({
    ExpectedBucketOwner: '123456789012',
    CopySource: 'example/foo/bar.png',
    Bucket: 'example',
    Key: 'foo/baz.png',
    MetadataDirective: 'REPLACE',
    CacheControl: 'max-age=60',
    ContentType: 'image/png',
});
const ret = await client.send(command);
console.log(ret);
```

## おまけ：ファイルPUT

```js
import { createReadStream } from 'node:fs';
import fetch from 'node-fetch';
const res = await fetch(url, {
    method: 'PUT',
    headers: {
        'cache-control': 'max-age=60',
        'content-type': 'image/png',
        'content-length': 1234,
        'content-md5': 'abc123abc/123abc123abc==',
    },
    body: createReadStream('bar.png'),
});
const body = await res.text();
console.log({
    status: res.status,
    statusText: res.statusText,
    body: body,
});
```

パラメーターを色々と変更しながら、Presigned-URL発行からファイルPUTまで一気に実行してみると、挙動が分かり易い。