slackの絵文字登録を楽にするslash-command実装

はじめに

slackの絵文字登録、面倒ですよね。
画像幅やサイズの制限もあり、毎回ローカルでリサイズしてWebフォームからアップロードしていました。

現状、slackのカスタム絵文字登録はAPIが存在しませんが、
ブラウザcookieをそのまま利用するアップロードスクリプトが公開されていました。

https://github.com/slackhq/slack-api-docs/issues/28
https://github.com/smashwilson/slack-emojinator

せっかくなのでSlash Command化して、誰でも叩けるようにしてしまいましょう。

構成

    (Slack) #Slash Command
       ↓
     POST
       ↓
 (API Gateway)
       ↓
RequestResponse
       ↓
   (Lambda1) #コマンド名のチェックと分岐のみ
       ↓
     Event
       ↓
   (Lambda2) #画像のGET, リサイズ, 絵文字アップロード
       ↓
     POST
       ↓
    (Slack) #結果の通知
  • SlackAppsのSlash Commandを使います
  • AWSのAPI Gatewayを介して、コマンド分岐用のLambda1を呼び出します
  • 絵文字アップロード用のLambda2をEventモードで呼び出します

ポイントはAPI-Gatewayから起動したLambda1から別の絵文字アップロード用のLambda2を呼び出しているところです。
Slash Commandには、レスポンスタイム3000ms以内という制限があり、
画像をGETしてリサイズして絵文字アップロードする処理はその制限を超えてしまいます。

Lambda1で最小限のコマンド名のチェックをした後、
呼び出しモードをEvent(非同期)にして絵文字アップロードのLambda2を呼び出します。

実装

Lambda1

# -*- coding: utf-8 -*-

import urlparse
import boto3
import json

def lambda_handler(event, context):
    parameters = parse_parameters(event["body"])
    payload = command(parameters)
    return { "statusCode": 200, "body": json.dumps(payload) }

def parse_parameters(token):
    parsed = urlparse.parse_qs(token)
    args = parsed["text"][0].split(" ")
    return {
        "user_id": parsed["user_id"][0],
        "channel_id": parsed["channel_id"][0],
        "image_url": args[0],
        "emoji_name": args[1],
        "response_url": parsed["response_url"][0],
        "team_id": parsed["team_id"][0],
        "channel_name": parsed["channel_name"][0],
        "token": parsed["token"][0],
        "command": parsed["command"][0],
        "team_domain": parsed["team_domain"][0],
        "user_name": parsed["user_name"][0],
    }

def command(parameters):
    if parameters["command"] == "/emoji-san":
        response = boto3.client("lambda").invoke(
            FunctionName="emoji-san",
            InvocationType="Event",
            Payload=json.dumps(parameters)
        )
        return {
            "text": "Thanks %s! Your emoji is now uploading." % parameters["user_name"]
        }
    else:
        return {
            "text": "Not supported command: %s" % parameters["command"]
        }

ざっくり説明するとSlackからWebhookされたデータから、
commandを取り出してboto3を使って別のLambdaを呼び出しています。
Eventモードで呼び出しているので、結果を待たずにレスポンスを返しています。

引数はtextに空白区切りで入っているためそのまま取り出します。
引数チェックしてヒントなど返してあげるとさらに良いですね。

後はデプロイ(自分はlamveryを使っています)してAPI-Gatewayからトリガーします。
また、このLambdaは別のLambdaを呼び出す権限が必要ですので設定しましょう(lambda:InvokeFunction)。
この辺りは調べると記事が充実しているので割愛します。

Lambda2

以下の.envファイルをスクリプトから読んでいます。
Cookieの取得方法は、smashwilson/slack-emojinatorのREADMEに書かれています。

SLACK_TEAM=xxxxx
SLACK_COOKIE="..."
SLACK_API_TOKEN=xxxxxxxxxxxxx
# -*- coding: utf-8 -*-

import os
import json
import upload
import urlparse
import requests
import commands
import threading
from PIL import Image
from StringIO import StringIO
from os.path import join, dirname
from dotenv import load_dotenv
from bs4 import BeautifulSoup

URL = "https://{team_name}.slack.com/customize/emoji"

def lambda_handler(event, context):
    load_dotenv(join(dirname(__file__), ".env"))
    command_emojisan(event)

def command_emojisan(parameters):
    image = download_image(parameters["image_url"])
    image = resize_image(image)
    image.save("/tmp/temp.jpg", "JPEG")
    session = requests.session()
    session.headers = {"Cookie": os.environ["SLACK_COOKIE"]}
    session.url = URL.format(team_name=os.environ["SLACK_TEAM"])
    upload_emoji(session, parameters["emoji_name"], "/tmp/temp.jpg")
    notify_slack(parameters)

def download_image(url):
    response = requests.get(url)
    return Image.open(StringIO(response.content))

def resize_image(image):
    image.thumbnail((128, 128), Image.ANTIALIAS)
    return image

def upload_emoji(session, emoji_name, filename):
    # Fetch the form first, to generate a crumb.
    r = session.get(session.url)
    r.raise_for_status()
    soup = BeautifulSoup(r.text, "html.parser")
    crumb = soup.find("input", attrs={"name": "crumb"})["value"]

    data = {
        'add': 1,
        'crumb': crumb,
        'name': emoji_name,
        'mode': 'data',
    }
    files = {'img': open(filename, 'rb')}
    return session.post(session.url, data=data, files=files, allow_redirects=False)

def notify_slack(parameters):
    payload = {
        "text": "Successfully upload: [:%s:]" % parameters["emoji_name"]
    }
    requests.post(parameters["response_url"], data=json.dumps(payload))

こちらもざっくり説明すると、
引数で受け取った画像URLから画像を取得し、Pillowを利用してリサイズしています。
リサイズされた画像はupload_emoji関数を通ってSlackにアップロードされます。
アップロードされたらresponse_urlに対して、完了の通知を行います。

※現状、絵文字のアップロードが成功でも失敗(例えば認証失敗や無効な絵文字名など)でも200が返ってくるため、簡単に失敗判定はできません。
その為、最後のslack通知に絵文字名を含んで、正しく表示される事を確認できるようにします。

後はこちらもデプロイしたら完了です。

Slash Commandの設定

ほぼ割愛です。
SlackAppsからSlash Commandを追加します。
WebhookのURLにAPI-Gatewayで作られたURLを設定します。
コマンド名やアイコンなどはご自由に(当記事では/emoji-san [Image URL] [Emoji Name])

まとめとハマりどころ

後はslackで/emoji-san https://example.com/image.jpg emoji1等すると、絵文字が登録されていきます。

  • いざ記事にすると手順が多かったのと、コードも直しつつ書いたのでそのまま動く保証はできません・・
  • Slackのレスポンスタイム制限に気づかずに途中で構造を変える事になり時間がかかった
    (複数Lambda以外に方法はないのでしょうか?)
  • コマンド名での分岐をLambda1で行えるので、新しいコマンドを作りたい時にはこっちの方が便利かも
  • Pillowはpip installした環境に依存してしまいます
    長くなるので省きますが、amazon-linuxのDockerコンテナを立ち上げてその中でインストールしたパッケージ一式をLambdaパッケージに含みました。
    CircleCIを通してデプロイするなど回避方法は色々あります。

続きを読む

node.js + Expressでリクエスト単位でプロキシするにはrequestをpipeするだけで良い

概要

“express proxy” で普通に検索すると、express-http-proxyとか、express-request-proxyとか言うパッケージが見つかると思います。ただ、どちらもあるサイトをまるごと別のサイトにプロキシするという目的のために作られていて、キャッシュとかインジェクションの仕組みがあって便利なものの、「あるリクエストをプロキシしたい」というシンプルな用途には向いていません。また、いずれも、URLのエンコードにバグがあって、それで苦労させられたりしました。

最初、上記のパッケージをフォークしていろいろコードをいじっていたのですが、コードを見ていたら、単にリクエスト単位でプロキシをするだけなら、Expressの機能だけで、ものすごく簡単にできるということが分かりました。要するに、requestの結果をresにパイプするだけで、プロキシできるのです。まぁ、言われてみれば、Expressのresponseは、WritableStreamなので、そりゃそうか・・・という感じですが、これを知ったときは、拍子抜けしました。

当然、生のHTTPがそのまま返されるだけなので、レスポンスの加工には向いていませんが、レスポンスの加工が必要ないのなら、これで十分です(その気になれば、テキストレベルでのヘッダーの微調整くらいなら簡単にできる思いますが・・・・)。

サンプルコード

request側は、基本的には、req.headersをそのまま渡せば良いのですが、hostだけは削除しないといけないようです。セキュリティも考慮すると、 ‘authorization’と’cookie’も削除するのが妥当でしょう。用途によってはx-forwarded-* の変更(あるいは削除)が必要かもしれません。

const request = require('request');

function (req, res, next) {
  const proxyRequestHeaders = Object.assign({}, req.headers);
  for(key of ['host', 'authorization', 'cookie']){
    delete proxyRequestHeaders.headers[key]
  }
  const proxyUrl = 'http://calculated.proxy.url/';
  request({
    url: proxyUrl,
    method: req.method,
    headers: proxyRequestHeaders,
  }).pipe(res);
}

用途

ちなみに、なんでこんなことをやろうとしたかというと、AWS S3のsignedURLをプロキシで返したかったのです。基本的には、302 redirectで問題ないのですが、後方互換性のためproxyする必要がありました。proxyだと、S3側で、if-none-matchヘッダとかrangeヘッダの処理とか細かいことを全部やってくれるので便利です。

続きを読む

忙しい人のための Use AWS WAF to Mitigate OWASP’s Top 10 Web Application Vulnerabilities メモ

