Amazon API GatewayのLambda AuthorizerにVPC Lambdaを使ってIP制限先のコンテンツを利用してみた

2023.07.28

初めに

API GatewayのオーソライザとしてLambdaは利用可能ですがVPC Lambdaは利用可能だったけ?と思いドキュメントを読んでみたところ明示はされていなかったので試してみることにしました。

VPC Lambdaを選択されるケースの1つとして固定のIPが欲しいというのはある話ですので今回はLambdaがIP制限のかかっているコンテンツへのアクセスを行うパターンを試してみます。

構成

構成は以下のとおりです。

認証についてはなんちゃってコンテンツですがAuthorizationヘッダに指定された値を元に{{ヘッダに指定された値}}.jsonを取得しに行き、その値が意図しているものであれば成功という型になります。

成功した場合はHelloWorldの関数が呼ばれますが、こちらは単純にhello worldを返却するだけのsam initのテンプレートで作成されるデフォルトの関数となります。

HelloWorld側は特にVPC Lambdaにする必要はないですが、普段SAMのアップデートでブログ書く際にわざわざVPC Lambdaで立てる機会もなくせっかくなのでVPC内に立てます。

コード

AWS SAMを利用して構築します。

SAMテンプレート

EC2は事前に立ててあるものを使うので含まれていません。

長いので畳んでおきます

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
  Function:
    Timeout: 60
    MemorySize: 128
Parameters:
  WebSgId:
    Type: String
  AuthHost:
    Type: String
Resources:
  Api:
    Type: AWS::Serverless::Api
    Properties:
      StageName: dev
      Auth:
        DefaultAuthorizer: LambdaAuthorizer
        Authorizers:
          LambdaAuthorizer:
            FunctionArn: !GetAtt AuthFunction.Arn
            Identity:
              Headers:
              - Authorization
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
            RestApiId: !Ref Api
      VpcConfig:
        SubnetIds:
          - !Ref HelloFunctionSubnet
        SecurityGroupIds:
          - !Ref HelloFunctionSg
  AuthFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: auth/
      Handler: app.lambda_handler
      Runtime: python3.9
      Environment:
        Variables:
          AUTH_HOST: !Ref AuthHost
      Architectures:
        - x86_64
      VpcConfig:
        SubnetIds:
          - !Ref AuthorizerSubnet
        SecurityGroupIds:
          - !Ref AuthorizerSg
#---------------------------
#--- VPC Contents
#---------------------------
  VPC:
    Type: AWS::EC2::VPC
    Properties: 
      CidrBlock: !Sub 172.0.0.0/16
      EnableDnsSupport: True
      EnableDnsHostnames: True
      Tags: 
        - Key: Name
          Value: !Sub lambda-vpc
  IGW:
    Type: AWS::EC2::InternetGateway
  IGWAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties: 
      InternetGatewayId: !Ref IGW
      VpcId: !Ref VPC
  #--------
  #--- Subnet
  #--------
  AuthorizerSubnet:
    Type: AWS::EC2::Subnet
    Properties: 
      VpcId: !Ref VPC
      AvailabilityZone: !Sub ${AWS::Region}a
      CidrBlock: 172.0.0.0/24
      Tags: 
        - Key: Name
          Value: lambda-authorizer-subnet-1a
  HelloFunctionSubnet:
    Type: AWS::EC2::Subnet
    Properties: 
      VpcId: !Ref VPC
      AvailabilityZone: !Sub ${AWS::Region}a
      CidrBlock: 172.0.1.0/24
      Tags: 
        - Key: Name
          Value: lambda-hello-subnet-1a
  NatSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Sub ${AWS::Region}a
      CidrBlock: 172.0.128.0/24
      Tags: 
        - Key: Name
          Value: nat-subnet-1a
  #--------
  #--- Route
  #--------
  NatRouteTable:
    Type: AWS::EC2::RouteTable
    Properties: 
      VpcId: !Ref VPC
      Tags: 
        - Key: Name
          Value: nat-subnet-route
  NatRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: 
      RouteTableId: !Ref   NatRouteTable
      SubnetId: !Ref NatSubnet
  ToInternetRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref NatRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref IGW
    DependsOn: IGWAttachment
  AuthorizerRouteTable:
    Type: AWS::EC2::RouteTable
    Properties: 
      VpcId: !Ref VPC
      Tags: 
        - Key: Name
          Value: lambda-authorizer-subnet-route
  AuthorizerRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: 
      RouteTableId: !Ref AuthorizerRouteTable
      SubnetId: !Ref AuthorizerSubnet
  AuthorizerToPublicRoute:
    Type: AWS::EC2::Route
    Properties:
       RouteTableId: !Ref AuthorizerRouteTable
       DestinationCidrBlock: 0.0.0.0/0
       NatGatewayId: !Ref NatGw
  #--------
  #--- Security Group
  #--------
  AuthorizerSg:
    Type: AWS::EC2::SecurityGroup
    Properties: 
      GroupName: lambda-authorizer-sg
      GroupDescription: "-"
      VpcId: !Ref VPC
      Tags: 
        - Key: Name
          Value: !Sub lambda-authorizer-sg
  HelloFunctionSg:
    Type: AWS::EC2::SecurityGroup
    Properties: 
      GroupName: hello-function-sg
      GroupDescription: "-"
      VpcId: !Ref VPC
      Tags: 
        - Key: Name
          Value: !Sub lambda-hello-function-sg
  #--------
  #--- Etc
  #--------
  NatGw:
    Type: AWS::EC2::NatGateway
    Properties: 
      AllocationId: !GetAtt EIP.AllocationId
      SubnetId: !Ref NatSubnet
      Tags: 
      - Key: Name
        Value: lambda-nat-1a
  EIP:
    Type: AWS::EC2::EIP
    Properties:
      Tags:
        - Key: Name
          Value: lambda-nat-eip
  ## 片付けをセットでやる為に疎通先のサーバの許可もこちらで定義しておく
  WebServerIngressFromNat:
    Type: AWS::EC2::SecurityGroupIngress
    Properties: 
      IpProtocol: tcp
      FromPort: 80
      ToPort: 80
      CidrIp: !Sub ${EIP.PublicIp}/32
      GroupId: !Ref WebSgId

