├── .gitignore ├── .npmignore ├── README.md ├── bin └── cloudfront-reverse-proxy-apigw.ts ├── cdk.json ├── cf_proxy_image.png ├── lib └── cloudfront-reverse-proxy-apigw-stack.ts ├── package.json ├── src ├── backend │ └── lambda │ │ ├── api-auth │ │ └── index.js │ │ └── api │ │ └── index.js └── frontend │ ├── dist │ ├── index.html │ └── index.js │ └── src │ ├── index.html │ ├── index.js │ └── package.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | bin/*.js 2 | lib/*.js 3 | !jest.config.js 4 | *.d.ts 5 | node_modules 6 | 7 | # CDK asset staging directory 8 | .cdk.staging 9 | cdk.out 10 | 11 | # Parcel default cache directory 12 | .parcel-cache 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudFront reverse proxy API Gateway to prevent CORS 2 | 3 | In this blog we will do a quick recap of CORS and reverse proxies. Then we will show how a reverse proxy can eliminate 4 | CORS, specifically in the context of a SPA hosted on CloudFront with an API Gateway backend. The sample code focuses 5 | on public, authenticated routes (Authorization header) and IAM signed request all being reverse proxied through 6 | CloudFront. Everything is done with the AWS CDK... continue reading the accompanying blog post 7 | here => https://www.rehanvdm.com/serverless/cloudfront-reverse-proxy-api-gateway-to-prevent-cors/index.html 8 | 9 |  10 | 11 | This is a stock standard CDK project using TypeScript. 12 | 13 | ### Prerequisites: 14 | 1. AWS IAM profile setup in `..\.aws\credentials`, with Admin rights. 15 | 3. AWS CDK bootstrap must have been run in the account already. 16 | 17 | ### Up and running 18 | * Run `npm install` 19 | * Change directory to ***/src/frontend/src*** and run `npm install` 20 | * Create an ACM certificate and Hosted zone. These are only needed to use and test the API Gateway Custom domain 21 | * Search and replace `rehan` with your AWS profile name 22 | * Replace the CDK context variables values in the package.json file, both `custDomainCertArn` and `custDomainName` 23 | with the values of resources you created above 24 | 25 | ### Useful commands 26 | * `cdk diff` compare deployed stack with current state 27 | * `cdk deploy` deploy this stack 28 | 29 | ### Other 30 | You can navigate to the frontend under /src/frontend/dist to view the basic HTML page used to make requests. -------------------------------------------------------------------------------- /bin/cloudfront-reverse-proxy-apigw.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from '@aws-cdk/core'; 4 | import { CloudfrontReverseProxyApigwStack } from '../lib/cloudfront-reverse-proxy-apigw-stack'; 5 | 6 | 7 | const app = new cdk.App(); 8 | new CloudfrontReverseProxyApigwStack(app, 'cloudfront-reverse-proxy-apigw'); 9 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node bin/cloudfront-reverse-proxy-apigw.ts", 3 | "context": { 4 | "@aws-cdk/core:enableStackNameDuplicates": "true", 5 | "aws-cdk:enableDiffNoFail": "true", 6 | "@aws-cdk/core:stackRelativeExports": "true" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /cf_proxy_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rehanvdm/cloudfront-reverse-proxy-apigw/fefc0f0f66d1d50cb4ad98f8f37a8247c0437afb/cf_proxy_image.png -------------------------------------------------------------------------------- /lib/cloudfront-reverse-proxy-apigw-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from '@aws-cdk/core'; 2 | import * as cloudfront from '@aws-cdk/aws-cloudfront'; 3 | import * as s3 from '@aws-cdk/aws-s3'; 4 | import * as s3deploy from '@aws-cdk/aws-s3-deployment'; 5 | import * as apigateway from '@aws-cdk/aws-apigateway'; 6 | import * as lambda from '@aws-cdk/aws-lambda'; 7 | import * as cert from '@aws-cdk/aws-certificatemanager'; 8 | 9 | export class CloudfrontReverseProxyApigwStack extends cdk.Stack { 10 | constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { 11 | super(scope, id, props); 12 | 13 | const allowCors = true; /* Set to false for production, no longer needed */ 14 | 15 | let defaultCorsOptions: apigateway.CorsOptions | undefined = undefined; 16 | if(allowCors) 17 | { 18 | defaultCorsOptions = { 19 | allowOrigins: apigateway.Cors.ALL_ORIGINS, 20 | allowMethods: apigateway.Cors.ALL_METHODS, 21 | allowCredentials: true, 22 | allowHeaders: ["*"], 23 | maxAge: cdk.Duration.days(1) 24 | }; 25 | } 26 | 27 | const name = (resourceName: string) => { 28 | return id + "-" + resourceName; 29 | }; 30 | 31 | const custDomainCertArn: string = this.node.tryGetContext('custDomainCertArn'); 32 | const custDomainName: string = this.node.tryGetContext('custDomainName'); 33 | const customDomainMappingPath = "default"; 34 | const cloudFrontApiGwPath = "cf-apigw"; 35 | const cloudFrontCustDomainPath = "cf-cust-domain"; 36 | const apiStageName = "prod"; 37 | const api = new apigateway.RestApi(this, name("rest-api"), { 38 | restApiName: name("rest-api"), 39 | deployOptions: { 40 | stageName: apiStageName, 41 | } 42 | }); 43 | 44 | const apiLambda = new lambda.Function(this, name("api-lambda"), { 45 | functionName: name("api-lambda"), 46 | code: new lambda.AssetCode('./src/backend/lambda/api'), 47 | handler: 'index.handler', 48 | runtime: lambda.Runtime.NODEJS_12_X, 49 | timeout: cdk.Duration.seconds(25), 50 | }); 51 | const apiLambdaAuth = new lambda.Function(this, name("api-lambda-auth"), { 52 | functionName: name("api-lambda-auth"), 53 | code: new lambda.AssetCode('./src/backend/lambda/api-auth'), 54 | handler: 'index.handler', 55 | runtime: lambda.Runtime.NODEJS_12_X, 56 | timeout: cdk.Duration.seconds(25), 57 | }); 58 | 59 | /* Add default non protected route that will catch all if no path exists. */ 60 | /* ANY /* */ 61 | api.root.addProxy({ 62 | defaultIntegration: new apigateway.LambdaIntegration(apiLambda), 63 | defaultCorsPreflightOptions: defaultCorsOptions, 64 | anyMethod: true, 65 | }); 66 | 67 | 68 | /* --------------------------- Paths used for the CloudFront to APIGW proxy ------------------------------------- */ 69 | /* Creates a top level resource that is required because CloudFront prepends the path with the `pathPattern` when forwarding */ 70 | let cloudFrontApiGwResource = api.root.addResource(cloudFrontApiGwPath, { defaultCorsPreflightOptions: defaultCorsOptions }); 71 | 72 | /* Add specific route for Custom Lambda Token Authorizer which requires the Authorization header */ 73 | /* GET /cf-apigw/auth */ 74 | let cfApiGwAuthResource = cloudFrontApiGwResource.addResource("auth", { defaultCorsPreflightOptions: defaultCorsOptions }); 75 | cfApiGwAuthResource.addMethod("GET", new apigateway.LambdaIntegration(apiLambda), { 76 | authorizationType: apigateway.AuthorizationType.CUSTOM, 77 | authorizer: new apigateway.TokenAuthorizer(this, name("api-apigw-auth"), { handler: apiLambdaAuth }) 78 | }); 79 | 80 | /* Add specific route for IAM Authorization which requires the Authorization header */ 81 | /* GET /cf-apigw/auth-iam */ 82 | let cfApiGwAuthIamResource = cloudFrontApiGwResource.addResource("auth-iam", { defaultCorsPreflightOptions: defaultCorsOptions }); 83 | cfApiGwAuthIamResource.addMethod("GET", new apigateway.LambdaIntegration(apiLambda), { 84 | authorizationType: apigateway.AuthorizationType.IAM, 85 | }); 86 | /* -------------------------------------------------------------------------------------------------------------- */ 87 | 88 | 89 | /* ------------------------ Paths used for the CloudFront to Custom Domain proxy --------------------------------- */ 90 | /* Creates a top level resource that is required because CloudFront prepends the path with the `pathPattern` when forwarding */ 91 | let cloudFrontCustDomainResource = api.root.addResource(cloudFrontCustDomainPath, { defaultCorsPreflightOptions: defaultCorsOptions }); 92 | 93 | /* Add specific route for Custom Lambda Token Authorizer which requires the Authorization header */ 94 | /* GET /cf-cust-domain/auth */ 95 | let cfCustDomainAuthResource = cloudFrontCustDomainResource.addResource("auth", { defaultCorsPreflightOptions: defaultCorsOptions }); 96 | cfCustDomainAuthResource.addMethod("GET", new apigateway.LambdaIntegration(apiLambda), { 97 | authorizationType: apigateway.AuthorizationType.CUSTOM, 98 | authorizer: new apigateway.TokenAuthorizer(this, name("api-cust-domain-auth"), { handler: apiLambdaAuth }) 99 | }); 100 | 101 | /* Add specific route for IAM Authorization which requires the Authorization header */ 102 | /* GET /cf-cust-domain/auth-iam */ 103 | let cfCustDomainAuthIamResource = cloudFrontCustDomainResource.addResource("auth-iam", { defaultCorsPreflightOptions: defaultCorsOptions }); 104 | cfCustDomainAuthIamResource.addMethod("GET", new apigateway.LambdaIntegration(apiLambda), { 105 | authorizationType: apigateway.AuthorizationType.IAM, 106 | }); 107 | /* -------------------------------------------------------------------------------------------------------------- */ 108 | 109 | 110 | let originAccessIdentity = new cloudfront.OriginAccessIdentity(this, 'OAI', { comment: id }); 111 | const siteBucket = new s3.Bucket(this, name('web-bucket'), { 112 | bucketName: name('web-bucket'), 113 | websiteIndexDocument: 'index.html', 114 | websiteErrorDocument: 'error.html', 115 | removalPolicy: cdk.RemovalPolicy.DESTROY 116 | }); 117 | siteBucket.grantRead(originAccessIdentity); 118 | 119 | const distribution = new cloudfront.CloudFrontWebDistribution(this, name("web-dist"), { 120 | comment: name("web-dist"), 121 | originConfigs: [ 122 | { 123 | s3OriginSource: { 124 | s3BucketSource: siteBucket, 125 | originAccessIdentity: originAccessIdentity 126 | }, 127 | behaviors : [ {isDefaultBehavior: true} ], 128 | }, 129 | /* --------------------------------- Reverse proxy path to API GW ------------------------------------------- */ 130 | { 131 | behaviors: [{ 132 | pathPattern: "/"+cloudFrontApiGwPath+"/*", 133 | allowedMethods: cloudfront.CloudFrontAllowedMethods.ALL, 134 | maxTtl: cdk.Duration.seconds(0), 135 | minTtl: cdk.Duration.seconds(0), 136 | defaultTtl: cdk.Duration.seconds(0), 137 | compress: false, 138 | forwardedValues: { 139 | queryString: true, 140 | headers: ["Authorization"] 141 | } 142 | }], 143 | customOriginSource: { 144 | /* domainName: api.url.replace('https://', '') 145 | Won't resolve at runtime, only after the stack is deployed. To get around this use multiple stacks and pass the 146 | output from the api(backend) stack to the frontend stack. Just keeping one now to reduce complexity. */ 147 | 148 | /* Replace manually in the AWS Console after deploy with the API GW domain 149 | example: "xxxxxxxxxx.execute-api.us-east-1.amazonaws.com"*/ 150 | domainName: "repalce.after-deployment-with-apigw-domain.com", 151 | originPath: "/"+apiStageName, 152 | originProtocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY, 153 | } 154 | }, 155 | /* --------------------------- Reverse proxy path to API GW Custom Domain ------------------------------------ */ 156 | { 157 | behaviors: [{ 158 | pathPattern: "/"+cloudFrontCustDomainPath+"/*", 159 | allowedMethods: cloudfront.CloudFrontAllowedMethods.ALL, 160 | maxTtl: cdk.Duration.seconds(0), 161 | minTtl: cdk.Duration.seconds(0), 162 | defaultTtl: cdk.Duration.seconds(0), 163 | compress: false, 164 | forwardedValues: { 165 | queryString: true, 166 | headers: ["Authorization"] 167 | } 168 | }], 169 | customOriginSource: { 170 | domainName: custDomainName, 171 | originPath: "/"+customDomainMappingPath, 172 | originProtocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY, 173 | } 174 | } 175 | ] 176 | }); 177 | 178 | new s3deploy.BucketDeployment(this, name('deploy-with-invalidation'), { 179 | sources: [ s3deploy.Source.asset("./src/frontend/dist") ], 180 | destinationBucket: siteBucket, 181 | distribution, 182 | distributionPaths: ['/*'] 183 | }); 184 | 185 | /* Remember to replace these with your own domain and change the value for custDomainCertArn and custDomainCertArn 186 | values in the package.json that gets passed as context variables */ 187 | const apiGwDomain = new apigateway.DomainName(this, name('cust-domain'), { 188 | domainName: custDomainName, 189 | certificate: cert.Certificate.fromCertificateArn(this, name('cust-domain-cert'), custDomainCertArn), 190 | endpointType: apigateway.EndpointType.EDGE, 191 | securityPolicy: apigateway.SecurityPolicy.TLS_1_2 192 | }); 193 | apiGwDomain.addBasePathMapping(api, { basePath: customDomainMappingPath, stage: api.deploymentStage}); 194 | 195 | /* TODO: After deployment copy the API Gateway domain name from the AWS console of the Custom domain name 196 | * and create a cname record that points to `custDomainName` */ 197 | 198 | new cdk.CfnOutput(this, name("output-apigw-endpoint"), { value: api.url + "/" + apiStageName }); 199 | new cdk.CfnOutput(this, name("output-apigw-custom-domain-endpoint"), { value: apiGwDomain.domainName + "/" + customDomainMappingPath }); 200 | new cdk.CfnOutput(this, name("output-cloudfront-endpoint"), { value: distribution.distributionDomainName }); 201 | } 202 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudfront-reverse-proxy-apigw", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cloudfront-reverse-proxy-apigw": "bin/cloudfront-reverse-proxy-apigw.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "cdk": "cdk", 11 | "build-frontends": "npm --prefix ./src/frontend/src run build", 12 | "deploy": "npm run build-frontends && tsc && cdk deploy --profile rehan --context custDomainCertArn=arn:aws:acm:us-east-1:XXXXXX --context custDomainName=YYYYYY", 13 | "diff": "tsc && cdk diff --profile rehan --context custDomainCertArn=XXXXX --context custDomainName=YYYYYY" 14 | }, 15 | "devDependencies": { 16 | "@aws-cdk/assert": "1.62.0", 17 | "@types/jest": "^26.0.10", 18 | "@types/node": "10.17.27", 19 | "jest": "^26.4.2", 20 | "ts-jest": "^26.2.0", 21 | "aws-cdk": "1.62.0", 22 | "ts-node": "^8.1.0", 23 | "typescript": "~3.9.7" 24 | }, 25 | "dependencies": { 26 | "@aws-cdk/aws-certificatemanager": "1.62.0", 27 | "@aws-cdk/aws-apigateway": "1.62.0", 28 | "@aws-cdk/aws-lambda": "1.62.0", 29 | "@aws-cdk/aws-lambda-event-sources": "1.62.0", 30 | "@aws-cdk/aws-cloudfront": "1.62.0", 31 | "@aws-cdk/aws-s3": "1.62.0", 32 | "@aws-cdk/aws-s3-deployment": "1.62.0", 33 | "@aws-cdk/core": "1.62.0", 34 | "source-map-support": "^0.5.16" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/backend/lambda/api-auth/index.js: -------------------------------------------------------------------------------- 1 | /* Taken as is from: https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html */ 2 | 3 | exports.handler = function(event, context, callback) { 4 | var token = event.authorizationToken; 5 | switch (token) { 6 | case 'allow': 7 | callback(null, generatePolicy('user', 'Allow', event.methodArn)); 8 | break; 9 | case 'deny': 10 | callback(null, generatePolicy('user', 'Deny', event.methodArn)); 11 | break; 12 | case 'unauthorized': 13 | callback("Unauthorized"); // Return a 401 Unauthorized response 14 | break; 15 | default: 16 | callback("Error: Invalid token"); // Return a 500 Invalid token response 17 | } 18 | }; 19 | 20 | // Help function to generate an IAM policy 21 | var generatePolicy = function(principalId, effect, resource) { 22 | var authResponse = {}; 23 | 24 | authResponse.principalId = principalId; 25 | if (effect && resource) { 26 | var policyDocument = {}; 27 | policyDocument.Version = '2012-10-17'; 28 | policyDocument.Statement = []; 29 | var statementOne = {}; 30 | statementOne.Action = 'execute-api:Invoke'; 31 | statementOne.Effect = effect; 32 | statementOne.Resource = resource; 33 | policyDocument.Statement[0] = statementOne; 34 | authResponse.policyDocument = policyDocument; 35 | } 36 | 37 | // Optional output with custom properties of the String, Number or Boolean type. 38 | authResponse.context = { 39 | "stringKey": "stringval", 40 | "numberKey": 123, 41 | "booleanKey": true 42 | }; 43 | return authResponse; 44 | } -------------------------------------------------------------------------------- /src/backend/lambda/api/index.js: -------------------------------------------------------------------------------- 1 | exports.handler = async (event, context) => 2 | { 3 | return { 4 | statusCode: 200, 5 | body: JSON.stringify(event), 6 | headers: { 7 | "Content-Type": "application/json", 8 | "Access-Control-Allow-Origin": "*", 9 | "Access-Control-Allow-Methods": "*", 10 | "Access-Control-Allow-Headers": "*" 11 | }, 12 | isBase64Encoded: false 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/frontend/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |? The API GW signing variables will be used for the Cloudfront API GW Mapping and similar logic for the Custom Domain.
25 |? You can get these values using the CLI command below. Replace 'rehan' with your profile name. Token valid for 12 hours by default.
32 |33 | aws --profile rehan sts get-session-token 34 |35 |
39 | After filling the parameters above, open the Network tab of the browser and hit Do Requests to verify that NO OPTION 40 | calls where made for the API calls. You can also verify the response to see the event that the Lambda function received. 41 |
42 | 43 | 44 | 45 | 46 | 47 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /src/frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |? The API GW signing variables will be used for the Cloudfront API GW Mapping and similar logic for the Custom Domain.
25 |? You can get these values using the CLI command below. Replace 'rehan' with your profile name. Token valid for 12 hours by default.
32 |33 | aws --profile rehan sts get-session-token 34 |35 |
39 | After filling the parameters above, open the Network tab of the browser and hit Do Requests to verify that NO OPTION 40 | calls where made for the API calls. You can also verify the response to see the event that the Lambda function received. 41 |
42 | 43 | 44 | 45 | 46 | 47 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /src/frontend/src/index.js: -------------------------------------------------------------------------------- 1 | var aws4 = require('aws4'); 2 | 3 | module.exports = { 4 | doRequests: function(cloudFrontDomain, cloudFrontApiProxyPath, cloudFrontCustDomainProxyPath, 5 | apiGwDomain, apiGwStagePath, apiGwCustDomain, apiGwCustMapping, accessKeyId, secretAccessKey, sessionToken) 6 | { 7 | const region = "us-east-1"; 8 | 9 | async function request(url, resource, method, body, headers = {}) 10 | { 11 | let requestUrl = `${url}${resource}`; 12 | 13 | console.log("Fetch options:", { 14 | url: requestUrl, 15 | method: method, 16 | headers: headers, 17 | body: body 18 | }); 19 | 20 | let response = await fetch(requestUrl, { 21 | method: method, 22 | headers: headers, 23 | body: body 24 | }); 25 | 26 | console.log(response); 27 | } 28 | 29 | /** 30 | * 31 | * @param url 32 | * @param resource 33 | * @param method 34 | * @param body 35 | * @param headers 36 | * @param signHost This is the API GW domain name |OR| the Custom Domain 37 | * @param signHostPath If signHost is the API GW then this is the Stage. If signHost is a Custom Domain then this is the Mapping path. 38 | * @returns {Promise