How CloudFront solves CORS problems

One domain means easier configuration and better security

Author's image
Tamás Sallai
4 mins

CORS errors

Let's consider a fairly typical serverless setup! There is an S3 bucket containing the static files of the frontend code (SPA, typically) and an API behind an API Gateway. The problem is that they are under different domains, which becomes apparent during the first call to the API:

fetch(<API_URL>)
// or
fetch(<API_URL>, {credentials: "include"})
Access to XMLHttpRequest at '...' from origin '...' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

Since the domains are different, the browser will treat this as a cross-origin request. And that means CORS headers are required on the API side.

The two most important headers are Access-Control-Allow-Origin and Access-Control-Allow-Credentials. The former is required for every cross-origin request, the latter is only when the {credentials: "include"} option is used.

Without the {credentials: "include"} option the credentials, most notably, cookies, are not sent. And since authentication usually depends on cookies, that means the user is anonymous in that case.

But if the credentials are included in the request, the API must respond with the Access-Control-Allow-Credentials: true header. Without that, the browser will throw an error.

Setting the CORS headers is not that hard, but it is still a chore. And since this is a security mechanism to protect your users an error in this process can open up CSRF attacks.

Why CloudFront

How could CloudFront help in this regard?

CORS is only needed for cross-origin requests, which means if the frontend and the backend are on the same domain this problem is non-existent. And this is exactly what CloudFront does.

With a CloudFront distribution, you can set up path-based routing to different backend services called origins. One origin can be the frontend bucket and the other one the API Gateway, then you can map the former to / and the latter to /api. In this case, all requests are same-origin.

With this setup, sending a request to the API is a simple fetch:

fetch("/api/")

This works, the browser sends credentials along with the request and no additional configuration is required on the API-side.

And since there is no CORS configuration, there is no risk of a misconfiguration opening an attack surface.

Setup

CloudFront configuration is divided into backends, called origins, and path mappings, called cache behaviors.

I'll use Terraform to provision a distribution:

resource "aws_cloudfront_distribution" "distribution" {
	# ...
}

Frontend origin

The frontend code is deployed to an S3 bucket which means a special configuration item called s3_origin_config.

origin {
	domain_name = aws_s3_bucket.frontend_bucket.bucket_regional_domain_name
	origin_id   = "s3" # ID

	s3_origin_config {
		origin_access_identity = aws_cloudfront_origin_access_identity.OAI.cloudfront_access_identity_path
	}
}

API origin

Integrating API Gateway to CloudFront can be tricky but the two key points here are to extract the domain name from the invoke_url and to make sure the stage name and the path pattern matches.

origin {
	domain_name = replace(aws_api_gateway_deployment.deployment.invoke_url, "/^https?://([^/]*).*/", "$1")
	origin_id   = "apigw" # ID

	custom_origin_config {
		http_port              = 80
		https_port             = 443
		origin_protocol_policy = "https-only"
		origin_ssl_protocols   = ["TLSv1.2"]
	}
}

Frontend cache behavior

The default_cache_behavior is the path mapping to /, which in our case is the frontend bucket. The target_origin_id maps this to the S3 origin.

default_cache_behavior {
	allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
	cached_methods   = ["GET", "HEAD"]
	target_origin_id = "s3" # Use the s3 origin

	# Don't forward query string or cookies
	forwarded_values {
		query_string = false
		cookies {
			forward = "none"
		}
	}

	viewer_protocol_policy = "redirect-to-https"
}

API cache behavior

And finally, map the API origin to the path /api/*.

ordered_cache_behavior {
	path_pattern     = "/api/*"
	allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
	cached_methods   = ["GET", "HEAD"]
	target_origin_id = "apigw" # Use the apigw origin

	# Disable caching
	default_ttl = 0
	min_ttl     = 0
	max_ttl     = 0

	# Forward everything
	forwarded_values {
		query_string = true
		cookies {
			forward = "all"
		}
	}

	viewer_protocol_policy = "redirect-to-https"
}

CloudFront domain name

The serverless app with the frontend and the API wired together is now available under the CloudFront distribution's domain:

output "frontend_url" {
  value = aws_cloudfront_distribution.distribution.domain_name
}

Conclusion

This is a rare case when using one more service reduces complexity. Cross-origin requests require some careful planning and this is a class of problems that is better to be just avoided altogether. By using one domain only you not only simplify backend configurations but also make sure no attack surface regarding cross-origin configuration opens up.

Which is a good thought to have during the 20-odd minutes of waiting for the CloudFront distribution to deploy.

November 26, 2019
In this article