API GatewayからLambda呼び出しはInvokeFunctionで行われるのでどちらの関数もセキュリティグループでのインバウンド許可は不要です。

テンプレート上には明示的にしてはないですが認証関数には以下のようなリソースベースのポリシーが割り当てられています。

認証関数

後述するWebサーバ側からjsonを取得して{"result": "success"}の場合のみ許可します。

auth/app.py

import os, urllib.request, json
def lambda_handler(event, context):
    effect = 'Deny'
    try:
        res = urllib.request.urlopen(
            "http://{}/{}.json".format(
                os.environ["AUTH_HOST"],
                event['authorizationToken']
            )
        )
        body = json.loads(res.read())
        if "success" == body["result"]:
            effect = "Allow"
    except:
        pass
    
    return {
        'principalId': 'user-auth',
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [
                {
                    'Action': 'execute-api:Invoke',
                    'Effect': effect,
                    'Resource': [
                        event['methodArn']
                    ]
                }
            ]
        }
    }

HelloWorld関数

Hello Worldのテンプレートのデフォルトのコードです

import json
def lambda_handler(event, context):
    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": "hello world",
        }),
    }

Webサーバ側

以下のようなコンテンツを用意しておきます。

$ tree
.
├── fail-user.json
└── success-user.json

0 directories, 2 files
$ curl http://localhost/fail-user.json
{
    "result": "fail"
}
$ curl http://localhost/success-user.json
{
    "result": "success"
}

デプロイ

通常通りsam deployを実行するだけなので割愛します。

動作確認

# 成功パターン
$ curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/hello  -H "Authorization: success-user"
{"message": "hello world"}%

# 失敗パターン(存在しないファイル)
$ curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/hello  -H "Authorization: hoge-user"
{"Message":"User is not authorized to access this resource with an explicit deny"}%

# 失敗パターン(存在するが認証失敗扱いパターン)
$ curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/hello  -H "Authorization: fail-user"
{"Message":"User is not authorized to access this resource with an explicit deny"}%

# 失敗パターン(Authorizationヘッダ未存在)
$ curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/hello
{"message":"Unauthorized"}%

Webサーバ側のアクセスログも確認しておきます。

52.197.128.xxx - - [28/Jul/2023:10:41:59 +0000] "GET /success-user.json HTTP/1.1" 200 28 "-" "Python-urllib/3.9"
52.197.128.xxx - - [28/Jul/2023:10:42:06 +0000] "GET /hoge-user.json HTTP/1.1" 404 196 "-" "Python-urllib/3.9"
52.197.128.xxx - - [28/Jul/2023:10:42:10 +0000] "GET /fail-user.json HTTP/1.1" 200 25 "-" "Python-urllib/3.9"
# Authorizationヘッダが存在しないパターンAPI Gateway側で弾く為ログなし

想定通り今回作成したNAT Gatewayからアクセスが来ています。

セキュリティグループで制限をかけているので、別の環境からのEC2アクセスはタイムアウトとなります。

% curl http://43.xxx.xxx.xxx/
curl: (28) Failed to connect to 43.xxx.xxx.xxx port 80 after 75004 ms: Couldn't connect to server

終わりに

コンテンツ保護としてIP制限はよく使われる方法ですので非認可アクセスはLambdaまで到達させたくないなぁと思った場合にタイトルだけでもみてできるんだなということを知ってもらえればと思います。

今回はNAT Gatewayに通信を向けていますが、VPCとオンプレ上のネットワークをVPNで繋いでいてそちらに繋ぎたいから等のケースにも対応できそうですね。