Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Remove CloudFront functions #406

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions docs/single-page-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,18 +224,29 @@ constructs:

The first domain in the list will be considered the main domain. In this case, `mywebsite.com` will redirect to `www.mywebsite.com`.

### Allow iframes
### Response headers policy

By default, as recommended [for security reasons](https://scotthelme.co.uk/hardening-your-http-response-headers/#x-frame-options), the single page application cannot be embedded in an iframe.
By default, the response headers policy is set to [Managed-SecurityHeadersPolicy](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-response-headers-policies.html#managed-response-headers-policies-security). To override this policy, set the `responseHeadersPolicy` to the policy ID:

To allow embedding the website in an iframe, set it up explicitly:
```yaml
constructs:
landing:
# ...
responseHeadersPolicy: 0a28e63d-d3a9-4578-9f8b-14347bfe8123 # Your Policy ID
```

or create a [response headers policy](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudfront.ResponseHeadersPolicyProps.html) inline:

```yaml
constructs:
landing:
# ...
security:
allowIframe: true
responseHeadersPolicy:
responseHeadersPolicyName: CustomPolicy
securityHeadersBehavior:
contentSecurityPolicy:
contentSecurityPolicy: default-src https:;
override: true
```

## Extensions
Expand Down
21 changes: 16 additions & 5 deletions docs/static-website.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,18 +227,29 @@ When a browser requests the URL of a non-existing file, the `error.html` file wi

Do not use this setting when doing JavaScript URL routing: this will break URL routing.

### Allow iframes
### Response headers policy

By default, as recommended [for security reasons](https://scotthelme.co.uk/hardening-your-http-response-headers/#x-frame-options), the static website cannot be embedded in an iframe.
By default, the response headers policy is set to [Managed-SecurityHeadersPolicy](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-response-headers-policies.html#managed-response-headers-policies-security). To override this policy, set the `responseHeadersPolicy` to the policy ID:

To allow embedding the website in an iframe, set it up explicitly:
```yaml
constructs:
landing:
# ...
responseHeadersPolicy: 0a28e63d-d3a9-4578-9f8b-14347bfe8123 # Your Policy ID
```

or create a [response headers policy](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudfront.ResponseHeadersPolicyProps.html) inline:

```yaml
constructs:
landing:
# ...
security:
allowIframe: true
responseHeadersPolicy:
responseHeadersPolicyName: CustomPolicy
securityHeadersBehavior:
contentSecurityPolicy:
contentSecurityPolicy: default-src https:;
override: true
```

## Extensions
Expand Down
45 changes: 11 additions & 34 deletions src/constructs/aws/SinglePageApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,44 +20,21 @@ export class SinglePageApp extends StaticWebsiteAbstract {
) {
super(scope, id, configuration, provider);

const cfnDistribution = this.distribution.node.defaultChild as cloudfront.CfnDistribution;
const requestFunction = this.createRequestFunction();

const defaultBehaviorFunctionAssociations = getCfnFunctionAssociations(cfnDistribution);

cfnDistribution.addOverride("Properties.DistributionConfig.DefaultCacheBehavior.FunctionAssociations", [
...defaultBehaviorFunctionAssociations,
{ EventType: FunctionEventType.VIEWER_REQUEST, FunctionARN: requestFunction.functionArn },
]);
}

private createRequestFunction(): cloudfront.Function {
let additionalCode = "";

if (this.configuration.redirectToMainDomain === true) {
additionalCode += redirectToMainDomain(this.domains);
}

/**
* CloudFront function that redirects nested paths to /index.html and
* let static files pass.
*
* Files extensions list taken from: https://docs.aws.amazon.com/amplify/latest/userguide/redirects.html#redirects-for-single-page-web-apps-spa
* Add pdf, xml, webmanifest, avif and wasm as well
*/
const code = `var REDIRECT_REGEX = /^[^.]+$|\\.(?!(css|gif|ico|jpg|jpeg|js|png|txt|svg|woff|woff2|ttf|map|json|webp|xml|pdf|webmanifest|avif|wasm)$)([^.]+$)/;
const cfnDistribution = this.distribution.node.defaultChild as cloudfront.CfnDistribution;
const requestFunction = this.createRedirectRequestFunction();

function handler(event) {
var uri = event.request.uri;
var request = event.request;
var isUriToRedirect = REDIRECT_REGEX.test(uri);
const defaultBehaviorFunctionAssociations = getCfnFunctionAssociations(cfnDistribution);

if (isUriToRedirect) {
request.uri = "/index.html";
}${additionalCode}
cfnDistribution.addOverride("Properties.DistributionConfig.DefaultCacheBehavior.FunctionAssociations", [
...defaultBehaviorFunctionAssociations,
{ EventType: FunctionEventType.VIEWER_REQUEST, FunctionARN: requestFunction.functionArn },
]);
}
}

return event.request;
}`;
private createRedirectRequestFunction(): cloudfront.Function {
const code = redirectToMainDomain(this.domains);

const functionName = ensureNameMaxLength(
`${this.provider.stackName}-${this.provider.region}-${this.id}-request`,
Expand Down
93 changes: 35 additions & 58 deletions src/constructs/aws/abstracts/StaticWebsiteAbstract.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import type { CfnDistribution, ErrorResponse } from "aws-cdk-lib/aws-cloudfront";
import {
AllowedMethods,
CachePolicy,
Distribution,
FunctionEventType,
HttpVersion,
ResponseHeadersPolicy,
ViewerProtocolPolicy,
} from "aws-cdk-lib/aws-cloudfront";
import { S3Origin } from "aws-cdk-lib/aws-cloudfront-origins";
import type { IResponseHeadersPolicy } from "aws-cdk-lib/aws-cloudfront";
import type { BucketProps, CfnBucket } from "aws-cdk-lib/aws-s3";
import { Bucket } from "aws-cdk-lib/aws-s3";
import type { Construct as CdkConstruct } from "constructs";
Expand All @@ -26,7 +26,6 @@ import { emptyBucket, invalidateCloudFrontCache } from "../../../classes/aws";
import ServerlessError from "../../../utils/error";
import type { Progress } from "../../../utils/logger";
import { getUtils } from "../../../utils/logger";
import { ensureNameMaxLength } from "../../../utils/naming";
import { s3Sync } from "../../../utils/s3-sync";

export const COMMON_STATIC_WEBSITE_DEFINITION = {
Expand All @@ -43,12 +42,8 @@ export const COMMON_STATIC_WEBSITE_DEFINITION = {
],
},
certificate: { type: "string" },
security: {
type: "object",
properties: {
allowIframe: { type: "boolean" },
},
additionalProperties: false,
responseHeadersPolicy: {
anyOf: [{ type: "string" }, { type: "object" }],
},
errorPage: { type: "string" },
redirectToMainDomain: { type: "boolean" },
Expand Down Expand Up @@ -107,13 +102,22 @@ export abstract class StaticWebsiteAbstract extends AwsConstruct {
);
}

const functionAssociations = [
{
function: this.createResponseFunction(),
eventType: FunctionEventType.VIEWER_RESPONSE,
},
];

let responseHeadersPolicy: IResponseHeadersPolicy;
if (typeof this.configuration.responseHeadersPolicy === "string") {
responseHeadersPolicy = ResponseHeadersPolicy.fromResponseHeadersPolicyId(
scope,
"ResponseHeadersPolicy",
this.configuration.responseHeadersPolicy
);
} else if (this.configuration.responseHeadersPolicy !== undefined) {
responseHeadersPolicy = new ResponseHeadersPolicy(
scope,
"ResponseHeadersPolicy",
this.configuration.responseHeadersPolicy
);
} else {
responseHeadersPolicy = ResponseHeadersPolicy.SECURITY_HEADERS;
}
this.distribution = new Distribution(this, "CDN", {
comment: `${provider.stackName} ${id} website CDN`,
// Send all page requests to index.html
Expand All @@ -126,9 +130,9 @@ export abstract class StaticWebsiteAbstract extends AwsConstruct {
// See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-policies-list
cachePolicy: CachePolicy.CACHING_OPTIMIZED,
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
functionAssociations: functionAssociations,
responseHeadersPolicy,
},
errorResponses: [this.errorResponse()],
errorResponses: this.errorResponses(),
// Enable http2 transfer for better performances
httpVersion: HttpVersion.HTTP2,
certificate: certificate,
Expand Down Expand Up @@ -300,60 +304,33 @@ export abstract class StaticWebsiteAbstract extends AwsConstruct {
}
}

private errorResponse(): ErrorResponse {
private errorResponses(): ErrorResponse[] {
const errorPath = this.errorPath();

// Custom error page
if (errorPath !== undefined) {
return {
httpStatus: 404,
ttl: Duration.seconds(0),
responseHttpStatus: 404,
responsePagePath: errorPath,
};
return [
{
httpStatus: 404,
ttl: Duration.seconds(0),
responseHttpStatus: 404,
responsePagePath: errorPath,
},
];
}

/**
* The default behavior is optimized for SPA: all unknown URLs are served
* by index.html so that routing can be done client-side.
*/
return {
httpStatus: 404,
const defaultResponse = (httpStatus: number): ErrorResponse => ({
httpStatus,
ttl: Duration.seconds(0),
responseHttpStatus: 200,
responsePagePath: "/index.html",
};
}

private createResponseFunction(): cloudfront.Function {
const securityHeaders: Record<string, { value: string }> = {
"x-frame-options": { value: "SAMEORIGIN" },
"x-content-type-options": { value: "nosniff" },
"x-xss-protection": { value: "1; mode=block" },
"strict-transport-security": { value: "max-age=63072000" },
};
if (this.configuration.security?.allowIframe === true) {
delete securityHeaders["x-frame-options"];
}
const jsonHeaders = JSON.stringify(securityHeaders, undefined, 4);
/**
* CloudFront function that manipulates the HTTP responses to add security headers.
*/
const code = `function handler(event) {
var response = event.response;
response.headers = Object.assign({}, ${jsonHeaders}, response.headers);
return response;
}`;

const functionName = ensureNameMaxLength(
`${this.provider.stackName}-${this.provider.region}-${this.id}-response`,
64
);

return new cloudfront.Function(this, "ResponseFunction", {
functionName,
code: cloudfront.FunctionCode.fromInline(code),
});

return [defaultResponse(403), defaultResponse(404)];
}

getBucketProps(): BucketProps {
Expand Down
8 changes: 5 additions & 3 deletions src/utils/getDefaultCfnFunctionAssociations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ export function getCfnFunctionAssociations(distribution: CfnDistribution): CfnFu
const defaultBehavior = (distribution.distributionConfig as CfnDistribution.DistributionConfigProperty)
.defaultCacheBehavior as CfnDistribution.DefaultCacheBehaviorProperty;

return (defaultBehavior.functionAssociations as Array<CfnDistribution.FunctionAssociationProperty>).map(
cdkFunctionAssociationToCfnFunctionAssociation
);
return defaultBehavior.functionAssociations instanceof Array
? (defaultBehavior.functionAssociations as Array<CfnDistribution.FunctionAssociationProperty>).map(
cdkFunctionAssociationToCfnFunctionAssociation
)
: [];
}
Loading