Google Cloud Storageの署名付きURL作成時にContent-Type を指定せずPUT時にContent-Type を含めると403エラーになる

前回、Google Cloud Storage(GCS)の署名付きURLが生成できることを確認したのですが、実際にURLを利用してファイルをアップロードした際にハマりました。

PythonでGoogle Cloud Storageの署名付きURLを作成する
https://qiita.com/kai_kou/items/45c34576604ec8f6a3d1

## 再現してみる

前回、署名付きURLを作成する際に、```Content-Type``` を空にしていたのですが、そのままで```curl``` でファイルをアップロードできたものの、Webアプリからアップロードしようとすると```403``` エラーとなりハマっていました。

```python:main.py_署名付きURLを作成するスクリプト
import time

import urllib

from datetime import datetime, timedelta

import os

import base64

from oauth2client.service_account import ServiceAccountCredentials

API_ACCESS_ENDPOINT = 'https://storage.googleapis.com'


def sign_url(bucket, bucket_object, method, expires_after_seconds=60):
    gcs_filename = '/%s/%s' % (bucket, bucket_object)
    
    # content_typeを空で生成している
    content_md5, content_type = None, None

    credentials = ServiceAccountCredentials.from_json_keyfile_name('[サービスアカウントキーのファイルパス]')
    google_access_id = credentials.service_account_email

    expiration = datetime.now() + timedelta(seconds=expires_after_seconds)
    expiration = int(time.mktime(expiration.timetuple()))

    signature_string = '\n'.join([
        method,
        content_md5 or '',
        content_type or '',
        str(expiration),
        gcs_filename])
    _, signature_bytes = credentials.sign_blob(signature_string)
    signature = base64.b64encode(signature_bytes)

    query_params = {'GoogleAccessId': google_access_id,
                    'Expires': str(expiration),
                    'Signature': signature}

    return '{endpoint}{resource}?{querystring}'.format(
        endpoint=API_ACCESS_ENDPOINT,
        resource=gcs_filename,
        querystring=urllib.parse.urlencode(query_params))


if __name__ == '__main__':
    url = sign_url('[バケット名]', 'hoge.json', 'PUT')
    print(url)

```

再現すると、こんな感じです。署名付きURLを生成します。

```sh
> python main.py

https://storage.googleapis.com/xxxxx/hoge.json?GoogleAccessId=xxxx%40xxxxx.iam.gserviceaccount.com&Expires=1542800155&Signature=xxx長いxxx
```

```curl```　で```Content-Type``` を指定しないままPUTでファイルアップロードしてみます。

```sh
> curl -i -X PUT https://storage.googleapis.com/(略) \
  --upload-file "[アップロードするファイルパス]"

HTTP/1.1 100 Continue

HTTP/1.1 200 OK
X-GUploader-UploadID: AEnB2Urlfu1tTz2Hm49qPwusYhRdMEYJkTVxVOV0AX21pz5_Ixt4BrD1xspqiVcvQvHYnVDXWn-FZFKd0vk4DDE6IDiVoWh1Uw
ETag: "8d7f148d56e423e591ad9a54d46d5cb8"
x-goog-generation: 1542800499311073
x-goog-metageneration: 1
x-goog-hash: crc32c=elpi7A==
x-goog-hash: md5=jX8UjVbkI+WRrZpU1G1cuA==
x-goog-stored-content-length: 42542345
x-goog-stored-content-encoding: identity
Vary: Origin
Content-Length: 0
Date: Wed, 21 Nov 2018 11:41:39 GMT
Server: UploadServer
Content-Type: text/html; charset=UTF-8
Alt-Svc: quic=":443"; ma=2592000; v="44,43,39,35"
```

はい。無事にアップロードできました。
次に```curl``` で```Content-Type``` を指定してPUTしてみます。


```sh
> curl -i -X PUT https://storage.googleapis.com/(略) \
  --upload-file "[アップロードするファイルパス]" \
  -H 'Content-Type: application/json'

<?xml version='1.0' encoding='UTF-8'?><Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match the signature you provided. Check your Google secret key and signing method.</Message><StringToSign>PUT

application/json
1542800722
/xxxxx/hoge.json</StringToSign></Error>
```

はい。
```does not match the signature you provided``` って怒られました。


仕方ないので、署名付きURLを生成するスクリプトで```Content-Type``` を指定してみます。

```python:一部抜粋
    # content_typeを空で生成している
    content_md5, content_type = None, 'application/json'
```

```sh
> curl -i -X PUT https://storage.googleapis.com/(略) \
  --upload-file "[アップロードするファイルパス]" \
  -H 'Content-Type: application/json'

HTTP/1.1 100 Continue

HTTP/1.1 200 OK
X-GUploader-UploadID: AEnB2UrSSXsLx_oUYGvy0oToDzBCs_JeSf13NCmc8PdNVpdL-SfwrRkPNxO4f64n7j3uv4UDLpOF8r4zIe3xG8pPQPTd3QvwWg
ETag: "8d7f148d56e423e591ad9a54d46d5cb8"
x-goog-generation: 1542801014953412
x-goog-metageneration: 1
x-goog-hash: crc32c=elpi7A==
x-goog-hash: md5=jX8UjVbkI+WRrZpU1G1cuA==
x-goog-stored-content-length: 42542345
x-goog-stored-content-encoding: identity
Vary: Origin
Content-Length: 0
Date: Wed, 21 Nov 2018 11:50:15 GMT
Server: UploadServer
Content-Type: text/html; charset=UTF-8
Alt-Svc: quic=":443"; ma=2592000; v="44,43,39,35"
```

はい。
署名付きURLで```Content-Type``` を指定すると大丈夫でした。


## まとめ

ドキュメントを見る限り必須の指定ではないものの、空文字を含む文字列が**一致しないと**、受け入れてくれないみたいです。悲しいTT ~~俺の一日返せぇ~~


署名付き URL  |  Cloud Storage ドキュメント  |  Google Cloud
https://cloud.google.com/storage/docs/access-control/signed-urls

>	必要に応じて指定。content-type を指定する場合、クライアント（ブラウザ）は、この HTTP ヘッダーを同じ値に設定する必要があります。


## 参考

PythonでGoogle Cloud Storageの署名付きURLを作成する
https://qiita.com/kai_kou/items/45c34576604ec8f6a3d1

署名付き URL  |  Cloud Storage ドキュメント  |  Google Cloud
https://cloud.google.com/storage/docs/access-control/signed-urls