サーバーレスのセキュリティ — IAMと最小権限
ダイチの会社にセキュリティ監査が入った。監査員から指摘が飛んできた。
「このLambda関数、S3の全バケットにフルアクセスですね。DynamoDBも全テーブルへの読み書き権限が...」
顔が青くなった。機能要件を優先するあまり、権限設計が甘かった。サーバーレスアーキテクチャではIAMがセキュリティの中心になる。OS レベルの設定は AWS が管理してくれるが、IAMの設計はあなたが担う。
IAM の基本 — 最小権限の原則
最小権限の原則(Principle of Least Privilege): 各Lambda関数には、その関数が必要とする権限のみを付与する。
悪い例(よくあるアンチパターン)
# NG: 広すぎる権限
Resources:
MyFunction:
Type: AWS::Serverless::Function
Properties:
Policies:
- AmazonDynamoDBFullAccess # 全テーブルへの全操作
- AmazonS3FullAccess # 全バケットへの全操作
- AWSLambdaFullAccess # Lambdaの全操作(関数が関数を呼べる!)良い例(最小権限)
# OK: 必要最小限の権限
Resources:
ListProductsFunction:
Type: AWS::Serverless::Function
Properties:
Policies:
# SAM ポリシーテンプレート(DynamoDB の特定テーブルへの読み取りのみ)
- DynamoDBReadPolicy:
TableName: !Ref ProductsTable
# カスタムポリシー(特定インデックスへのクエリのみ)
- Statement:
- Effect: Allow
Action:
- dynamodb:Query
Resource:
- !Sub "${ProductsTable.Arn}/index/CategoryIndex"
ProcessPaymentFunction:
Type: AWS::Serverless::Function
Properties:
Policies:
# Secrets Manager の特定シークレットのみ
- Statement:
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource:
- !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payment-api-key-*"SAM ポリシーテンプレート一覧
SAMには頻出パターンをまとめたポリシーテンプレートが用意されている。
Policies:
# DynamoDB
- DynamoDBReadPolicy: # GetItem, Query, Scan, BatchGetItem
TableName: !Ref MyTable
- DynamoDBWritePolicy: # PutItem, UpdateItem, DeleteItem, BatchWriteItem
TableName: !Ref MyTable
- DynamoDBCrudPolicy: # 上記両方
TableName: !Ref MyTable
# S3
- S3ReadPolicy: # GetObject, ListBucket
BucketName: !Ref MyBucket
- S3WritePolicy: # PutObject, DeleteObject
BucketName: !Ref MyBucket
# SQS
- SQSSendMessagePolicy:
QueueName: !GetAtt MyQueue.QueueName
- SQSPollerPolicy:
QueueName: !GetAtt MyQueue.QueueName
# SNS
- SNSPublishMessagePolicy:
TopicName: !GetAtt MyTopic.TopicName
# Secrets Manager
- AWSSecretsManagerGetSecretValuePolicy:
SecretArn: !Ref MySecret環境変数と機密情報の管理
API キーやDBパスワードをLambdaの環境変数に直接書いてはいけない。
Secrets Manager の使用
# template.yaml
Resources:
PaymentApiSecret:
Type: AWS::SecretsManager::Secret
Properties:
Name: !Sub "/${AWS::StackName}/payment-api-key"
GenerateSecretString:
SecretStringTemplate: '{"username": "payment_service"}'
GenerateStringKey: "api_key"
PasswordLength: 32
ProcessPaymentFunction:
Type: AWS::Serverless::Function
Properties:
Environment:
Variables:
# シークレットのARNのみを環境変数に持つ(値ではない)
PAYMENT_SECRET_ARN: !Ref PaymentApiSecret
Policies:
- AWSSecretsManagerGetSecretValuePolicy:
SecretArn: !Ref PaymentApiSecret# Ruby コードでの Secrets Manager 参照
require 'aws-sdk-secretsmanager'
require 'json'
# キャッシュ(ウォームスタート時の再取得を避ける)
$cached_secrets = {}
def get_secret(secret_arn)
return $cached_secrets[secret_arn] if $cached_secrets.key?(secret_arn)
client = Aws::SecretsManager::Client.new
response = client.get_secret_value(secret_id: secret_arn)
secret = JSON.parse(response.secret_string)
# 5分間キャッシュ(Lambda コンテナが再利用される間有効)
$cached_secrets[secret_arn] = secret
secret
rescue Aws::SecretsManager::Errors::ServiceError => e
raise "Failed to retrieve secret: #{e.message}"
end
def lambda_handler(event:, context:)
secret = get_secret(ENV['PAYMENT_SECRET_ARN'])
api_key = secret['api_key']
# api_key を使って決済API呼び出し
# ...
endINFO
Secrets Manager は呼び出し回数に応じて課金される($0.05/10,000リクエスト)。Lambda のウォームスタート時はキャッシュを使い、コールドスタート時のみ取得することでコストを抑えられる。ローテーション設定も可能。
VPC 配置 — データベースへのアクセス制御
Aurora などのRDSへのアクセスはVPC内に限定すべきだ。
# VPC の設定
LambdaSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Lambda function security group
VpcId: !Ref VpcId
SecurityGroupEgress:
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
DestinationSecurityGroupId: !Ref DBSecurityGroup
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0 # AWS API エンドポイントへのアクセス
DBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: RDS security group
VpcId: !Ref VpcId
SecurityGroupIngress:
# Lambda のセキュリティグループからのみアクセス許可
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
SourceSecurityGroupId: !Ref LambdaSecurityGroup
OrderFunction:
Type: AWS::Serverless::Function
Properties:
VpcConfig:
SecurityGroupIds:
- !Ref LambdaSecurityGroup
SubnetIds:
- !Ref PrivateSubnet1
- !Ref PrivateSubnet2WARNING
VPC内に配置したLambdaがインターネット(外部API等)にアクセスするには NAT Gateway が必要。NAT Gatewayは月約$40〜かかる。DynamoDBやS3へのアクセスはVPCエンドポイントを使えばNAT Gateway不要でプライベートネットワーク内で完結できる。
VPC エンドポイントの設定
# S3 へのプライベートアクセス(NAT Gatewayなし)
S3VpcEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref VpcId
ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3"
VpcEndpointType: Gateway
RouteTableIds:
- !Ref PrivateRouteTable
# DynamoDB へのプライベートアクセス
DynamoDBVpcEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref VpcId
ServiceName: !Sub "com.amazonaws.${AWS::Region}.dynamodb"
VpcEndpointType: Gateway
RouteTableIds:
- !Ref PrivateRouteTableLambda 関数 URL とリソースポリシー
Lambda Function URL を使う場合は、リソースポリシーで呼び出し元を制限できる。
ProductApiFunction:
Type: AWS::Serverless::Function
Properties:
FunctionUrlConfig:
AuthType: AWS_IAM # IAM認証必須(AuthType: NONE はパブリックアクセスなので注意)
Cors:
AllowOrigins:
- "https://ec-site.example.com"
AllowMethods:
- GET// Lambda リソースポリシー(特定のアカウントのみ許可)
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/FrontendRole"
},
"Action": "lambda:InvokeFunctionUrl",
"Resource": "arn:aws:lambda:ap-northeast-1:123456789012:function:ProductApiFunction",
"Condition": {
"StringEquals": {
"lambda:FunctionUrlAuthType": "AWS_IAM"
}
}
}
]
}入力バリデーション
外部からの入力は必ずバリデーションする。
# frozen_string_literal: true
require 'json'
class ValidationError < StandardError; end
def lambda_handler(event:, context:)
body = parse_and_validate_body(event['body'])
# 処理
rescue ValidationError => e
{ statusCode: 400, body: JSON.generate({ error: e.message }) }
rescue JSON::ParserError
{ statusCode: 400, body: JSON.generate({ error: 'Invalid JSON' }) }
end
private
def parse_and_validate_body(raw_body)
raise ValidationError, 'Request body is required' if raw_body.nil? || raw_body.empty?
body = JSON.parse(raw_body)
# 型チェック
validate_presence!(body, %w[product_id quantity payment_method_id])
validate_integer!(body['quantity'], min: 1, max: 100)
validate_string!(body['product_id'], max_length: 36)
body
end
def validate_presence!(hash, keys)
missing = keys.reject { |k| hash.key?(k) && !hash[k].nil? }
raise ValidationError, "Missing required fields: #{missing.join(', ')}" if missing.any?
end
def validate_integer!(value, min: nil, max: nil)
raise ValidationError, 'Quantity must be an integer' unless value.is_a?(Integer)
raise ValidationError, "Quantity must be >= #{min}" if min && value < min
raise ValidationError, "Quantity must be <= #{max}" if max && value > max
end
def validate_string!(value, max_length: nil)
raise ValidationError, 'Must be a string' unless value.is_a?(String)
raise ValidationError, "Too long (max #{max_length})" if max_length && value.length > max_length
# SQLインジェクション的な文字の拒否
raise ValidationError, 'Invalid characters' if value.match?(/[<>'"%;]/)
endセキュリティチェックリスト
ダイチは監査を受けて、チーム向けのセキュリティチェックリストを作成した。
| チェック項目 | 確認方法 |
|---|---|
| 各関数の権限が最小か | sam validate + IAM Access Analyzer |
| 環境変数に機密情報がないか | template.yaml を目視確認 |
| 全シークレットが Secrets Manager 経由か | コード検索 ENV['.*KEY|.*SECRET|.*PASSWORD'] |
| VPC 配置が必要な関数に設定されているか | VpcConfig 有無を確認 |
| 入力バリデーションが実装されているか | コードレビュー |
| CloudTrail でLambda実行ログが有効か | AWS Console 確認 |
セキュリティ監査を通じて、ダイチはサーバーレスのセキュリティは「コードよりIAMポリシーとネットワーク設計が主戦場」と学んだ。EC2では見えていたOSレベルの設定はAWSが担うが、その分IAM設計の重要性が増す。