こんにちは、ひろかずです。

2017年7月に「Use AWS WAF to Mitigate OWASP’s Top 10 Web Application Vulnerabilities」が公開されました。
日本語翻訳されていないので、なかなか読めていない方もいると思います。
リリースから少し時間が経ってしまいましたが、読了したので一筆書きます。

どんなドキュメントか

OWASP Top10 で上っている各項目についての解説と、各項目についてAWS WAFでどのような対応ができるかを記載したもの。
Conditionを作る上でのアドバイスも記載されている。

参照ドキュメント

Use AWS WAF to Mitigate OWASP’s Top 10 Web Application Vulnerabilities

A1 – Injection

SQLインジェクション攻撃は、比較的簡単に検出できるが、バックエンドの構造によって複雑さが変わるため、アプリケーション側での対応も必要。
cookieやカスタムヘッダをデータベース参照に使う場合は、Conditionで検査対象とするように設定すること。
リクエスト中にクエリを流す事を是としているサービスの場合、対象URLを検知除外(バイパス)するような設定を検討すること。

A2 – Broken Authentication and Session Management

String Matchを活用して、盗まれた(怪しい)トークンを「安全のために」ブラックリストに登録する
デバイスのロケーションが異なるトークンを検出する等、ログから悪性利用をしているトークンを分析して、ブラックリストに登録するような使い方。
Rate-basedルールを用いて、認証URLを総当り攻撃を防ぐ施策も有効。

A3 – Cross-Site Scripting (XSS)

XSS攻撃は、HTTPリクエストで特定のキーHTMLタグ名を必要とするため、一般的なシナリオで比較的簡単に軽減できる。
BODYやQUERY_STRING, HEADER: Cookieを検査するのが推奨される。
URLを検査対象とすることは一般的ではないが、アプリケーションが短縮URL使っていると、パラメータはURLパスセグメントの一部として表示され、クエリ文字列には表示されない(後でサーバー側で書き換えられる)
CMSエディタ等、たくさんHTMLを生成するサービスには効果は薄い。
対象URLを検知除外(バイパス)する場合は、別途対策を検討すること。
加えて、<script>タグを多用するイメージ(svgグラフィックフォーマット)やカスタムデータも誤検知率が高いので調整が必要。

A4 – Broken Access Control

2017年から新たに登場
認証ユーザーの制限が適切に動作せず、必要以上に内部アプリケーションオブジェクト(不正なデータの公開、内部Webアプリケーション状態の操作、パストラバーサル、リモート/ローカルファイルの呼び出し)を操作できるというもの。
機能レベルのアクセス制御の欠陥は、一般的に後で追加されたアプリケーションで発生する。
アクセスレベルは、呼び出し時に一度検証されるが、その後に呼び出される様々なサブルーチンについては、都度検証されない。
呼び出し元のコードが、ユーザーの代わりに他のモジュールやコンポーネントを呼び出す暗黙的な信頼ができてしまう。
アプリケーションが、アクセスレベルやサブスクリプションレベルに応じてコンポーネントへアクセスさせる場合は、呼び出しの都度、検証する必要がある。

AWS WAFとしては、「../」や「://」をQUERY_STRINGやURIに含むかを検証することで、リモート/ローカルファイルの呼び出しを検出できる。
これも、リクエスト中に「../」や「://」を含む事を是とするアプリケーションには効果は薄い。
管理モジュール、コンポーネント、プラグイン、または関数へのアクセスが既知の特権ユーザーのセットに限定されている場合、Byte_MatchとIPSetの組み合わせでアクセスを制限することができる。

認証要求がHTTPリクエストの一部として送信され、JWTトークン(のようなもの)でカプセル化されている場合

  • Lambda@Edgeファンクションを用いて、関連リクエストパラメータがトークン内のアサーションおよび承認と一致することを確認することで、権限不適合リクエストはバックエンドに届く前に拒否することができる。

A5 – Security Misconfiguration

セキュリティの影響を受けるパラメータの設定ミスは、アプリケーションだけではなく、OSやミドルウェア、フレームワーク全てにおいて発生し得る。
セキュリティの影響を受けるパラメータの設定ミス例

  • ApacheのServerTokens Full(デフォルト)構成
  • 本番Webサーバーでデフォルトのディレクトリ一覧を有効にしたまま
  • エラーのスタックトレースをユーザーに戻すアプリケーション
  • PHPの脆弱なバージョンと組み合わせて、HTTPリクエストを介して内部サーバ変数を上書き

AWS WAFとしては、パラメータの設定ミスを悪用するHTTPリクエストパターンが認識可能な場合に限り対応可能。

  • A4 – Broken Access Controlと同様に、特定パスやコンポーネントに対してByte_MatchとIPSetの組み合わせでアクセスを制限を行う。(Wordpress管理画面 等)
  • 脆弱なPHPを利用している場合、QUERY_STRING中の“_SERVER[“を遮断する。

その他に考慮できること

  • Amazon Inspectorでの設定ミスの捜査(rootログイン許可や脆弱なミドルウェアバージョン、脆弱性の対応状況)
  • Amazon InspectorでのCISベンチマークとの適合性チェック
  • AWS ConfigやAmazon EC2 Systems Managerを用いた、システム構成変更の検出

A6 – Sensitive Data Exposure

アプリケーションの欠陥による機密情報の暴露をWAFで緩和するのは難しい。
この欠陥には、一般に不完全に実装された暗号化が含まれる(弱い暗号の利用を許容していること)

AWS WAFとしては、HTTPリクエスト中の機密情報を識別するパターンをString-Match Conditionで検出することで、機密情報へのアクセスを検出することができる。(ホストするアプリケーションに対する深い知識が必要)
アプリケーションが、アップロードを許容する場合、Base64を示す文字列の一部を検出するString-Match Conditionが有効(BodyはBase64でエンコードされているから)
一般的ではない手法

その他に考慮できること

  • AWS内の機能を使って、接続ポイントにて、ELBで強い暗号化suiteを使用するように制限する
  • クラシックロードバランサの場合、事前定義・カスタムセキュリティポリシーで制御できる。
  • アプリケーションロードバランサの場合、事前定義セキュリティポリシーで制御できる。
  • Cloud Frontでも利用するSSLプロトコルを制限できる。

A7 – Insufficient Attack Protection

2017年から新設
このカテゴリは、新しく発見された攻撃経路や異常なリクエストパターン、または発見されたアプリケーションの欠陥にタイムリーに対応する能力に重点を置いている。
幅広い攻撃ベクトルが含まれるので、他のカテゴリと重複する部分もある。

確認ポイント

  • アプリケーションに対して、異常なリクエストパターンや大量通信を検出できるか?
  • その検出を自動化できる仕組みはあるか?
  • 不要な通信に対して、ブロックできる仕組みはあるか?
  • 悪意のあるユーザーの攻撃開始を検出できるか?
  • アプリケーションの脆弱性に対するパッチ適用までの時間はどの程度かかる?
  • パッチ適用後の有効性を確認する仕組みはあるか?

AWS WAFで何ができるか

  • Size-Content Conditionを使うことで、アプリケーションで利用するサイズ以上の通信を検出・遮断できる。
  • URIやクエリ文字列のサイズを、アプリケーションにとって意味のある値に制限すること。
  • RESTful API用のAPIキーなどの特定のヘッダーが必要になるようにすることもできる。
  • 特定IPアドレスからの5分間隔での要求レートに対する閾値設定。(例えば、UserAgentとの組み合わせでのカウントも可能)
  • Web ACLはプログラマブルで反映が早いのが利点(CloudFrontに対しても約1分で反映)
  • ログの出力傾向から分析して、Web ACLを自動調整する機能も構築できる。

AWS WAFのセキュリティオートメーション機能の活用

  • Lambdaを使って、4xxエラーを多く出しているIPアドレスを特定してIPブラックリストに登録する。
  • 既知の攻撃者に対しては、外部ソースのIPブラックリストを活用する。
  • ボットとスクレイバに対しては、robots.txtファイルの ‘disallow’セクションにリストされている特定URLへのアクセスを行ったIPを、IPブラックリストに登録する。(ハニーポットと表現されています)

A8 – Cross-Site Request Forgery

CSRFは、WebアプリケーションのStateを変更する機能を対象としている。
State変更に係るURLやフォーム送信などのHTTPリクエストを考慮する。
「ユーザーがその行動を取っている」ということを必ず証明する機構がなければ、悪意のあるユーザーによるリクエスト偽造を判断する方法はない。

  • セッショントークンや送信元IPは偽造できるので、クライアント側の属性に頼るのは有効ではない。
  • CSRFは、特定のアクションのすべての詳細が予測可能であるという事実(フォームフィールド、クエリ文字列パラメータ)を利用する。
  • 攻撃は、クロスサイトスクリプティングやファイルインクルージョンなどの他の脆弱性を利用する方法で実行される。

CSRF攻撃への対処

  • アクションをトリガーするHTTPリクエストに予測困難なトークンを含める
  • ユーザー認証プロンプトの要求
  • アクション要求時のキャプチャの提示

AWS WAFで何ができるか

  • 固有トークンの存在確認
  • 例えば、UUIDv4を活用して、x-csrf-tokenという名前のカスタムHTTPヘッダーの値を期待する場合は、Byte-Size Conditionが使える。
  • ブロックルールには、POST HTTPリクエスト条件に加える

その他にできること

  • 上記のようなルールでは、古い/無効な/盗まれた トークンの検出については、アプリケーションでの対応が必要
  • 例えばの仕組み
  • サーバが、一意のトークンを隠しフィールドとしてブラウザに送信し、ユーザーのフォーム送信時の期待値にする。
  • 期待したトークンが含まれないPOSTリクエストを安全に破棄できる。
  • 処理後のセッションストアからトークンは削除しておくことで、再利用がされないことを保証。

A9 – Using Components with Known Vulnerabilities

既知の脆弱性を持つコンポーネントの利用
ソースや、商用/オープンソースのコンポーネント、フレームワークを最新に保つことが重要
最も簡単な攻撃ベクトルで、その他の攻撃手法の突破口にもなる。
脆弱なサブコンポーネントに依存するコンポーネントの利用しているケース。
コンポーネントの脆弱性は、CVEにて管理/追跡されないので、緩和が難しい。

  • アプリケーション開発者は、それぞれのベンダー、作成者、またはプロバイダとのコンポーネントのステータスを個別に追跡する責任を負う。
  • 脆弱性は、既存のバージョンを修正するのではなく、新機能を含む新しいバージョンのコンポーネントで解決される。
  • そのため、アプリケーション開発者は、新バージョンの実装、テスト、デプロイの工数を負担する。

基本的な対処に

  • アプリケーションの依存関係と基礎となるコンポーネントの依存関係の識別と追跡
  • コンポーネントのセキュリティを追跡するための監視プロセス
  • コンポーネントのパッチやリリース頻度、許容されるライセンスモデルを考慮したソフトウェア開発プロセスとポリシーの確立
  • これらにより、コンポーネントプロバイダーがコード内の脆弱性に対処する際に、迅速に対応できる。

AWS WAFで何ができるか

  • アプリケーションで使用していないコンポーネントの機能に対するHTTPリクエストをフィルタリングおよびブロックによる、攻撃面の削減
  • 例)HTTPリクエストを直接/間接的にアセンブルするコードへのアクセスのブロックする
  • String-matchコンディションを用いたURI検査(例えば、”/includes/”の制限)
  • アプリケーションでサードパーティのコンポーネントを使用しているが機能のサブセットのみを使用する場合は、同様のAWS WAF条件を使って、使用しないコンポーネントの機能への公開URLパスをブロックする。

