Skip to content

Commit

Permalink
Simplify server-side-website with the new AllViewerExceptHostHeader p…
Browse files Browse the repository at this point in the history
…olicy
  • Loading branch information
mnapoli committed Feb 23, 2023
1 parent 2858552 commit 2bad9fd
Show file tree
Hide file tree
Showing 3 changed files with 20 additions and 233 deletions.
33 changes: 6 additions & 27 deletions docs/server-side-website.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,34 +335,13 @@ Applications are of course free to catch errors and display custom error pages.

### Forwarded headers

By default, CloudFront is configured to forward the following HTTP headers to the backend running on Lambda:
> **Note**:
>
> In previous Lift versions, the headers forwarded to your Lambda backend were limited. You were able to add new headers via the `forwardedHeaders` option.
>
> [This is no longer the case](https://twitter.com/cristiangraz/status/1628585607479050240): **all headers are now always forwarded**.

- `Accept`
- `Accept-Language`
- `Authorization`
- `Content-Type`
- `Origin`
- `Referer`
- `User-Agent`
- `X-Forwarded-Host`
- `X-Requested-With`

Why only this list? Because CloudFront + API Gateway requires us to define explicitly the list of headers to forward. It isn't possible to forward _all_ headers.

To access more headers from the client (or [from CloudFront](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-cloudfront-headers.html)), you can redefine the list of headers forwarded by CloudFront in `forwardedHeaders`:

```yaml
constructs:
website:
# ...
forwardedHeaders:
- Accept
- Accept-Language
# ...
- X-Custom-Header
```

CloudFront accepts maximum 10 headers.
All headers are forwarded to your Lambda backend. If you used the `forwardedHeaders` option in the past, you can safely remove it.

## Extensions

Expand Down
82 changes: 7 additions & 75 deletions src/constructs/aws/ServerSideWebsite.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import type { CfnBucket } from "aws-cdk-lib/aws-s3";
import { Bucket } from "aws-cdk-lib/aws-s3";
import type { CfnDistribution } from "aws-cdk-lib/aws-cloudfront";
import type { CfnDistribution, IOriginRequestPolicy } from "aws-cdk-lib/aws-cloudfront";
import {
AllowedMethods,
CacheCookieBehavior,
CacheHeaderBehavior,
CachePolicy,
CacheQueryStringBehavior,
Distribution,
FunctionEventType,
HttpVersion,
OriginProtocolPolicy,
OriginRequestCookieBehavior,
OriginRequestHeaderBehavior,
OriginRequestPolicy,
OriginRequestQueryStringBehavior,
ViewerProtocolPolicy,
} from "aws-cdk-lib/aws-cloudfront";
import type { Construct } from "constructs";
Expand Down Expand Up @@ -115,32 +108,12 @@ export class ServerSideWebsite extends AwsConstruct {
removalPolicy: RemovalPolicy.DESTROY,
});

/**
* We create custom "Origin Policy" and "Cache Policy" for the backend.
* "All URL query strings, HTTP headers, and cookies that you include in the cache key (using a cache policy) are automatically included in origin requests. Use the origin request policy to specify the information that you want to include in origin requests, but not include in the cache key."
* https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-origin-requests.html
*/
const backendOriginPolicy = new OriginRequestPolicy(this, "BackendOriginPolicy", {
originRequestPolicyName: `${this.provider.stackName}-${id}`,
comment: `Origin request policy for the ${id} website.`,
cookieBehavior: OriginRequestCookieBehavior.all(),
queryStringBehavior: OriginRequestQueryStringBehavior.all(),
headerBehavior: this.headersToForward(),
});
const backendCachePolicy = new CachePolicy(this, "BackendCachePolicy", {
cachePolicyName: `${this.provider.stackName}-${id}`,
comment: `Cache policy for the ${id} website.`,
// For the backend we disable all caching by default
defaultTtl: Duration.seconds(0),
// Prevent request collapsing by letting CloudFront understand that requests with
// different cookies or query strings are not the same request
// https://github.com/getlift/lift/issues/144#issuecomment-1131578142
queryStringBehavior: CacheQueryStringBehavior.all(),
cookieBehavior: CacheCookieBehavior.all(),
// Authorization is an exception and must be whitelisted in the Cache Policy
// This is the reason why we don't use the managed `CachePolicy.CACHING_DISABLED`
headerBehavior: CacheHeaderBehavior.allowList("Authorization"),
});
// https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-except-host-header
// It is not supported by the AWS CDK yet
const backendOriginPolicy = new (class implements IOriginRequestPolicy {
public readonly originRequestPolicyId = "b689b0a8-53d0-40ab-baf2-68738e2966ac";
})();
const backendCachePolicy = CachePolicy.CACHING_DISABLED;

