Skip to content

Front the Function URL with CloudFront and block direct access #2

Description

@jplock

Motivation

The Lambda Function URL is public and unauthenticated (AuthType: NONE) — required today because SNS HTTP delivery cannot sign requests. The in-app boundary (SNS signature verification + topic allowlist) keeps the endpoint safe, but the URL itself:

  • cannot have AWS WAF attached (no rate limiting / IP filtering),
  • cannot use a custom domain,
  • bills a Lambda invocation for every request, including junk POSTs from anyone who discovers the URL.

Fronting the Function URL with CloudFront closes those gaps: WAF attachment point, custom-domain support, and direct-access lockdown. No API Gateway — CloudFront talks to the Function URL directly.

Decision: OAC signing is not viable for this workload

CloudFront's Origin Access Control for Lambda origins (AuthType: AWS_IAM + SigV4-signed origin requests) was considered and rejected. Per the official docs:

If you use PUT or POST methods with your Lambda function URL, your users must compute the SHA256 of the body and include the payload hash value of the request body in the x-amz-content-sha256 header when sending the request to CloudFront. Lambda doesn't support unsigned payloads.

The docs' example computes the hash client-side; CloudFront does not compute it at the edge. Every SNS delivery (Notification, SubscriptionConfirmation, UnsubscribeConfirmation) is a POST, and SNS does not send that header — so every delivery would fail with InvalidSignatureException. CloudFront Functions cannot read the request body to inject the hash, and Lambda@Edge is out of scope by design. This is documented behavior, not a hypothesis — no spike needed. (Background: AWS blog on Lambda OAC.)

If Lambda ever supports unsigned payloads for OAC-signed requests, revisit — AWS_IAM + OAC is the stronger lockdown.

Design: shared-secret origin header

The Function URL stays AuthType: NONE; direct access is blocked by a secret only CloudFront knows:

  • CloudFront distribution with the Function URL as origin: HTTPS only protocol, caching disabled (all traffic is POST), custom origin header X-Origin-Verify: <secret> injected on every origin request.
  • Secret stored in Secrets Manager / SSM and passed to the function via the existing config.rs env-var path.
  • The app rejects requests missing or mismatching the header with 403, in the router layer ahead of VerifiedSns, so junk fails before any signature-verification work.
  • Rotation: the app accepts two values (current + previous) so the CloudFront config and secret can roll without dropping deliveries.
  • WAF attached to the distribution with a rate-based rule.

Tasks:

  • template.yaml: CloudFront distribution + origin header, WAF WebACL, secret resource, new function env var
  • config.rs: origin-verify secret(s) config field
  • Router-layer header check returning 403 (ahead of VerifiedSns)
  • Handler tests: 403 without/with-wrong header, 200 path with header, rotation window (both accepted values)
  • Update stack outputs and scripts/subscribe.sh docs to use the CloudFront domain; re-subscribe existing topics
  • Verify the retry contract passes through unmodified (origin 4xx/5xx reach SNS as-is; no custom error responses remapping status codes)
  • scripts/e2e-ses-bounce.sh passes through the CloudFront endpoint; direct Function URL access without the header is rejected
  • Operations docs: topology change + secret-rotation runbook

Non-goals

  • API Gateway in the path (adds cost and config with no capability CloudFront lacks here).
  • Lambda@Edge (explicitly excluded).
  • Native SNS→Lambda subscription (separate discussion — removes the public endpoint entirely but sacrifices the loose cross-account topic wiring).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions