Securing Lambda URLs with Signed Requests

Limiting access to AWS Lambda Function URLs with IAM authorization.

using a Lambda@Edge function to sign requests between a CloudFront distribution and a Lambda function URL.  Direct requests to the Lambda function URL are blocked.

Lambda function URLs are an excellent means of attaching a Lambda function to a CloudFront distribution as a custom origin, with the caveat that the URL must be publicly accessible over the internet. Fortunately, Lambda URLs have the capability for IAM role-based authorization, meaning you can restrict access to specific resources such that everyone else calling the URL gets a 401 response. Failure to implement this means that your Lambda now has two open endpoints: the CloudFront distribution itself and the unsecured function URL, which now acts like a backdoor, circumventing any security measures you might have in place.

Role-Based Authorization

The authorization will consist of specific headers injected into the origin request. This is done with a Lambda@Edge function associated with the CloudFront distribution's origin request event. Thus, we begin with a standard IAM role that enables CloudFront execution, and then we attach an additional role that allows it to generate signatures for the origin Lambda's URL.

# Creates a role that allows an authorizing lambda@edge function
# to invoke another lambda function's secured URL.

resource "random_id" "cd_function_suffix" {
    byte_length = 2
}

# This policy document allows the lambda function to be invoked by cloudfront.
data "aws_iam_policy_document" "edge_lambda_execution_role" {
    statement {
    effect = "Allow"
    principals {
        type        = "Service"
        identifiers = [
            "lambda.amazonaws.com",
            "edgelambda.amazonaws.com"
        ]
    }
        actions = ["sts:AssumeRole"]
    }
}

#  We will pass this role to the authorizing edge function
resource "aws_iam_role" "signed_auth_iam_role" {
    name = "edge-lambda-exe-${random_id.cd_function_suffix.hex}"
    assume_role_policy = data.aws_iam_policy_document.edge_lambda_execution_role.json
}

resource "aws_lambda_permission" "allow_cloudfront_execution" {
    function_name = module.signed_authentication_lambda.name
    statement_id  = "allow-cf-exec-${random_id.cd_function_suffix.hex}"
    action        = "lambda:GetFunction"
    principal     = "edgelambda.amazonaws.com"
}

# gives permission to invoke the secured function.
resource "aws_iam_policy" "lambda_invoke_function_policy" {
  name = "invoke-function-url-policy-${random_id.cd_function_suffix.hex}"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
            "lambda:InvokeFunctionUrl"
        ]
        Resource = [
            # This is the secure lambda function
            module.api_lambda.arn
        ]
      }
    ]
  })
}

# Attaches the secure invocation to the authorizer's role
resource "aws_iam_role_policy_attachment" "lambda_invoke_signed_function_attachment" {
  role       = aws_iam_role.signed_auth_iam_role.name
  policy_arn = aws_iam_policy.lambda_invoke_function_policy.arn
}

Generating a Signed Request

At this stage, we have a Lambda@Edge function authorized to generate signed headers for origin requests. We must now develop the code responsible for manually injecting the signed headers. Amazon's own example code was used for this stage: The process is as follows:

  1. Build a new request to sign using the function's incoming request.
  2. Sign the new request using Lambda's credentials, which were granted in step 1.
  3. Return the signed request.
/** The goal of the authorizing function is to inject cryptographically
signed headers into the existing CloudFront origin request.
This authorizing function has been granted explicit permissions to
invoke the secured function.
*/


import { SignatureV4 } from '@aws-sdk/signature-v4';

import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { HttpRequest } from "@aws-sdk/protocol-http";
const { createHash, createHmac } = await import('node:crypto');

function Sha256(secret) {
    return secret ? createHmac('sha256', secret) : createHash('sha256');
}

const credentialProvider = fromNodeProviderChain();
const credentials = await credentialProvider();

export const handler = async(event) => {

    const request = event.Records[0].cf.request;

    // remove the x-forwarded-for from the signature
    let headers = request.headers;
    delete headers['x-forwarded-for'];

    if (!request.origin.hasOwnProperty('custom'))
        throw("Unexpected origin type. Expected 'custom'. Got: " + JSON.stringify(request.origin));

    const uri = request.uri;
    const hostname = request.headers['host'][0].value;
    const region = hostname.split(".")[2];
    const path = uri + (request.querystring ? '?'+ request.querystring : '');
    const decodedBody = (request.body && request.body.data) ? Buffer.from(request.body.data, request.body.encoding) : undefined

    // [1] build the request to sign.
    // Its based on the incoming request from CloudFront.
    const req = new HttpRequest({
        hostname,
        path,
        body: decodedBody,
        method: request.method,
    });

    // (cloudfront header manipulation)
    for (const header of Object.values(headers)) {
        req.headers[header[0].key] = header[0].value;
    }

    // [2] sign the request with _this_ function's credentials
    // This works because this lambda function has the necessary
    // policy explicitly naming the secured function by its arn.
    const signer = new SignatureV4({
        credentials,
        region,
        service: 'lambda',
        sha256: Sha256,
    });

    const signedRequest = await signer.sign(req);

    // (more cloudfront header manipulation)
    for (const header in signedRequest.headers){
        request.headers[header.toLowerCase()] = [{
            key: header,
            value: signedRequest.headers[header].toString(),
        }];
    }

    // [3] return the signed request.
    // this will be passed to the origin (secure lambda function).
    return request;
}

The setup is ready for testing once the handler code is deployed to the Lambda function.

# An example of calling a secured Lambda URL without authorization.
# All requests must go through a CloudFront distribution since
# only it has the necessary permissions.


curl https://4oon7yb6wquqo37r4mo4x6ahcy0dppzq.lambda-url.us-east-1.on.aws/api/items/gol

# {"Message":"Forbidden"} 

curl https://d14g3vtvgqzrd8.cloudfront.net/api/items/gol

# {"targetUrl":"https://apps.ben.website/game-of-life/","id":"gol","dateUploaded":"2024-04-12T16:53:00.172Z"}

Conclusion

The overall footprint of the added resources was minimal, and the work to integrate this into an existing application was simple. The complete code for this project is available at projects / Scratchpad / Authenticated Lambda Endpoint · GitLab.