mybook

サーバーレスのセキュリティ — IAMと最小権限

ダイチの会社にセキュリティ監査が入った。監査員から指摘が飛んできた。

「このLambda関数、S3の全バケットにフルアクセスですね。DynamoDBも全テーブルへの読み書き権限が...」

顔が青くなった。機能要件を優先するあまり、権限設計が甘かった。サーバーレスアーキテクチャではIAMがセキュリティの中心になる。OS レベルの設定は AWS が管理してくれるが、IAMの設計はあなたが担う

IAM の基本 — 最小権限の原則

最小権限の原則(Principle of Least Privilege): 各Lambda関数には、その関数が必要とする権限のみを付与する。

Loading diagram...

悪い例(よくあるアンチパターン)

# 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の環境変数に直接書いてはいけない。

Loading diagram...

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呼び出し
  # ...
end

INFO

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 PrivateSubnet2

WARNING

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 PrivateRouteTable

Lambda 関数 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

セキュリティチェックリスト

ダイチは監査を受けて、チーム向けのセキュリティチェックリストを作成した。

Loading diagram...
チェック項目確認方法
各関数の権限が最小かsam validate + IAM Access Analyzer
環境変数に機密情報がないかtemplate.yaml を目視確認
全シークレットが Secrets Manager 経由かコード検索 ENV['.*KEY|.*SECRET|.*PASSWORD']
VPC 配置が必要な関数に設定されているかVpcConfig 有無を確認
入力バリデーションが実装されているかコードレビュー
CloudTrail でLambda実行ログが有効かAWS Console 確認

セキュリティ監査を通じて、ダイチはサーバーレスのセキュリティは「コードよりIAMポリシーとネットワーク設計が主戦場」と学んだ。EC2では見えていたOSレベルの設定はAWSが担うが、その分IAM設計の重要性が増す。