HTTPS と TLS — 通信を暗号化する
インシデントの翌朝、リョウはまずログを全件見直した。攻撃者はAPIエンドポイントへのリクエスト内容を正確に把握していた。なぜだろうか。
調査を進めると、社内開発環境のAPIがHTTPで通信していることが判明した。そして本番環境でも、ロードバランサーとECSコンテナ間の通信が平文だったことがわかった。
「通信を覗き見されていた可能性がある」
リョウは背筋が凍った。
TLSとは何か
TLS(Transport Layer Security)は、ネットワーク通信を暗号化するプロトコルだ。HTTPSは「HTTP over TLS」の略であり、すべてのWebトラフィックを暗号化する。
TLSが提供する3つの保証:
- 機密性 — 通信内容を第三者が読めない
- 完全性 — 通信途中でデータが改ざんされていない
- 認証 — 通信相手が本物であることを確認
INFO
TLS 1.3(2018年標準化)は、TLS 1.2と比べてハンドシェイクが1往復少なく、ラウンドトリップが減少します。また、Forward Secrecyが必須になり、過去の通信も保護されます。
AWS ACMで証明書を取得する
リョウの環境はAWSを使っていたので、AWS Certificate Manager(ACM)で無料のSSL証明書を取得した。
# ACMで証明書をリクエスト(DNS検証方式)
aws acm request-certificate \
--domain-name "api.stockflow.example.com" \
--subject-alternative-names "*.stockflow.example.com" \
--validation-method DNS \
--region ap-northeast-1
# 出力例
{
"CertificateArn": "arn:aws:acm:ap-northeast-1:123456789012:certificate/abc123"
}# 証明書の検証レコードを取得
aws acm describe-certificate \
--certificate-arn "arn:aws:acm:ap-northeast-1:123456789012:certificate/abc123" \
--query "Certificate.DomainValidationOptions"
# Route 53に検証レコードを自動追加
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890ABC \
--change-batch file://validation-records.jsonINFO
ACMの証明書はALB、CloudFront、API GatewayなどのAWSサービスで直接使用できます。証明書の自動更新も行われるため、証明書期限切れの事故を防げます。
ALBでHTTPSを強制する
証明書を取得したら、Application Load Balancer(ALB)でHTTPSを強制する。
# terraform/alb.tf
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate.main.arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.api.arn
}
}
# HTTPからHTTPSへのリダイレクト
resource "aws_lb_listener" "http_redirect" {
load_balancer_arn = aws_lb.main.arn
port = "80"
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}ELBSecurityPolicy-TLS13-1-2-2021-06 を選択することで、TLS 1.3と1.2のみを許可し、古い脆弱なバージョンを拒否できる。
RailsでHSTSを設定する
HSTS(HTTP Strict Transport Security)は、ブラウザに「このドメインは常にHTTPSで接続せよ」と指示するHTTPヘッダーだ。
# config/environments/production.rb
Rails.application.configure do
# HTTPSを強制
config.force_ssl = true
# HSTSの設定(デフォルト値を強化)
config.ssl_options = {
hsts: {
expires: 1.year,
subdomains: true,
preload: true
},
redirect: {
status: 301,
body: "Moved Permanently"
}
}
endこれにより、Railsは自動的に以下のヘッダーを付与する。
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
WARNING
preloadフラグを有効にしてHSTSリストに登録すると、ブラウザはHTTP接続を完全に拒否します。一度登録すると削除に時間がかかるため、本番環境で問題ないことを確認してから設定してください。
TLSの設定を検証する
設定後、リョウは複数の方法で設定を検証した。
# opensslで証明書を確認
openssl s_client -connect api.stockflow.example.com:443 -tls1_3 2>&1 | head -30
# TLSバージョンとCipher Suiteを確認
curl -v --tlsv1.3 https://api.stockflow.example.com/health 2>&1 | grep -E "TLS|SSL|cipher"
# SSL Labsのテスト(コマンドライン版)
sslyze api.stockflow.example.com# 出力例(良好な設定)
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256証明書の有効期限監視
証明書の期限切れは深刻なインシデントになりうる。ACMは自動更新するが、外部の証明書を使う場合は監視が必要だ。
# config/initializers/ssl_certificate_check.rb
# CloudWatchにメトリクスを送る(カスタム監視)
class SslCertificateChecker
def self.check(domain)
cert = fetch_certificate(domain)
days_until_expiry = (cert.not_after - Time.now) / 86400
if days_until_expiry < 30
# SNSでアラートを送信
Aws::SNS::Client.new.publish(
topic_arn: ENV['ALERT_TOPIC_ARN'],
message: "SSL certificate for #{domain} expires in #{days_until_expiry.round} days",
subject: "[WARNING] SSL Certificate Expiring Soon"
)
end
days_until_expiry
end
private
def self.fetch_certificate(domain)
tcp = TCPSocket.new(domain, 443)
ssl = OpenSSL::SSL::SSLSocket.new(tcp)
ssl.hostname = domain
ssl.connect
cert = ssl.peer_cert
ssl.close
cert
end
end# config/schedule.yml(whenevergemの設定)
every 1.day, at: '9:00 am' do
runner "SslCertificateChecker.check('api.stockflow.example.com')"
endAPI間通信の暗号化
本番環境のECSコンテナ間の通信も暗号化する必要がある。
# docker-compose.production.yml(概念例)
services:
api:
environment:
DATABASE_URL: "postgresql://user:pass@db.internal:5432/stockflow?sslmode=verify-full&sslrootcert=/etc/ssl/certs/rds-ca.pem"
REDIS_URL: "rediss://redis.internal:6380/0" # rediss:// でTLS有効# config/database.yml
production:
adapter: postgresql
url: <%= ENV['DATABASE_URL'] %>
# SSLモードの強制
sslmode: verify-full
sslrootcert: /etc/ssl/certs/rds-ca.pemWARNING
sslmode: verify-fullは最も厳格な設定で、証明書の有効性と証明書のCNがホスト名と一致することを確認します。sslmode: requireだけでは中間者攻撃を防げません。
セキュリティヘッダーの追加
TLSに加えて、追加のセキュリティヘッダーを設定する。
# config/initializers/security_headers.rb
Rails.application.config.middleware.use Rack::Attack
# Rackミドルウェアでセキュリティヘッダーを追加
class SecurityHeaders
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
headers.merge!(
# XSSフィルタを有効化
'X-XSS-Protection' => '1; mode=block',
# コンテンツタイプのスニッフィングを防止
'X-Content-Type-Options' => 'nosniff',
# クリックジャッキングを防止
'X-Frame-Options' => 'DENY',
# 参照元情報を制限
'Referrer-Policy' => 'strict-origin-when-cross-origin',
# 権限ポリシー
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()'
)
[status, headers, body]
end
end
Rails.application.config.middleware.insert_before 0, SecurityHeadersリョウの気づき
夜通し作業してHTTPSを完全に設定したリョウは、ひとつの重要な教訓を得た。
「通信の暗号化は基礎中の基礎だ。でも、これだけでは攻撃者を防げない。次は、通信している相手が本当に正当なユーザーかどうかを確認する方法が必要だ」
TLSは「通信路を守る」ものだ。「誰が通信しているか」を守るのは、認証の仕事になる。
チェックリスト
- 全エンドポイントでHTTPSを強制している
- HTTPからHTTPSへのリダイレクト(301)が設定されている
- TLS 1.2以上のみ許可している(TLS 1.0/1.1は無効化)
- HSTSヘッダーが設定されている(
max-ageは最低1年) - ACMまたは同等の証明書自動更新が設定されている
- 証明書の有効期限監視が設定されている
- 内部通信(DB、Redis、マイクロサービス間)も暗号化されている
- TLS設定をSSL Labsなどでスコアリングしている(A以上を目指す)
- セキュリティヘッダーが適切に設定されている