const apiId =
configuration.apiGateway === "rest"
Expand Down Expand Up @@ -373,47 +346,6 @@ export class ServerSideWebsite extends AwsConstruct {
return typeof this.configuration.domain === "string" ? this.configuration.domain : this.configuration.domain[0];
}

private headersToForward(): OriginRequestHeaderBehavior {
let additionalHeadersToForward = this.configuration.forwardedHeaders ?? [];
if (additionalHeadersToForward.includes("Host")) {
throw new ServerlessError(
`Invalid value in 'constructs.${this.id}.forwardedHeaders': the 'Host' header cannot be forwarded (this is an API Gateway limitation). Use the 'X-Forwarded-Host' header in your code instead (it contains the value of the original 'Host' header).`,
"LIFT_INVALID_CONSTRUCT_CONFIGURATION"
);
}
// `Authorization` cannot be forwarded via this setting (we automatically forward it anyway so we remove it from the list)
additionalHeadersToForward = additionalHeadersToForward.filter((header: string) => header !== "Authorization");
if (additionalHeadersToForward.length > 0) {
if (additionalHeadersToForward.length > 10) {
throw new ServerlessError(
`Invalid value in 'constructs.${this.id}.forwardedHeaders': ${additionalHeadersToForward.length} headers are configured but only 10 headers can be forwarded (this is an CloudFront limitation).`,
"LIFT_INVALID_CONSTRUCT_CONFIGURATION"
);
}

// Custom list
return OriginRequestHeaderBehavior.allowList(...additionalHeadersToForward);
}

/**
* We forward everything except:
* - `Host` because it messes up API Gateway (that uses the Host to identify which API Gateway to invoke)
* - `Authorization` because it must be configured on the cache policy
* (see https://aws.amazon.com/premiumsupport/knowledge-center/cloudfront-authorization-header/?nc1=h_ls)
*/
return OriginRequestHeaderBehavior.allowList(
"Accept",
"Accept-Language",
"Content-Type",
"Origin",
"Referer",
"User-Agent",
"X-Requested-With",
// This header is set by our CloudFront Function
"X-Forwarded-Host"
);
}

