JavaScriptでAmazon S3にファイルをアップロード

# 概要

Web ブラウザーから実行する JavaScript で、AJAX 通信で Amazon S3 の Bucket にできるだけセキュアに画像ファイルをアップロードできるようにしてみます。
取り扱い可能な画像ファイルは png、jpeg、gif を想定しています。
作業は、次の順で進めます。

1. アップロード先となる S3 Bucket の作成
2. S3 に PutObject だけが可能なポリシーを作成
3. STS が使用するロールの作成
4. アップロードのみ可能なIAMユーザーを作成
5. Lambda 関数を作成
6. Lambda が使用するロールにポリシーをアタッチ
7. API Gateway を作成
8. クライアントプログラムの作成

# アップロード先となる S3 Bucket の作成

アップロード先となる Bucket を作成します。
ここでは、 images という名前で Bucket を作成します。

アップロード先のフォルダーを uploads としたいので、uploads フォルダーの作成もしましょう。

images Bucket は画像ファイルは Web ページからアクセスされるようにしたいので、S3 のプロパティから Static website hosting を有効にします。
インデックスドキュメントは、ファイルが存在しなくても index.html を指定しておけば Static website hosting が有効になります。

最終的に異なるドメインから S3 の画像ファイルを参照することになるので、CORS の設定が必要です。
CORS というのは、ドメインをまたいで JavaScript を実行可能にする仕組みです。読み方は“コルス”と発音することが多いようです。
簡単に説明すると、ドメインをまたいで JavaScript の実行を許すと他のページから自分の作った JavaScript を使っていたずらされたり、XSS などの脆弱性になりかねないので、JavaScript を実行可能なドメインを限定することで意図しない使われ方がされないようにするものと思っていただければと思います。

images Bucket の S3 のアクセス制限から CORS の設定を行いましょう。

ここでは、公開する Web アプリケーションのサーバーのホスト名を myserver.com とします。
ファイルの作成と更新を許可するため、HTTPメソッドはPUTとPOSTを許可します。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>https://myserver.com</AllowedOrigin>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedMethod>POST</AllowedMethod>
    <AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>
```

# S3 に PutObject （リソースの作成・置換）だけが可能なポリシーを作成

最終的にファイルをアップロード可能な URL を公開するので、アップロード以外のことができてほしくはありません。
万が一アップロード可能な URL を実行可能な機能がなんらかの方法によって乗っ取られたとしても PutObject しかできなければファイルを盗み取られるようなことはありません。
そのため、S3:PutObject のみ可能なポリシーを作成します。
このポリシーは後述の Lambda 関数から使用します。

ここでは UploadablePolicy という名前にしました。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::images/uploads/*"
        }
    ]
}
```

# STS が使用するロールの作成

STS(Security Token Service) は一時的な認証情報を取得するために使用します。一時的な認証情報は S3 にアップロードするための URL を署名するために使用します。

ここでは、RoleForTemporary という名前にしました。
”ポリシーをアタッチします”ボタンを押して、UploadablePolicy をアタッチしてください。

# アップロードのみ可能な IAM ユーザーを作成

特定の権限しかもたないユーザーのアクセスキーと、シークレットアクセスキーを使用したいので IAM ユーザーを作成します。
既存の IAM ユーザーのアクセスキーとシークレットアクセスキーを使用してもいいのですが、人に割り当てられていたものを使用する場合、その人が異動したり退職した場合など割り当てを変更する必要がでてきて煩わしいので、人に割り当てられていない IAM ユーザーにすることをオススメします。

ここでは、 upload_user というユーザー名の IAM ユーザーを作成しました。
この IAM ユーザーはプログラムによるアクセスしかしないので、アクセスの種類を”プログラムによるアクセス”にチェックを入れて作成しました。
アクセス許可の設定では、“既存のポリシーを直接アタッチ”から、UploadablePolicy をアタッチしてください。
タグの設定は不要です。

# Lambda 関数を作成

Lambda 関数の作成で、“一から作成”で各設定を以下のようにします。

+ 名前･･･uploadFunc
+ ランタイム･･･Node.js 6.10
+ ロール･･･カスタムロールの作成

ロールのドロップダウンで、“カスタムロールの作成”を選択すると、ロールの概要を入力するページが開きます。
ロール名を入力してください。
ここでは、RoleForUploadFunc にしました。

続いて“関数の作成”ボタンを押して関数コードにプログラムを書いていきます。
uploadFunc フォルダー以下のプログラムを編集します。index.js は最初から用意されているファイルです。uploadFunc フォルダーに lib.js というファイルも作りましょう。
後述する API Gateway でリクエストパラメーターをリクエストパラメーターとして受け取らず、JSON で受け取るように設定するのですが、そうしている理由は、JSON で受け取ると、Lambda 関数単体でのテストがしやすいためです。

## uploadFunc/index.js

```javascript
'use strict';

var lib    = require('./lib');
var aws    = require("aws-sdk");
var sts    = new lib.sts("RoleForTemporary");
var bucket = "images";

module.exports.handler = function(event, context, cb) {
    if (!event.file) {
        return lib.respond(cb, 400, 'Parameter "file" is missing.');
    } else if (!event.type) {
        return lib.respond(cb, 400, 'Parameter "type" is missing.');
    }

    //STSでS3に書き込み権限のあるロールを要求
    sts.assumeRole(function (err, data) {
        if (err) return lib.respond(cb, err);
        else {
            var credencials = data.Credentials;
            var s3 = new aws.S3({
                "accessKeyId" : credencials.AccessKeyId,
                "secretAccessKey" : credencials.SecretAccessKey,
                "sessionToken" : credencials.SessionToken
            });
            var s3_params = {Bucket: bucket, Key: "uploads/" + event.file, ContentType: event.type}; // アップロードするURLに対し署名
            var signed_url = s3.getSignedUrl('putObject', s3_params);
            var response = {"signedurl":signed_url};
            
            return lib.respond(cb, null, response);
        }
    });
};
```

## uploadFunc/lib.js

accessKeyID の値は、IAM ユーザー upload_user のアクセスキーをセットしてください。ここでは、ABCDEFGHIJKLMNOPQRST であったとして進めます。
secretAccessKey の値は、IAM ユーザー upload_user のシークレットアクセスキーをセットしてください。ここでは、abcdefghijklmnopqrstuvwxyz0123456789ABCD であったとして進めます。
RoleArn のアカウント ID は AWS のアカウント ID です。ここでは、123456789012 であったとして進めます。

```javascript
'use strict';

var aws = require("aws-sdk");

function sts_(roleName) {
    this.stsobj = new aws.STS({
        "accessKeyId": "ABCDEFGHIJKLMNOPQRST",
        "secretAccessKey": "abcdefghijklmnopqrstuvwxyz0123456789ABCD"
    });
    this.param = {
        "RoleArn": "arn:aws:iam::123456789012:role/" + roleName,
        "RoleSessionName": "session_" + roleName
    };
}
sts_.prototype.assumeRole = function(cb) {
    return this.stsobj.assumeRole(this.param, cb);
};

module.exports = {
    "respond" : function(cb, err, body) {
        if (err) {
            if (typeof err == 'object') {
                if (!err.status) err.status = '400';
                else if (typeof err.status == 'number') err.status = err.status + '';
            } else if ((err + '').match(/^[0-9]{3}$/)) {
                err = {
                    "status" : err,
                    "message" : body
                };
            }
            err = JSON.stringify(err);
        }
        return cb(err, body);
    },
    "sts": sts_
};
```

# Lambda が使用するロールにポリシーをアタッチ

IAM で、RoleForUploadFunc ポリシーを選択して、”ポリシーをアタッチします”ボタンを押して、UploadablePolicy をアタッチしてください。

# API Gateway を作成

API Gateway を作成すると、EC2 で Apache や nginx を用意しなくても Web API を公開することができます。

Lambda のコンソールから、左側の”トリガーの追加”にある”API Gateway”を追加してください。

“トリガーの設定”の API のドロップダウンから“新規 API の作成”を選択してください。
そうすると、セキュリティというドロップダウンが現れるので、“オープン”を選択します。

”▼追加の設定”を押して、“バイナリメディアタイプ”を設定します。
追加するバイナリメディアタイプは以下の3つです。

- image/png
- image/gif
- image/jpeg

設定しおえたら右下にある“追加”ボタンを押してください。

Lambda のコンソールで、API Gateway に uploadFunc-API というハイパーリンクが現れるので、それを押してください。
そうすると API Gateway のコンソールが表示されます。

リソース欄に /uploadFunc が表示されるのでそれを選択します。
そして、その上にある“アクション▼”と書かれたドロップダウンを押してください。そして“メソッドの作成”を押してください。

そうすると ANY の下にドロップダウンが表示されるので、そこで GET を選択してください。セットアップのページが開きます。

## セットアップ

/uploadFunc - GET - セットアップと表示されたページが表示されるので以下のように設定します。

- 統合タイプ･･･ Lambda 関数
- Lambda プロキシ統合の使用･･･チェックしない
- Lambda リージョン･･･ap-northeast-1 （もちろん、別のリージョンをご利用であれば変更してください）
- Lamda 関数･･･uploadFunc
- デフォルトタイムアウトの使用･･･チェックする

以上を入力したら”保存“ボタンを押してください。メソッドの実行設定のページが開きます。

## メソッドの実行設定

メソッドの実行に関する設定を行います。メソッドリクエストと、統合リクエストの設定を変更します。メソッドレスポンスと統合レスポンスの設定は変更しません。

### メソッドリクエスト

API Gateway が受け取るリクエストパラメーターを登録します。

URLクエリ文字列パラメータに、file と type を追加してください。それぞれ必須とキャッシュのチェックボックスはチェックしなくてかまいません。

### 統合リクエスト

公開した Web API が受け取ったリクエストパラメーターを JSON データに変換します。

マッピングテンプレートのリクエスト本文のパススルーから“テンプレートが定義されていない場合(推奨)”を選択し、Content-Type に application/json を追加します。
テンプレートの JSON を以下の通りにしてください。