その他にできること

  • ペネトレーションテスト。マーケットプレイスで買える。申請が必要だが、一部事前承認を受けているサービスでは申請が不要なこともある。(マーケットプレイス上のマークで識別可能)
  • 展開とテストのプロセスに統合して、潜在的な脆弱性を検出するとともに、展開されたパッチが対象となるアプリケーションの問題を適切に緩和する。

A10 – Underprotected APIs

2017年版の新カテゴリ
特定アプリケーションの欠陥パターンではなく、潜在的な攻撃ターゲットを示す

  • UIを持たないアプリケーションは増えてきている。(UI/API両方使えるサービスも同様)
  • 攻撃ベクトルは、A1-A9までと変わらないことが多い。
  • APIはプログラムからのアクセスのために設計されているので、セキュリティテストでは、追加の考慮事項がある。
  • APIは、(Rest,SOAP問わず)複雑なデータ構造での動作とより広い範囲の要求頻度と入力値を使用するように設計される事が多い。

AWS WAFでできること

  • A1-A9での対応と変わらないが、対APIとして追加でできる事がある。
  • 強化が必要な主要コンポーネントは、プロトコルパーサ自体(XML,YAML,JSON 等)
  • パーサーの欠陥を悪用しようとする特定の入力パターンをString-match Condition、またはByte-Size Conditionを使用して、そのような要求パターンをブロックする。

旧OWASP Top10 A10 – Unvalidated Redirects and Forwards

リダイレクトや転送要求を検証しない場合、悪意のある当事者が正当なドメインを使用してユーザーを不要な宛先に誘導する可能性がある。

  • 例)短縮URLを生成する機能がある場合、URLジェネレータがターゲットドメインを検証しないことで、悪意を持ったユーザーが正式サービス上から悪意のあるサイトへの短縮URLを発行できる。

AWS WAFでできること

  • まず、アプリケーションでリダイレクトとフォワードがどこで発生するのかを理解する(リクエストパターン等)
  • アプリケーションが使用する、サードパーティコンポーネントも同様にチェックする。
  • エンドユーザからのHTTPリクエストに応じてリダイレクトと転送が生成された場合は、AWS WAFでリクエストをQUERY_STRINGやURIに対するString-Match Conditionでフィルタリングする(リダイレクト/転送の目的で信頼されているドメインのホワイトリストを維持する)

Cloud Formationテンプレート

Web ACLと、このドキュメントで推奨されている条件タイプとルールを含むAWS CloudFormationテンプレートが用意されています。
今回は、解説を割愛します。

所感

AWS WAFの設定は基本的にDIYですが、実装のポイントをOWASP Top10になぞらえて、その対応を解説する良いヒント集だと思いました。
AWS WAFを設定する人は、一読するのがいいですね。
外部にAWS WAFの設定を依頼する人は、アプリケーションの特性をよく理解している人を立てて、設定する人とよくコミュニケーションを取ることが重要だとわかります。
セキュリティ製品の真価を発揮するには、保護する対象のサービスへの理解とサービスへの適合化が必要なのは、どんなソリューションにも共通しますね。

今日はここまでです。
お疲れ様でした。

続きを読む

WebSocketをLoadBalancer経由で使う時にsocket-io-sticky-sessionを使うと、keep-aliveを有効にしているとSession ID unknownになることがある

はじめに

npmの socket-io-sticky-session (以降、sio-sticky)が、stickyを有効にしたAWSのALBなどのLoadBalancerの後ろにいて、
LoadBalancerからKeep-Alive接続される場合、X-Forwarded-Forなどのヘッダによって正しいWorkerに割り振られないことがある。

という話です。

説明

症状

sio-stickyX-Forwarded-ForなどのHTTPヘッダをみてNode.jsの Webサーバ/WebSocketサーバ などにStickyに接続を振り分けてくれます。これは、SocketIOのpollingやwebsocketで使われるsidというSessionIDを持つリクエストを同じプロセスのNode.jsに振り分ける時に大変重宝します(sidは異なるプロセスのNode.jsで共有されないので)。

しかし、以下のような構成の場合、
ブラウザがALBのCookieをちゃんと送っていても、Stickyが上手く行かずにエラー(BadRequest: Session ID unknown)になることがあります。

20170829_WebSocket400エラー問題の説明.png

原因

原因は、 ALB と sio-sticky の間の keep-alive 接続だと思われます。

sio-sticky は 新規のTCP接続があった時に、配下のWorker(Node.js)にその接続を渡すので、keep-aliveされたあとの2回目以降のHTTPリクエストのヘッダなどを見たりしません。
また、ALBはどのターゲットにリクエストを送るかは制御していますが、どのkeep-aliveされた接続にどのHTTPリクエストを流すかは気にしてはいないので、基本的に運任せになります。

下の図で説明しますと、

20170829_WebSocket400エラー問題の説明.png

  • ① Browser1 と Browser2 は、異なるWorker(Node.js)に振り分けられていたとする
  • ② Browser2からのリクエストがしばらくなくても、ALBとWorkerの間のTCP接続は維持される(keep-alive)
  • ③ Browser1からALBにリクエストがある場合、この別のWorkerとkeep-aliveされた接続にリクエストが流されることがある。異なるworkerにリクエストが行くと、Session ID unknownと怒られてしまう。

というイメージです。

検証

sio-stickyのkeep-alive時の動作についての検証は下記を見てください。
https://github.com/mokemokechicken/socket-io-sticky-session/blob/feature/for_test_keep-alive/NOTE-keep-alive.md

ちなみに、ALBからsio-stickyへの接続は通常の設定ではkeep-aliveされますし、今回の現象も確認することができます。

さいごに

sio-stickyのような仕組みだとTCP接続時に振り分けるので辛いのでしょうね。
keep-aliveを無効にするのも非効率ですし、sio-stickyなどを使わずに1つのNode.jsを直接ALBにぶら下げるのがBetterなのかなぁ、と思います。

続きを読む

AWS EC2にLAMP環境を構築するまで

AWS EC2にLAMP環境を構築したときのメモです。
DBも、RDSを使用せずにEC2に入れてます。

作りたい環境

  • Amazon Linux
  • Apache2.4
  • MySQL 5.6
  • PHP7

前提

  • EC2インスタンスが起動している
  • ターミナルからSSHでログインできる

手順

必要なものをインストール

ログイン

~ $ ssh -i .ssh/your_key.pem ec2-user@{インスタンスのIP}

yumアップデート

[ec2-user@ip-***** ~]$ sudo yum update -y

Apache、MySQL、PHP、MySQLドライバのインストール

[ec2-user@ip-***** ~]$ sudo yum install -y httpd24 php70 mysql56-server php70-mysqlnd

Apache、PHP、MySQLのバージョン確認

# Apache
[ec2-user@ip-***** ~]$ httpd -v
Server version: Apache/2.4.25 (Amazon)
Server built:   Jan 19 2017 16:55:49

# PHP
[ec2-user@ip-***** ~]$ php -v
PHP 7.0.16 (cli) (built: Mar  6 2017 19:45:42) ( NTS )
Copyright (c) 1997-2017 The PHP Group
Zend Engine v3.0.0, Copyright (c) 1998-2017 Zend Technologies

# MySQL
[ec2-user@ip-***** ~]$ sudo service mysqld start
...
...
...
Starting mysqld:                                           [  OK  ]

[ec2-user@ip-***** ~]$ mysql --version
mysql  Ver 14.14 Distrib 5.6.36, for Linux (x86_64) using  EditLine wrapper

Apacheの設定

起動・スタートページの表示