private createCacheBehaviors(bucket: Bucket): Record<string, BehaviorOptions> {
const behaviors: Record<string, BehaviorOptions> = {};
for (const pattern of Object.keys(this.getAssetPatterns())) {
Expand Down
138 changes: 7 additions & 131 deletions test/unit/serverSideWebsite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,12 @@ describe("server-side website", () => {
const cfDistributionLogicalId = computeLogicalId("backend", "CDN");
const cfOriginId1 = computeLogicalId("backend", "CDN", "Origin1");
const cfOriginId2 = computeLogicalId("backend", "CDN", "Origin2");
const originPolicyId = computeLogicalId("backend", "BackendOriginPolicy");
const cachePolicyId = computeLogicalId("backend", "BackendCachePolicy");
const requestFunction = computeLogicalId("backend", "RequestFunction");
expect(Object.keys(cfTemplate.Resources)).toStrictEqual([
"ServerlessDeploymentBucket",
"ServerlessDeploymentBucketPolicy",
bucketLogicalId,
bucketPolicyLogicalId,
originPolicyId,
cachePolicyId,
requestFunction,
originAccessIdentityLogicalId,
cfDistributionLogicalId,
Expand Down Expand Up @@ -88,8 +84,8 @@ describe("server-side website", () => {
DefaultCacheBehavior: {
AllowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "PATCH", "POST", "DELETE"],
Compress: true,
CachePolicyId: { Ref: cachePolicyId },
OriginRequestPolicyId: { Ref: originPolicyId },
CachePolicyId: "4135ea2d-6df8-44a3-9df3-4b5a84be39ad",
OriginRequestPolicyId: "b689b0a8-53d0-40ab-baf2-68738e2966ac",
TargetOriginId: cfOriginId1,
ViewerProtocolPolicy: "redirect-to-https",
FunctionAssociations: [
Expand Down Expand Up @@ -141,52 +137,6 @@ describe("server-side website", () => {
},
},
});
expect(cfTemplate.Resources[originPolicyId]).toStrictEqual({
Type: "AWS::CloudFront::OriginRequestPolicy",
Properties: {
OriginRequestPolicyConfig: {
Name: "app-dev-backend",
Comment: "Origin request policy for the backend website.",
CookiesConfig: { CookieBehavior: "all" },
QueryStringsConfig: { QueryStringBehavior: "all" },
HeadersConfig: {
HeaderBehavior: "whitelist",
Headers: [
"Accept",
"Accept-Language",
"Content-Type",
"Origin",
"Referer",
"User-Agent",
"X-Requested-With",
"X-Forwarded-Host",
],
},
},
},
});
expect(cfTemplate.Resources[cachePolicyId]).toStrictEqual({
Type: "AWS::CloudFront::CachePolicy",
Properties: {
CachePolicyConfig: {
Comment: "Cache policy for the backend website.",
DefaultTTL: 0,
MaxTTL: 31536000,
MinTTL: 0,
Name: "app-dev-backend",
ParametersInCacheKeyAndForwardedToOrigin: {
CookiesConfig: { CookieBehavior: "all" },
QueryStringsConfig: { QueryStringBehavior: "all" },
HeadersConfig: {
HeaderBehavior: "whitelist",
Headers: ["Authorization"],
},
EnableAcceptEncodingBrotli: false,
EnableAcceptEncodingGzip: false,
},
},
},
});
expect(cfTemplate.Resources[requestFunction]).toMatchObject({
Type: "AWS::CloudFront::Function",
Properties: {
Expand Down Expand Up @@ -232,15 +182,11 @@ describe("server-side website", () => {
const bucketLogicalId = computeLogicalId("backend", "Assets");
const cfDistributionLogicalId = computeLogicalId("backend", "CDN");
const cfOriginId1 = computeLogicalId("backend", "CDN", "Origin1");
const originPolicyId = computeLogicalId("backend", "BackendOriginPolicy");
const cachePolicyId = computeLogicalId("backend", "BackendCachePolicy");
const requestFunction = computeLogicalId("backend", "RequestFunction");
expect(Object.keys(cfTemplate.Resources)).toStrictEqual([
"ServerlessDeploymentBucket",
"ServerlessDeploymentBucketPolicy",
bucketLogicalId,
originPolicyId,
cachePolicyId,
requestFunction,
cfDistributionLogicalId,
]);
Expand All @@ -256,8 +202,8 @@ describe("server-side website", () => {
DefaultCacheBehavior: {
AllowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "PATCH", "POST", "DELETE"],
Compress: true,
CachePolicyId: { Ref: cachePolicyId },
OriginRequestPolicyId: { Ref: originPolicyId },
CachePolicyId: "4135ea2d-6df8-44a3-9df3-4b5a84be39ad",
OriginRequestPolicyId: "b689b0a8-53d0-40ab-baf2-68738e2966ac",
TargetOriginId: cfOriginId1,
ViewerProtocolPolicy: "redirect-to-https",
FunctionAssociations: [
Expand Down Expand Up @@ -521,88 +467,18 @@ describe("server-side website", () => {
});
});

it("should allow to override the forwarded headers", async () => {
const { cfTemplate, computeLogicalId } = await runServerless({
it("should not error if 'forwardedHeaders' are configured", async () => {
return runServerless({
command: "package",
config: Object.assign(baseConfig, {
constructs: {
backend: {
type: "server-side-website",
forwardedHeaders: ["X-My-Custom-Header", "X-My-Other-Custom-Header"],
forwardedHeaders: ["foo", "bar"],
},
},
}),
});
expect(cfTemplate.Resources[computeLogicalId("backend", "BackendOriginPolicy")]).toMatchObject({
Properties: {
OriginRequestPolicyConfig: {
HeadersConfig: {
HeaderBehavior: "whitelist",
Headers: ["X-My-Custom-Header", "X-My-Other-Custom-Header"],
},
},
},
});
});

it("should not forward the Authorization header in the Origin Policy", async () => {
const { cfTemplate, computeLogicalId } = await runServerless({
command: "package",
config: Object.assign(baseConfig, {
constructs: {
backend: {
type: "server-side-website",
forwardedHeaders: ["Authorization", "X-My-Custom-Header"],
},
},
}),
});
expect(cfTemplate.Resources[computeLogicalId("backend", "BackendOriginPolicy")]).toMatchObject({
Properties: {
OriginRequestPolicyConfig: {
HeadersConfig: {
// Should not contain "Authorization"
Headers: ["X-My-Custom-Header"],
},
},
},
});
});

it("should forbid to force forwarding the Host header", async () => {
await expect(() => {
return runServerless({
command: "package",
config: Object.assign(baseConfig, {
constructs: {
backend: {
type: "server-side-website",
forwardedHeaders: ["Host"],
},
},
}),
});
}).rejects.toThrowError(
"Invalid value in 'constructs.backend.forwardedHeaders': the 'Host' header cannot be forwarded (this is an API Gateway limitation). Use the 'X-Forwarded-Host' header in your code instead (it contains the value of the original 'Host' header)."
);
});

it("should error if more than 10 headers are configured", async () => {
await expect(() => {
return runServerless({
command: "package",
config: Object.assign(baseConfig, {
constructs: {
backend: {
type: "server-side-website",
forwardedHeaders: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"],
},
},
}),
});
}).rejects.toThrowError(
"Invalid value in 'constructs.backend.forwardedHeaders': 11 headers are configured but only 10 headers can be forwarded (this is an CloudFront limitation)."
);
});

it("should synchronize assets to S3", async () => {
Expand Down

0 comments on commit 2bad9fd

Please sign in to comment.