```json
{
    "file" : "$input.params('file')",
    "type" : "$input.params('type')"
}
```

### CORS 設定

最終的に異なるドメインから STS のトークンを発行することになるので、CORS の設定が必要です。
STS のトークンは S3 に公開する URL を署名してセキュアにするために使用します。

リソース欄の /uploadFunc を選択します。
そして、その上にある“アクション▼”と書かれたドロップダウンを押してください。そして“CORSの有効化”を押してください。

CORS の有効化ページで、以下のように設定してください。

- uploadFunc-API API のゲートウェイレスポンス･･･DEFAULT 4XX：チェックなし、DEFAULT 4XX：チェックなし
- メソッド･･･GETとOPTIONSをチェック（OPTIONSはチェックを外せない）
- Access-Control-Allow-Headers･･･'*'
- Access-Control-Allow-Origin*･･･'*'

続いて、”CORS を有効にして既存の CORS ヘッダーを置換”を押してください。

### メソッドのデプロイ

API Gateway で API を公開する作業を行います。この作業の後、Lambda のコンソールで関数を編集してもやりなおす必要はありません。

リソース欄の /uploadFunc を選択します。
そして、その上にある“アクション▼”と書かれたドロップダウンを押してください。そして“APIのデプロイ”を押してください。

API のデプロイと書かれた小さなウィンドウが表示されるので、デプロイされるステージから default を選択して、”デプロイ”ボタンを押してください。

# クライアントプログラムの作成

以下のような HTML と JavaScirpt (jQuery) でファイルをアップロードします。
プログラム中の URL https://1234567890.execute-api.ap-northeast-1.amazonaws.com/default/uploadFunc は、Lambda のコンソールに表示されたものを使用してください。1234567890 の部分はランダムな英数字で構成されます。

アップロードの仕組みは以下の通りです。

1. https://1234567890.execute-api.ap-northeast-1.amazonaws.com/default/uploadFunc?〜 でアップロード先の S3 の URL を取得する。
2. 取得した URL に対して input type="file" のファイルを POST する。

1で取得するアップロード先の S3 の URL は 15 分有効な URL です。それ以降に使用すると、タイムアウトのエラーが発生します。本ページ中に 15 と入力するところがなかったのでお気づきかもしれませんが、デフォルトが 15 分だからです。
変更したければ、Lambda 関数の uploadFunc/index.js のコメント“// アップロードするURLに対し署名”のある行のパラメーターを変更してください。詳しくは AWS の資料をご覧ください。

```html
〜
<form action="." method="post" class="upload form">
  <div class="columns">
    <div class="column is-12">
      <div class="file has-name">
        <label class="file-label">
          <input class="file-input" type="file" name="resume">
          <div class="file-cta">
            <span class="file-icon"><i class="fa fa-cloud-upload" aria-hidden="true"></i></span>
            <span class="file-label">ファイルを選択...</span>
          </div>
          <span class="file-name"><i class="fa fa-question" aria-hidden="true"></i></span>
        </label>
      </div>
    </div>
  </div>
  <div class="columns">
    <div class="column is-12">
      <input type="button" class="upload button" name="upload-button" value="アップロード">
    </div>
  </div>

  <div class="columns">
    <div class="column is-12 uploaded"></div>
  </div>
</form>
<script>
  $(function () {
    $(".upload.button").on('click', function () {
      var error = null;
      var orig_file = $('.file-input').prop('files')[0];
      var up_file = "bar/foo.png";

      $.ajax({
        url: 'https://1234567890.execute-api.ap-northeast-1.amazonaws.com/default/uploadFunc?file=' + up_file + '&type=' + orig_file.type,
        type: 'GET'
      }).then(function (data) {
        return $.ajax({
          url: data.signedurl,
          type: 'PUT',
          data: orig_file,
          contentType: orig_file.type,
          processData: false
        });
      }).then(function (data) {
        console.log("Upload success");

        $(".uploaded").append($("<img />").attr("src", "http://images.s3-website-ap-northeast-1.amazonaws.com/uploads/" + up_file));
      }).fail(function (data) {
        error = data.message || data.statusText || data.errorText;
      })
        .always(function (jqXHR, textStatus) {
          if (error) {
            console.log("Error: " + error);
          }
        });

      // false を返してデフォルトの動作をキャンセル
      return false;
    });
  });
</script>
〜
```

# 課題

本ページで紹介したファイルのアップロードだと、ファイルの格納場所を指定しているので、存在するファイルを上書きできてしまいます。
本ページで紹介したファイルのアップロードを実装するアプリによっては、他人が作成したファイルを上書きできてしまったりするので場合によっては脆弱性になりかねません。
実装するアプリによっては他人のアップロードしたファイルを上書きできないようにする仕組みを別途組み込んだり、する工夫が必要になると思います。
そういった点に注意してご利用ください。

# 参考にしたページ

- [Qiita - ブラウザからS3に直接アップロード（Security Token Service利用）](https://qiita.com/kochizufan/items/444b7e1dfcf568945410)
- [[AWS]S3の期限付きURLを生成する[node]](https://dev.classmethod.jp/cloud/aws/node-pre-signed-url/)