# サービス開始 (起動はするが ServerNameを設定してください と出る(一旦スルー))
[ec2-user@ip-***** ~]$ sudo service httpd start
Starting httpd: AH00557: httpd: apr_sockaddr_info_get() failed for ip-******
AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using 127.0.0.1. Set the 'ServerName' directive globally to suppress this message
                                                           [  OK  ]
#ブラウザで EC2のIPアドレスにアクセスして、Apaceのスタートページが表示されることを確認する

# ドキュメントルートの確認
[ec2-user@ip-***** ~]$ sudo cat /etc/httpd/conf/httpd.conf | less

# 「/DocumentRoot」 で検索
DocumentRoot "/var/www/html"

# 起動設定
[ec2-user@ip-***** ~]$ sudo chkconfig httpd on

# 確認 (2, 3, 4, 5がONになっていればOK)
[ec2-user@ip-***** ~]$ chkconfig
httpd           0:off   1:off   2:on    3:on    4:on    5:on    6:off

グループ設定

# ec2-user を apache グループに追加
[ec2-user@ip-***** ~]$ sudo usermod -a -G apache ec2-user

# 一旦ログアウトして再度ログイン
[ec2-user@ip-***** ~]$ groups
ec2-user wheel apache

# /var/www以下の権限確認
[ec2-user@ip-***** ~]$ ls -l /var/www/
total 20
drwxr-xr-x 2 root root 4096 Jan 19 16:56 cgi-bin
drwxr-xr-x 3 root root 4096 Jun 26 06:18 error
drwxr-xr-x 2 root root 4096 Jan 19 16:56 html
drwxr-xr-x 3 root root 4096 Jun 26 06:18 icons
drwxr-xr-x 2 root root 4096 Jun 26 06:18 noindex

# apacheグループに /var/www 所有・書き込み権限付与
[ec2-user@ip-***** ~]$ sudo chown -R ec2-user:apache /var/www
[ec2-user@ip-***** ~]$ sudo chmod 2775 /var/www/

# /var/www 以下のディレクトリの権限変更
[ec2-user@ip-***** ~]$ find /var/www -type d -exec sudo chmod 2775 {} ;

# /var/www 以下のファイルの権限変更
[ec2-user@ip-***** ~]$ find /var/www -type f -exec sudo chmod 0664 {} ;

httpd.confの設定

/etc/httpd/conf/httpd.conf.conf
# サーバ管理者メールアドレス 変更 87行目あたり
ServerAdmin your_email@example.com

# サーバ名 (ドメイン設定してから)

# クロスサイトトレーシング対策 追記
TraceEnable Off

# ディレクトリ一覧を非表示 変更 145行目あたり
Options -Indexes FollowSymLinks

# cgi-bin使用しない 248〜260行目あたり
#    ScriptAlias /cgi-bin/ "/var/www/cgi-bin/"

#<Directory "/var/www/cgi-bin">
#    AllowOverride None
#    Options None
#    Require all granted
#</Directory>

security.confの設定(新規作成)

/etc/httpd/conf.d/security.conf
ServerTokens Prod
Header unset X-Powered-By
# httpoxy 対策
RequestHeader unset Proxy
# クリックジャッキング対策
Header append X-Frame-Options SAMEORIGIN
# XSS対策
Header set X-XSS-Protection "1; mode=block"
Header set X-Content-Type-Options nosniff
# XST対策
TraceEnable Off

<Directory /var/www/html>
    # .htaccess の有効化
    AllowOverride All
    # ファイル一覧出力の禁止
    Options -Indexes
</Directory>

Welcomeページ非表示

/etc/httpd/conf.d/welcome.conf 中身をコメントアウト

Apache再起動

[ec2-user@ip-***** ~]$ sudo service httpd restart

phpinfoの設置

[ec2-user@ip-***** ~]$ vi /var/www/html/phpinfo.php
<?php
    echo phpinfo();
?>
# ブラウザで IPアドレス/phpinfo.php にアクセス
# 確認後、phpinfo.phpを削除
[ec2-user@ip-***** ~]$ rm /var/www/html/phpinfo.php

PHP設定

[ec2-user@ip-***** ~]$ sudo cp /etc/php.ini /etc/php.ini.org
/etc/php.ini
# タイムゾーン設定
- ;date.timezone =
+ date.timezone = Asia/Tokyo

# 日本語設定
- ;mbstring.internal_encoding =
+ mbstring.internal_encoding = UTF-8

- ;mbstring.language = Japanese
+ mbstring.language = Japanese

- ;mbstring.http_input =
+ mbstring.http_input = auto

- ;bstring.detect_order = auto
+ bstring.detect_order = auto

MySQLの設定

起動設定

[ec2-user@ip-***** ~]$ sudo chkconfig mysqld on

初期設定

[ec2-user@ip-***** ~]$ sudo mysql_secure_installation
...
...
Enter current password for root (enter for none):
# Enterキー

Set root password? [Y/n]
# rootのパスワードを変更するか。
# Y

New password:
# 任意のパスワード入力

Re-enter new password:
# もう一度入力

Password updated successfully!

Remove anonymous users? [Y/n]
# 匿名ユーザーを削除するかどうか。
# Y

Disallow root login remotely? [Y/n]
# rootユーザーのリモートホストからのログインを無効化するかどうか。
# Y

Remove test database and access to it? [Y/n]
# testデータベースを削除するか。
# Y

Reload privilege tables now? [Y/n]
# これらの変更を即座に反映するか。
# Y

...
Thanks for using MySQL!

Cleaning up...

mysqlへログイン

[ec2-user@ip-***** ~]$ mysql -u root -p
Enter password:
# 初期設定で設定したパスワード

Welcome to the MySQL monitor.  Commands end with ; or g.
Your MySQL connection id is 14
Server version: 5.6.36 MySQL Community Server (GPL)

Copyright (c) 2000, 2017, Oracle and/or its affiliates. All rights reserved.
...
mysql>

アプリケーション用データベース追加

# 現在の状態を確認
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
+--------------------+

# データベース「test_app_db」を追加
mysql> create database test_app_db character set utf8;

# 確認
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| test_app_db  |
+--------------------+

アプリケーションユーザ追加

# 現在のユーザ設定確認
mysql> select Host, User, Password  from mysql.user;
+-----------+------+-------------------------------------------+
| Host      | User | Password                                  |
+-----------+------+-------------------------------------------+
| localhost | root | *1234567890ABCDEFGHIJ0987654321KLMNOPQRST|
| 127.0.0.1 | root | *1234567890ABCDEFGHIJ0987654321KLMNOPQRST|
| ::1       | root | *1234567890ABCDEFGHIJ0987654321KLMNOPQRST|
+-----------+------+-------------------------------------------+

# ユーザ「app_user」を追加 (ユーザ名は16文字以内)
mysql> create user 'app_user'@'localhost' identified by  '任意のパスワード';

# データベース「test_app_db」にのみアクセス可能
# 権限は、ALL
mysql> grant ALL on test_app_db.* to 'app_user'@'localhost';

# 設定の反映
mysql> flush privileges;

# 権限の確認
mysql> show grants for 'app_user'@'localhost';
+--------------------------------------------------------------------------------------------------------------------+
| Grants for app_user@localhost                                                                                   |
+--------------------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'app_user'@'localhost' IDENTIFIED BY PASSWORD '*ABCDEFGHIJ0987654321KLMNOPQRST1234567890' |
| GRANT ALL PRIVILEGES ON `test_app_db`.* TO 'app_user'@'localhost'                                         |
+--------------------------------------------------------------------------------------------------------------------+

#  ログアウト
mysql> quit
Bye

# アプリケーションユーザでログイン
[ec2-user@ip-***** ~]$ mysql -u app_user -p

# データベースの確認
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| test_app_db  |
+--------------------+

mysql> quit

phpMyAdminのインストール

# 「EPEL」リポジトリ有効化
[ec2-user@ip-***** ~]$ sudo yum-config-manager --enable epel

# phpMyAdminインストール
[ec2-user@ip-***** ~]$ sudo yum install -y phpMyAdmin

# エラーになった
Error: php70-common conflicts with php-common-5.3.29-1.8.amzn1.x86_64
Error: php56-common conflicts with php-common-5.3.29-1.8.amzn1.x86_64
Error: php56-process conflicts with php-process-5.3.29-1.8.amzn1.x86_64
 You could try using --skip-broken to work around the problem
 You could try running: rpm -Va --nofiles --nodigest

# 手動でインストール
[ec2-user@ip-***** ~]$ cd /var/www/html
[ec2-user@ip-*****  html]$ sudo wget https://files.phpmyadmin.net/phpMyAdmin/4.6.6/phpMyAdmin-4.6.6-all-languages.tar.gz
[ec2-user@ip-*****  html]$ sudo tar xzvf phpMyAdmin-4.6.6-all-languages.tar.gz
[ec2-user@ip-*****  html]$ sudo mv phpMyAdmin-4.6.6-all-languages phpMyAdmin
[ec2-user@ip-*****  html]$ sudo rm phpMyAdmin-4.6.6-all-languages.tar.gz
[ec2-user@ip-*****  html]$ cd phpMyAdmin
[ec2-user@ip-*****  phpMyAdmin]$ sudo cp config.sample.inc.php config.inc.php

# IPアドレス/phpMyAdmin でアクセス
# PHPの「mbstring」拡張が無いというエラーが出るのでインストール
[ec2-user@ip-***** ~]$ sudo yum install -y php70-mbstring

# Apace再起動
[ec2-user@ip-***** ~]$ sudo service httpd restart

# .htaccessでphpMyAdminへのアクセス制限
[ec2-user@ip-*****  phpMyAdmin]$ sudo vi .htaccess

order deny,allow
deny from all
allow from **.***.***.***

# ブラウザで [IPアドレス]/phpMyAdmin にアクセスしてログインできるか確認

phpMyAdminの環境保護領域設定

ブラウザでアクセスした際、画面下に「phpMyAdmin 環境保管領域が完全に設定されていないため、いくつかの拡張機能が無効になっています。」というメッセージが表示されていた場合、初期設定が必要。


# rootアカウントでログインする
# phpMyAdminのディレクトリ内にある create_tables.sql を流す
# /phpMyAdmin/sql/create_tables.sql

# アプリケーションユーザに phpMyAdminテーブルへのアクセス権を付与する
[ec2-user@ip-***** ~]$ mysql -u root -p

# 現在の権限確認
mysql> show grants for 'app_user'@'localhost';
+--------------------------------------------------------------------------------------------------------------------+
| Grants for app_user@localhost                                                                                   |
+--------------------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'app_user'@'localhost' IDENTIFIED BY PASSWORD '*ABCDEFGHIJ0987654321KLMNOPQRST1234567890' |
| GRANT ALL PRIVILEGES ON `test_app_db`.* TO 'app_user'@'localhost'                                         |
+--------------------------------------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

# select, insert, update ,deleteの権限を付与
mysql> grant select, insert, update, delete on phpmyadmin.* to 'app_user'@'localhost';

# 設定を反映
mysql> flush privileges;

# 確認
mysql> show grants for 'app_user'@'localhost';
+--------------------------------------------------------------------------------------------------------------------+
| Grants for app_user@localhost                                                                                   |
+--------------------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'app_user'@'localhost' IDENTIFIED BY PASSWORD '*ABCDEFGHIJ0987654321KLMNOPQRST1234567890' |
| GRANT SELECT, INSERT, UPDATE, DELETE ON `phpmyadmin`.* TO 'app_user'@'localhost'                                |
| GRANT ALL PRIVILEGES ON `test_app_db`.* TO 'app_user'@'localhost'                                         |
+--------------------------------------------------------------------------------------------------------------------+
3 rows in set (0.00 sec)

# config.inc.php の編集
[ec2-user@ip-***** ~]$ sudo vi /var/www/html/phpMyAdmin/config.inc.php
# 以下の記述のコメントアウトを解除する
/* Storage database and tables */
// $cfg['Servers'][$i]['pmadb'] = 'phpmyadmin';
// $cfg['Servers'][$i]['bookmarktable'] = 'pma__bookmark';
// $cfg['Servers'][$i]['relation'] = 'pma__relation';
// $cfg['Servers'][$i]['table_info'] = 'pma__table_info';
// $cfg['Servers'][$i]['table_coords'] = 'pma__table_coords';
// $cfg['Servers'][$i]['pdf_pages'] = 'pma__pdf_pages';
// $cfg['Servers'][$i]['column_info'] = 'pma__column_info';
// $cfg['Servers'][$i]['history'] = 'pma__history';
// $cfg['Servers'][$i]['table_uiprefs'] = 'pma__table_uiprefs';
// $cfg['Servers'][$i]['tracking'] = 'pma__tracking';
// $cfg['Servers'][$i]['userconfig'] = 'pma__userconfig';
// $cfg['Servers'][$i]['recent'] = 'pma__recent';
// $cfg['Servers'][$i]['favorite'] = 'pma__favorite';
// $cfg['Servers'][$i]['users'] = 'pma__users';
// $cfg['Servers'][$i]['usergroups'] = 'pma__usergroups';
// $cfg['Servers'][$i]['navigationhiding'] = 'pma__navigationhiding';
// $cfg['Servers'][$i]['savedsearches'] = 'pma__savedsearches';
// $cfg['Servers'][$i]['central_columns'] = 'pma__central_columns';
// $cfg['Servers'][$i]['designer_settings'] = 'pma__designer_settings';
// $cfg['Servers'][$i]['export_templates'] = 'pma__export_templates';

設定ファイル用パスフレーズの設定

ログイン時に「設定ファイルに、暗号化 (blowfish_secret) 用の非公開パスフレーズの設定を必要とするようになりました。」というメッセージが表示されている場合、設定ファイルにパスフレーズを追記する必要がある。


[ec2-user@ip-***** ~]$ sudo vi /var/www/html/phpMyAdmin/config.inc.php

# 下記の部分に任意の文字列(32文字以上)を入力
/**
 * This is needed for cookie based authentication to encrypt password in
 * cookie. Needs to be 32 chars long.
 */
$cfg['blowfish_secret'] = ''; /* YOU MUST FILL IN THIS FOR COOKIE AUTH! */

パスワード自動生成

その他

時刻設定

[ec2-user@ip-***** ~]$ sudo vi /etc/sysconfig/clock
ZONE="Asia/Tokyo"
UTC=false

[ec2-user@ip-***** ~]$ sudo cp /usr/share/zoneinfo/Japan /etc/localtime
[ec2-user@ip-***** ~]$ sudo /etc/init.d/crond restart
[ec2-user@ip-***** ~]$ date
Tue Jun 27 10:20:25 JST 2017

git

[ec2-user@ip-***** ~]$ sudo yum install -y git
[ec2-user@ip-***** ~]$ git --version
git version 2.7.5

composer

[ec2-user@ip-***** ~]$ curl -sS https://getcomposer.org/installer | php
All settings correct for using Composer
Downloading...

Composer (version 1.4.2) successfully installed to: /home/ec2-user/composer.phar
Use it: php composer.phar

[ec2-user@ip-***** ~]$ sudo mv composer.phar /usr/local/bin/composer

[ec2-user@ip-***** ~]$ composer
   ______
  / ____/___  ____ ___  ____  ____  ________  _____
 / /   / __ / __ `__ / __ / __ / ___/ _ / ___/
/ /___/ /_/ / / / / / / /_/ / /_/ (__  )  __/ /
____/____/_/ /_/ /_/ .___/____/____/___/_/
                    /_/
Composer version 1.4.2 2017-05-17 08:17:52

参考URL

続きを読む

AWS上にProxyサーバとしてSquidを導入する

Proxy (Squid) サーバの設定

$ sudo su -
# yum -y install squid
# vim /etc/squid/squid.conf
# squid -z
# service squid start
# chkconfig squid on
# vi /etc/sysconfig/iptables
# vi iptables-restore < /etc/sysconfig/iptables
# service iptables restart
# iptables -L
squid.conf
# Deny requests to certain unsafe ports
http_access deny !Safe_ports

# Deny CONNECT to other than secure SSL ports
http_access deny CONNECT !SSL_ports

# We strongly recommend the following be uncommented to protect innocent
# web applications running on the proxy server who think the only
# one who can access services on "localhost" is a local user
#http_access deny to_localhost

#
# INSERT YOUR OWN RULE(S) HERE TO ALLOW ACCESS FROM YOUR CLIENTS
#

# Example rule allowing access from your local networks.
# Adapt localnet in the ACL section to list your (internal) IP networks
# from where browsing should be allowed
http_access allow localnet
http_access allow localhost

# And finally deny all other access to this proxy
#http_access deny all

# Squid normally listens to port 3128
http_port 3128

# Uncomment and adjust the following to add a disk cache directory.
#cache_dir ufs /var/spool/squid 100 16 256

# Leave coredumps in the first cache dir
coredump_dir /var/spool/squid

# Add any of your own refresh_pattern entries above these.
refresh_pattern ^ftp:           1440    20%     10080
refresh_pattern ^gopher:        1440    0%      1440
refresh_pattern -i (/cgi-bin/|?) 0     0%      0
refresh_pattern .               0       20%     4320

visible_hostname hayashier.info

acl myacl src all
http_access allow myacl
http_access deny all

forwarded_for off

request_header_access X-Forwarded-For deny all
request_header_access Via deny all
request_header_access Cache-Control deny all
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:FWINPUT - [0:0]
-A INPUT -j FWINPUT
-A FORWARD -j FWINPUT
-A FWINPUT -i lo -j ACCEPT
-A FWINPUT -p icmp --icmp-type any -j ACCEPT
-A FWINPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
-A FWINPUT -p tcp -m tcp --dport 22 -s 0.0.0.0/0 -j ACCEPT
-A FWINPUT -p tcp -m tcp --dport 3128 -s 0.0.0.0 -j ACCEPT
COMMIT

別のインスタンスからプロキシ経由でのアクセス可能なことを確認。

$ curl hayashier.com -x 52.41.123.30:3128 -vvv > /dev/null
* Rebuilt URL to: hayashier.com/
*   Trying 52.41.123.30...
* TCP_NODELAY set
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0* Connected to 52.41.123.30 (52.41.123.30) port 3128 (#0)
> GET http://hayashier.com/ HTTP/1.1
> Host: hayashier.com
> User-Agent: curl/7.51.0
> Accept: */*
> Proxy-Connection: Keep-Alive
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Date: Tue, 04 Jul 2017 14:32:03 GMT
< Server: Apache
< X-Powered-By: PHP/7.0.12
< X-Pingback: http://hayashier.com/xmlrpc.php
< Link: <http://hayashier.com/wp-json/>; rel="https://api.w.org/", <http://wp.me/P8lnQs-2>; rel=shortlink
< X-Frame-Options: SAMEORIGIN
< Vary: Cookie,Accept-Encoding
< X-Mod-Pagespeed: 1.9.32.14-0
< Cache-Control: max-age=0, no-cache
< Content-Length: 17392
< Content-Type: text/html; charset=UTF-8
< X-Cache: MISS from hayashier.info
< X-Cache-Lookup: MISS from hayashier.info:3128
< Via: 1.0 hayashier.info (squid/3.1.23)
* HTTP/1.0 connection set to keep alive!
< Connection: keep-alive
<
{ [11790 bytes data]
* Curl_http_done: called premature == 0
100 17392  100 17392    0     0  38520      0 --:--:-- --:--:-- --:--:-- 38563
* Connection #0 to host 52.41.123.30 left intact
$ sudo tail -f /var/log/squid/access.log

アクセスのログが記録される。

access.log
1499178222.199    403 35.162.193.203 TCP_MISS/200 17067 GET http://hayashier.com/ - DIRECT/54.89.215.227 text/html
1499178556.929    379 35.162.193.203 TCP_MISS/200 17009 GET http://hayashier.com/ - DIRECT/54.89.215.227 text/html
1499178768.861    448 35.162.193.203 TCP_MISS/200 17974 GET http://hayashier.com/ - DIRECT/54.89.215.227 text/html

プロキシサーバの参照先設定。参考にできそう。
http://tech.farend.jp/blog/2015/01/13/proxy_for_vpc/

続きを読む

CloudFront でのクッキーの扱いについて

ドキュメント

デフォルト

デフォルトでは、CloudFront はクッキーを考慮しない。

リクエストの Cookie: ヘッダは、オリジンに転送されない。
レスポンスの Set-Cookie: ヘッダはビューアに転送されない。

設定

ビヘイビアで、以下のいずれかを選べる。

  • None (デフォルト)
  • Whitelist
  • All

20170705161229.png

この設定は、以下の3つの挙動に影響する。

  • オリジンに Cookie: を転送するかどうか
  • キャッシュのキーに Cookie: を使うかどうか
  • オリジンが返した Set-Cookie: をビューアに転送するかどうか

ドキュメントを読んでもいまいち正確にわからなかったので、実際に設定して挙動を確認した。

Cookie: をオリジンに転送 キャッシュのキーに Cookie: Set-Cookie: をビューアーに
None しない 使わない 返さない
Whitelist 一部する 一部使う 全て返す
All 全てする 全て使う 全て返す

基本的に想像通りなのだが、Whitelist の Set-Cookie の扱いだけ例外的で、ホワイトリストに関わらず全てのクッキーをビューアに返す。

なので、Whitelist で制限しているつもりでも、例えばセッションIDがキャッシュで配信されてしまうことに注意が必要。場合によっては、とても危険な状態になり得る。

ドキュメントは以下のように書かれている。

ホワイトリストにない Cookie がオリジンからの応答に含まれている場合、CloudFront はその Cookie もビューアに返します。

一方で、以下の説明もあり、矛盾しているように思えるのだけれども、

オリジンがホワイトリストに登録された Cookie と登録されていない Cookie の両方を返した場合、CloudFront はホワイトリストに登録された Cookie のみをキャッシュします

これは、Cookie: をキャッシュのキーに使うかどうかの話が混ざっているのではないかと思う。

自分が試した限りでは、CloudFront は Set-Cookie: ヘッダをキャッシュする。X-Cache: Hit from cloudfront のレスポンスに、ホワイトリストに載っているかどうかに関わらず、Set-Cookie: ヘッダが設定される。

こちらのドキュメントにも、

Set-Cookie – Cookie を転送するように CloudFront を構成している場合、Set-Cookie ヘッダーフィールドがクライアントに転送されます。

とあり、ホワイトリストで制限されるとは書かれていない。

実験

以下 x_* をホワイトリストに設定したビヘイビアのレスポンス。

$ curl -b 'x_1=1; a=1' -vvv 'https://...'

< HTTP/1.1 200 OK
< Content-Type: text/html
< Content-Length: 52
< Connection: keep-alive
< Date: Wed, 05 Jul 2017 07:35:49 GMT
< Server: nginx
< Set-Cookie: a=5; path=/
< Set-Cookie: b=6; path=/
< Age: 30
< X-Cache: Hit from cloudfront
< Via: 1.1 xxxxxxxxxxxxxxxx.cloudfront.net (CloudFront)
< X-Amz-Cf-Id: xxxxxxxxxxxxxxxxx

1499240149 array(1) {
  ["x_1"]=>
  string(1) "1"
}

ビューアは x_1a の2つのクッキーを送信しており、オリジンには x_1 だけが届いている。

オリジンはホワイトリストに含まれない ab の2つのクッキーを返しており、X-Cache: Hit from cloudfront のレスポンスに Set-Cookie が含まれている。

オリジンに置いた PHP ソース。

<?php
setcookie('a', '5', 0, '/');
setcookie('b', '6', 0, '/');

echo time();
echo ' ';
var_dump($_COOKIE);

続きを読む

CloudFront配下のアクセス制限付き動画(HLS形式)を、署名付きCookieを使ってVideo.jsで再生する

相変わらずタイトルが長いですが(Googleで検索するとタイトルの途中でぶった切られてます…と思ったら誰かが登録したはてブでした)、
AWS SDK for Javaを使ってカスタムポリシーを使用した署名付きCookie(CloudFront用)を設定する
で設定した環境に、HLS形式の動画(.m3u8ファイル+複数に分割された.tsファイル)を配置して、外部のアプリケーションから読み込ませるものです。

なお、Video.jsでのHLS形式の動画再生については、(Video.jsのバージョンが少し古いですが)以下の記事が参考になります。
Video.js を使って HLS形式の動画をストリーミング再生する(akiyoko blog)

6/5追記:CloudFrontのBehavior設定変更手順を書き漏らしていたので修正しました。

0. 前提

まず、
AWS SDK for Javaを使ってカスタムポリシーを使用した署名付きCookie(CloudFront用)を設定する
の通りに、CloudFrontとS3が設定されているものとします。
また、テスト用のアプリケーションはローカル開発環境にSpring Bootを使って実装するものとします(要hostsファイル変更)。

アプリケーションやコンテンツを公開するドメインは、↑の記事に合わせます。
アプリケーション : https://hmatsu47.site/
https://hmatsu47.site/set-cookieにアクセスするとhttps://hmatsu47.site/index.htmlに遷移し、動画プレイヤーが表示されます。
動画コンテンツ  : https://www.hmatsu47.site/
→S3バケット「testmatsusignedcookie」直下に「sample.m3u8」ファイルと、同ファイルに記述した分割動画ファイル(XXXXX.ts)を保存します。

いずれも、自身の環境に合わせて読み替え/書き替えてください。

1. CloudFrontのBehaviorの設定を変更する

CloudFrontで、対象ディストリビューションのBehavior設定を変更し、S3バケットにフォワードされるHTTPリクエストで、要求ヘッダ中の「Origin」が除外されないようにして保存します。
cf_whitelist.jpg
※上図ではHTTPメソッドとして「GET」「HEAD」に加えて「OPTIONS」を受け入れるよう設定していますが、preflight requestが飛ぶ場合などでうまくいかないときは、「OPTIONS」メソッドの受け入れも試してみてください。

2. S3バケット設定(CORS)を変更する

S3バケットのアクセス権限で、CORS構成エディターを使って設定を変更し、クロスオリジンのScriptアクセスを許可します。
※先の記事の1-3. S3バケットのアクセス権限を設定する⑥・⑦の画面にある[CORS の設定]をクリックし、構成エディタで内容を変更して保存します。

CORS設定
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>http://hmatsu47.site</AllowedOrigin>
    <AllowedOrigin>https://hmatsu47.site</AllowedOrigin>
    <AllowedMethod>HEAD</AllowedMethod>
    <AllowedMethod>GET</AllowedMethod>
    <MaxAgeSeconds>3600</MaxAgeSeconds>
    <ExposeHeader>ETag</ExposeHeader>
    <AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>

<MaxAgeSeconds>には適切な有効期間を指定します。

3. アプリケーションを変更する

先の記事の2. アプリケーションに署名付きCookieをセットするコードを実装する「SetCookieController.java」末尾のコメント行(「// リダイレクトで遷移」)以降を変更して、P3Pコンパクトポリシーを応答ヘッダに付与するとともに(IE11用)、ローカル側のindex.htmlに遷移する形に書き換えます。

SetCookieController.java(変更部分=末尾のみ)
        res.setHeader("P3P", "CP=\"【P3Pコンパクトポリシー】\"");
        // リダイレクトで遷移
        res.sendRedirect("index.html");
    }
}

P3Pコンパクトポリシーは、IE11で別オリジン(別FQDN or 別プロトコル/ポート番号のサイト)にScriptからCookieを送信できるようにするために指定します。
正しい内容でなくても任意の文字列がセットされていれば動作してしまうようです。
なお、IE以外のブラウザでは、すでに機能していないため、指定がなくても別オリジンへの(Scriptによる)Cookie送信は可能です。

4. 動画再生に必要なファイルを配置する

Video.jsおよび必要な.jsファイル等と、それらを呼び出すindex.htmlを用意します。

以下、Spring Boot環境では、「src/main/resources」の下に「static」フォルダを作成して配置します。Video.js関連のファイルはダウンロードするなどして用意してください。

  • video-js.5.19.2 (フォルダ)
  • video-js.5.19.2/video-js.min.css
  • video-js.5.19.2/video.min.js
  • video-js.5.19.2/videojs-contrib-media-sources.min.js
  • video-js.5.19.2/videojs-contrib-hls.min.js
  • video-js.5.19.2/video-js.swf
  • index.html
index.html
<html>
  <head>
    <title>HLS test</title>
    <link href="/video-js.5.19.2/video-js.min.css" rel="stylesheet">
    <script src="/video-js.5.19.2/video.min.js"></script>
    <script src="/video-js.5.19.2/videojs-contrib-media-sources.min.js"></script>
    <script src="/video-js.5.19.2/videojs-contrib-hls.min.js"></script>
    <script>
      videojs.options.flash.swf = "/video-js.5.19.2/video-js.swf"
    </script>
  </head>
  <body>
    <video id="test" class="video-js vjs-default-skin vjs-big-play-centered" controls preload="auto" width="【動画プレイヤー横幅】" height="【同・高さ】">
    </video>
    <script>
      var player = videojs('test', {techOrder: ['flash', 'html5']});

      player.src({
        src: 'https://www.hmatsu47.site/sample.m3u8',
        type: 'application/x-mpegURL',
        withCredentials: true
      });
    </script>    
  </body>
</html>

署名付きCookieをScriptからCloudFrontに送信するために、「withCredentials」「true」にしています。

IE11ではVideo.js+HTML5によるHLS形式の動画再生に不具合があるため、Flashを使った再生となります。
因みに、ローカルの「video-js.swf」を読み込む指定をしていますが、IE11だけこの指定は無視され、CDNからダウンロードされてしまいます…。結果として、IE以外のブラウザで外部のファイルをわざわざ読みにいかないようにするためだけにこの指定がある、ということになってしまっています。

5. 動画ファイルをS3バケットにアップロードする

「sample.m3u8」ファイルおよび分割動画ファイルについても、S3バケットにアップロードしておきます。

6. 動画再生をテストする

以上の作業で、署名付きCookieで保護された動画を再生することができる環境ができあがります。

ローカルのブラウザで、https://hmatsu47.site/set-cookieを開くと動画再生画面が表示され、再生ボタンをクリックすると動画の再生が始まります。

※index.htmlから読み込むファイルとコンテンツタイプの指定を適切に変更することで、HLS形式でない動画を再生することも可能です。

なお、IE11でFlashを使う再生の場合、HTML5で再生するよりも分割動画の読み込みタイミングが遅いため、再生機器や回線の速度によっては動画がギクシャクしやすくなります。ご注意ください。

7. 補足/CookieとCORSについて

ややこしいのですが、Cookieの有効範囲とCORSによる「オリジン間のアクセス制御」は別物です。

■Cookieの有効範囲:Domain属性、Secure属性、Path属性、HttpOnly属性で指定

  • Domain属性を指定しなければSet-Cookieしたサーバと同じFQDN、指定していれば指定ドメインおよびそのサブドメイン。今回のケースでは「hmatsu47.site」を指定しているため、「hmatsu47.site」および「www.hmatsu47.site」のどちらでも有効(テストには使っていないが「app.hmatsu47.site」「video.hmatsu47.site」などでも有効)。なお、Set-Cookieしたサーバと関係のないドメインをDomain属性で指定しても無効(ブラウザで破棄される)。
  • Secure属性を指定しなければHTTP/HTTPSの両方で、指定すればHTTPSのみで有効。ポート番号の区別なし。
  • Path属性を指定すれば指定のパス以下で有効(Path属性には色々問題はあるが、説明は省略)。
  • HttpOnly属性を指定すればScriptから利用不可。
  • サードパーティーCookie(ブラウザが読み込んだHTML本体とは別のドメインのサーバから画像や.jsなどを読み込むことによりセットされるCookie)については、ブラウザの設定によって有効/無効が決まる。

■CORS:オリジン(FQDNとプロトコル・ポート番号の組み合わせ)をまたぐアクセスを制御

  • 同一オリジンにあたるのは、「FQDNとプロトコル(HTTP/HTTPS)およびポート番号(80/81、443/8443など)の組み合わせが同じ」。つまり、https://hmatsu47.sitehttps://www.hmatsu47.siteは別オリジン。http://www.hmatsu47.sitehttps://www.hmatsu47.sitehttp://hmatsu47.sitehttp://hmatsu47.site:81も別オリジン。
  • (ブラウザが処理中の)別オリジンのサーバのScriptからアクセスを受けるようなケースで、アクセス制御に使用される。https://hmatsu47.site からの読み込みアクセスは、リクエストを受けてから○○秒の範囲で有効にするよ」というようなイメージ。アクセスを受けたサーバがアクセス元オリジンを区別できるように、ブラウザからのリクエストの要求ヘッダにはオリジンの情報(Origin)を付与する。
  • デフォルトでは、たとえブラウザが(アクセス先の別ドメインに対して有効な)Cookieを持っていたとしても、Scriptからのリクエストでは送信しない。JavaScriptでは、XMLHttpRequestでwithCredentials属性を有効にすることで(リクエストを送信するとき一緒に)送信される。なお、CORSとは別に、IE11の場合はP3Pコンパクトポリシーも必要になる。
  • Cookieを受け付けるかどうかは、リクエストを受けるサーバのCORSでも指定できる。

つまり、

  • いくらwithCredentials属性を有効にしてXMLHttpRequestでリクエストを送信しても、相手先のドメインで有効なCookieを持っていないとCookieは送信されない。
  • サードパーティーCookieがブラウザに受け入れられるかどうかとCORSの設定は別問題であるため、分けて考える必要がある。

ということです。

なお、今回のサンプルでは、Javaのコードで署名付きCookieを生成する際に「HttpOnly」属性を付けています。
これは、アクセス元のJavaScriptの中ではこのCookieを参照する必要がないからです。結果、「HttpOnly」属性を付けていても問題なく動作します。

続きを読む

AWS EC2にdockerベースのredashを一から設定して起動するまで

スクリーンショット 2017-05-31 0.22.43.png

オープンソースのDashboardツールredash。
postgresからAWS redshiftなど各種DB/DWHの情報をビジュアライズできるツール。
最近ではAirbnb社が開発したsupersetも注目されてますが、流行りのredashをインストールしてみた。

AWS EC2にdockerベースのredashを一から設定して起動するまでのセットアップメモ。

ポイントは以下
– redash、postgresql、nginx、redisで構成
– 全部公式のdockerイメージがある
– docker-composeで簡単一括実行
– 諸々のソースはredash公式のGit Hubリポジトリに公開されている。

※nginxはwebサーバ、postgreはクエリの実行結果やデータの保存、redisはタスクキューに使用するらしい。

1) EC2に最低限必要なツールをセットアップ

※amazon linux t2microインスタンスを利用

最初のおまじないと、docker & gitインストール

sudo yum install -y
sudo install -y docker git

sudo権限なしでdockerコマンドを使えるようにする

sudo usermod -a -G docker ec2-user
exit #一旦ログオフし再接続が必要

docker-composeインストール
※yum install docker-composeとかできると見せかけてできない

sudo curl -L "https://github.com/docker/compose/releases/download/1.11.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

2) Git Hubからredash一式をgit close

git clone https://github.com/getredash/redash.git

https://github.com/getredash/redash

3) redashのdocker-compose.yamlを編集

cloneしたredash/docker-compose.yamlを編集する。
もともとのdocker-compose.yamlはプロダクション利用には不向きの設定らしく、redash/docker-compose.production.yamlをコピーし、編集する。
編集ポイントは、postgresqlのvolumeとpassword関連。デフォルトではコメントアウトまたは何も記載がない。

docker-compose.yaml

# This is an example configuration for Docker Compose. Make sure to atleast update
# the cookie secret & postgres database password.
#
# Some other recommendations:
# 1. To persist Postgres data, assign it a volume host location.
# 2. Split the worker service to adhoc workers and scheduled queries workers.
version: '2'
services:
  server:
    image: redash/redash:latest
    command: server
    depends_on:
      - postgres
      - redis
    ports:
      - "5000:5000"
    environment:
      PYTHONUNBUFFERED: 0
      REDASH_LOG_LEVEL: "INFO"
      REDASH_REDIS_URL: "redis://redis:6379/0"
      REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
      REDASH_COOKIE_SECRET: veryverysecret
  worker:
    image: redash/redash:latest
    command: scheduler
    environment:
      PYTHONUNBUFFERED: 0
      REDASH_LOG_LEVEL: "INFO"
      REDASH_REDIS_URL: "redis://redis:6379/0"
      REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
      QUEUES: "queries,scheduled_queries,celery"
      WORKERS_COUNT: 2
  redis:
    image: redis:3.0-alpine
  postgres:
    image: postgres:9.5.6-alpine
    volumes:  #コメントアウトを取る
      - /home/ec2-user/postgres-data:/var/lib/postgresql/data
    environment: #追加する
      - POSTGRES_USER:'test'
      - POSTGRES_PASSWORD:'pwd'
      - POSTGRES_DB:'mydb'
  nginx:
    image: redash/nginx:latest
    ports:
      - "80:80"
    depends_on:
      - server
    links:
      - server:redash

postgresのデータは永続化するためホストにvolumeマウントする。
上記設定に応じてホスト側にマウントパスディレクトリを作成しておく。

sudo mkdir -p /home/ec2-user/postgres-data

4) docker-composeでredash(及び関連ツール)起動

docker-compose run --rm server create_db
docker-compose up

5) ブラウザで確認

EC2側で設定したパブリックIP宛にブラウザで起動すると、Redisログインページが表示される。
※EC2のSG等で80番のインバウンドが許可されていること。

参考)
http://qiita.com/wizpra-koyasu/items/aa8b3fc069816d91ae05

続きを読む

AWS SDK for Javaを使ってカスタムポリシーを使用した署名付きCookie(CloudFront用)を設定する

CloudFront配下のコンテンツへのアクセスを制限する場合、

  • 規定ポリシーを使用した署名付きURL
  • カスタムポリシーを使用した署名付きURL
  • 規定ポリシーを使用した署名付きCookie
  • カスタムポリシーを使用した署名付きCookie

が使えます。

【参考:AWS内ドキュメントへのリンク】
署名付き URL の使用
既定ポリシーを使用して署名付き URL を作成する
カスタムポリシーを使用して署名付き URL を作成する
署名付き Cookie の使用
既定ポリシーを使用した署名付き Cookie の設定
カスタムポリシーを使用した署名付き Cookie の設定

が、

  • 署名付きURLを使用する場合は、アクセスするリソースのそれぞれのリンクに署名を付ける必要がある
  • 規定ポリシーを使用する場合は、アクセス許可対象の指定にワイルドカード(「?」「*」)が使えない

ということで、

  • 1ファイル毎にアクセス権を与える
  • アプリケーション側から1個ずつリソースへのリンクを動的生成する

という場合には、規定ポリシーや署名付きURLで良いのですが、

  • アクセス制限対象のコンテンツがHTMLファイル+.css+画像+動画で構成される

という場合には、署名付きURLでは読み込む.css、画像、動画の各ファイルへのリンクにも署名を付けないといけなくなって現実的でないので、今回はカスタムポリシーを使用した署名付きCookieを使って実験してみました。

0. 今回の実験内容

  • アプリケーションで署名付きCookieを発行し、ブラウザにセットします。
  • アクセス制限対象のコンテンツは、S3バケット「testmatsusignedcookie」に保存します。
  • アプリケーションはhttps://hmatsu47.site/で公開します(cookieのセットは「/set-cookie」で実行)。
  • アクセス制限対象のコンテンツは、https://www.hmatsu47.site/で公開します(CloudFrontディストリビューション)。
  • サードパーティーCookieの受け入れを制限しているブラウザもある(多い)ため、発行する署名付きCookieにはDomain属性「hmatsu47.site」を指定します。

1. CloudFront+S3側の設定

以下の流れで設定します。

1-1. AWS マネジメントコンソールでCloudFrontキーペアを作成する

以下、IAMユーザでは作成できないので、ルート認証情報を持つユーザを使って操作を行います。

01_sec_auth.jpg
①右上のユーザのメニューから、「セキュリティ認証情報」を選択。
「CloudFrontのキーペア」を展開
[新しいキーペアの作成]をクリック
[プライベートキーファイルのダウンロード]をクリックし、プライベートキーファイルをダウンロード
⑤ポップアップを閉じた画面で、キーペアIDを確認・記録
 ※ダウンロードしたプライベートキーファイルの「pk-」「.pem」の間の文字列からキーペアIDを確認することもできます。
⑥以下のリンク先を参考に、OpenSSLでプライベートキーファイル(.pem形式)を.der形式にコンバート
 CloudFront プライベートキーの形式を変更する(.NET および Java のみ)
※Windows環境用のOpenSSLのバイナリ(.exe)を提供しているサイトがありますので、それを使うことも可能です。

opensslコマンド
openssl pkcs8 -topk8 -nocrypt -in 【プライベートキーファイル名】.pem -inform PEM -out 【出力ファイル名】.der -outform DER

1-2. CloudFrontディストリビューションを作成する

02_cf_create_dist_01.jpg
①コンソールのCloudFront→Distributionで、[Create Distribution]をクリック

02_cf_create_dist_02.jpg
②Webの[Get Started]をクリック

02_cf_create_dist_03.jpg
③オリジンのS3バケットに関する項目を設定
「Restrict Bucket Access」「Yes」を選択
「Origin Access Identity」「Create a New Identity」を選択
「Grant Read Permissions on Bucket」「Yes, …」を選択

02_cf_create_dist_04.jpg
「Restrict Viewer Access」「Yes」を選択
「Trusted Signers」「Self」にチェック
「Alternate Domain Names」にドメイン名(CloudFrontのディストリビューションに割り当てるFQDN)を指定
 ※ディストリビューション作成完了後、Route 53の当該レコードでAレコードのエイリアスを指定します。

02_cf_create_dist_05.jpg
⑩SSL/TLS関連項目を設定

02_cf_create_dist_06.jpg
「Create Distribution」をクリック

02_cf_oai.jpg
「Origin Access Identity」をクリック
「Amazon S3 Canonical User ID」を確認
⑭ディストリビューションが作成されるのを待つ

1-3. S3バケットのアクセス権限を設定する

①コンソールのS3で、公開対象のS3バケット→「アクセス権限」タブを選択

03_s3_acl.jpg
[アクセスコントロールリスト]をクリック
[ユーザを追加する]をクリック
④CloudFront設定の⑬で確認した文字列を入力
「オブジェクトアクセス」「読み込み」にチェック
[保存]をクリック

03_s3_policy.jpg
[バケットポリシー]をクリックし、(CloudFrontディストリビューション作成時の指定により)ポリシーが設定されていることを確認

2. アプリケーションに署名付きCookieをセットするコードを実装する

今回はローカル開発環境での実験のため、Spring Boot環境で実装しました。
以下、「/set-cookie」用Controllerクラスについてのみ記します。

あらかじめ、.der形式に変換しておいたプライベートキーファイルを、「src/main/resources」の下に「files」フォルダを作成してその中に「private-key.der」という名前で保存しておきます。
今回はSpring Bootの環境に合わせてこの場所にファイルを保存したため、プライベートキーファイルの内容の取り出し方がやや面倒な形になっていますが、通常のServlet環境でWEB-INF内にファイルを保存しておけば、もっと素直に取り出せるはずです。

なお、Amazonでは、キーペア(プライベートキーとパブリックキー)を90日毎に更新することを推奨しており、そのような短いサイクルでの更新を行うのであれば、キーペアIDとプライベートキーファイルは、ハードコーディングしたりアプリケーションのパッケージに固定的に含めたりするよりも、アプリケーションと分けて更新できる形で保持するのがいいでしょう。

SetCookieController.java
package site.hmatsu47.springboot;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.RequestMapping;

import com.amazonaws.services.cloudfront.CloudFrontCookieSigner;
import com.amazonaws.services.cloudfront.CloudFrontCookieSigner.CookiesForCustomPolicy;
import com.amazonaws.util.DateUtils;

import java.io.File;
import java.nio.file.Files;
import java.security.KeyFactory;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Date;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Controller;

@Controller
public class SetCookieController {

    @Autowired
    ResourceLoader resourceLoader;

    @RequestMapping("/set-cookie")
    public void setCookie(HttpServletResponse res) throws Exception{
        String url = "https://www.hmatsu47.site/";      // URL
        String filepath = "files/private-key.der";      // プライベートキー
        Resource resource = resourceLoader.getResource("classpath:" + filepath);
        File file = resource.getFile();
        byte[] privateKeyByteArray = Files.readAllBytes(file.toPath());
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyByteArray);
        KeyFactory kf = KeyFactory.getInstance("RSA");
        RSAPrivateKey privateKey = (RSAPrivateKey) kf.generatePrivate(keySpec);
        String resourcePath = "*";                      // アクセスを許可する範囲
        String keyPairId = "【キーペアID】";                  // キーペアID
        Date activeFrom = null;                         // 有効期間:開始(null=即時)
        Date expiresOn = DateUtils.parseISO8601Date("2020-12-31T23:59:59.999Z"); // 有効期間:終了(UTCで)
        String ipRange = "0.0.0.0/0";                   // アクセス元のIPアドレス範囲
        // cookie用のキー/値の生成
        CookiesForCustomPolicy cookies = CloudFrontCookieSigner.getCookiesForCustomPolicy(
                url + resourcePath, privateKey, keyPairId, expiresOn, activeFrom, ipRange);
        // cookieの設定
        Cookie cookiePolicy = new Cookie(
                cookies.getPolicy().getKey(), cookies.getPolicy().getValue());
        cookiePolicy.setDomain("hmatsu47.site");
        cookiePolicy.setHttpOnly(true);
        cookiePolicy.setSecure(true);
        res.addCookie(cookiePolicy);

        Cookie cookieSignature = new Cookie(
                cookies.getSignature().getKey(), cookies.getSignature().getValue());
        cookieSignature.setDomain("hmatsu47.site");
        cookieSignature.setHttpOnly(true);
        cookieSignature.setSecure(true);
        res.addCookie(cookieSignature);

        Cookie cookieKeyPairId = new Cookie(
                cookies.getKeyPairId().getKey(), cookies.getKeyPairId().getValue());
        cookieKeyPairId.setDomain("hmatsu47.site");
        cookieKeyPairId.setHttpOnly(true);
        cookieKeyPairId.setSecure(true);
        res.addCookie(cookieKeyPairId);
        // とりあえずの返却値
        res.getWriter().write("OK");
    }
}

3. 実行する

公開対象のS3バケットには、適当な内容のindex.htmlと、index.htmlの指定で読み込まれる画像などを保存しておきます。

まず、ブラウザを起動し、https://www.hmatsu47.site/を開きます。すると、アクセス権限がないので対象のページが開かず、エラーになります。

次に、2. で実装したhttps://hmatsu47.site/set-cookieを開きます。すると、署名付きCookieがドメイン「hmatsu47.site」およびそのサブドメインの範囲を対象にセットされます。

再度、ブラウザでhttps://www.hmatsu47.site/を開くと、今度は正しくページが開きます。

ブラウザを閉じると、署名付きCookieが消えるため、再度ブラウザを起動したときにはhttps://www.hmatsu47.site/は開かず、エラーになります。

※今回は、取り急ぎ確認するために、ローカル開発環境でSpring Bootアプリケーションを実行しました(hostsファイルを編集し、「hmatsu47.site」をローカルに向ける)。

hostsファイル(追記)
127.0.0.1   hmatsu47.site

とりあえず非SSL/TLS通信のままSecure属性付きのCookieを発行してみたところ、IE11ではそのまま受け入れられましたが、Chromeでは受け入れられませんでした。

続きを読む