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

feat: ele-4721 add support for security headers for cdn site hosting #60

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from 14 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
2 changes: 1 addition & 1 deletion lib/cdn-site-hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Establishes infrastructure to host a static-site or single-page-application in S
This construct will:

- Create an S3 bucket with static website hosting enabled
- Create a CloudFront web distribution to deliver site content
- Create a CloudFront distribution to deliver site content
- Register the CloudFront distribution with the provided certificate
- Deploy provided source code to S3 and invalidate the CloudFront distribution

Expand Down
97 changes: 65 additions & 32 deletions lib/cdn-site-hosting/cdn-site-hosting-construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import * as certificatemanager from "@aws-cdk/aws-certificatemanager";
import * as cloudfront from "@aws-cdk/aws-cloudfront";
import * as s3 from "@aws-cdk/aws-s3";
import * as s3deploy from "@aws-cdk/aws-s3-deployment";
import * as origins from "@aws-cdk/aws-cloudfront-origins";
import { getSiteDomain } from "./utils";
import { CommonCdnSiteHostingProps } from "./cdn-site-hosting-props";
import { Duration } from "@aws-cdk/core";

export interface CdnSiteHostingConstructProps
extends CommonCdnSiteHostingProps {
Expand All @@ -22,7 +24,7 @@ export interface CdnSiteHostingConstructProps
*/
export class CdnSiteHostingConstruct extends cdk.Construct {
public readonly s3Bucket: s3.Bucket;
public readonly cloudfrontWebDistribution: cloudfront.CloudFrontWebDistribution;
public readonly cloudfrontDistribution: cloudfront.Distribution;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we checked the projects to see if we're relying on this in some way?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't but as I was intending a breaking change to the project I thought it wouldn't matter?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might just help highlight anything where we're relying on this for something that will no longer be available. In all likelihood, we won't be relying upon it in this way, but if we were doing that and several of the apps were then blocked from upgrading, the construct's usefulness would substantially lower.

Might be worth emailing round the devs to ask anyone who is using this to take a look at their own project and feed back (by a short deadline). I'll look at user-utils, for instance.


constructor(
scope: cdk.Construct,
Expand All @@ -35,18 +37,10 @@ export class CdnSiteHostingConstruct extends cdk.Construct {

const siteDomain = getSiteDomain(props);

// certificate
const viewerCertificate = cloudfront.ViewerCertificate.fromAcmCertificate(
certificatemanager.Certificate.fromCertificateArn(
this,
`${siteDomain}-cert`,
props.certificateArn
),
{
aliases: [siteDomain],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Has this been renamed to domainNames below?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup.

sslMethod: cloudfront.SSLMethod.SNI,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Has this been renamed to sslSupportMethod below?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup.

securityPolicy: cloudfront.SecurityPolicyProtocol.TLS_V1_1_2016,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Has this been renamed to minimumProtocolVersion below?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup.

}
const certificate = certificatemanager.Certificate.fromCertificateArn(
this,
`${siteDomain}-cert`,
props.certificateArn
);

let websiteErrorDocument: string | undefined = props.websiteErrorDocument;
Expand All @@ -67,36 +61,75 @@ export class CdnSiteHostingConstruct extends cdk.Construct {
});
new cdk.CfnOutput(this, "Bucket", { value: this.s3Bucket.bucketName });

const defaultSecurityHeaders: cloudfront.ResponseSecurityHeadersBehavior = {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
contentSecurityPolicy: {
contentSecurityPolicy: "default-src 'self';",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: how have we decided on the defaults for all of these headers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've just taken all the defaults from the recommended from commented link above it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only setting default-src. Is this default strong enough on all the other CSP directives?

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... though it's good it's covering the major fetch directives...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How will this affect authentication in our SPAs?

override: true,
},
// https://web.dev/security-headers/#xcto
contentTypeOptions: { override: true },
// https://web.dev/security-headers/#recommended-usages-4
frameOptions: {
frameOption: cloudfront.HeadersFrameOption.DENY,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment: I guess we'll have to override this in Otis, for LTI, etc?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, but for most apps it should be deny which is why I have set it as DENY (recommended).

override: true,
},
// https://web.dev/referrer-best-practices/#setting-your-referrer-policy:-best-practices
referrerPolicy: {
referrerPolicy:
cloudfront.HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN,
override: true,
},
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security#examples
strictTransportSecurity: {
accessControlMaxAge: Duration.days(365 * 2),
includeSubdomains: true,
preload: true,
override: true,
},
// xxs-protection is overridden by the contentSecurityPolicy in modern browsers
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection
xssProtection: { protection: false, override: true },
};

const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(
this,
"ResponseHeadersPolicy",
{
securityHeadersBehavior: {
...defaultSecurityHeaders,
...props.securityHeaders,
},
}
);

// Cloudfront distribution
this.cloudfrontWebDistribution = new cloudfront.CloudFrontWebDistribution(
this.cloudfrontDistribution = new cloudfront.Distribution(
this,
"SiteDistribution",
{
viewerCertificate,
originConfigs: [
{
// We use a custom origin rather than S3 origin because the latter
// does not seem to support websiteErrorDocument correctly
Comment on lines -78 to -79

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is this no longer an issue?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no custom origin support in the new class as far as I am aware, I tried going to a bogus page in Otis in a stack I bought up and it returned the styled Otis error page rather than something from s3 so assume it works in the new class?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the customOriginSource was a hold-your-nose solution because the default documented solution for attaching to an S3 bucket didn't seem to work via CDK.

This may be another benefit of the change, rather than something we need to recreate.

customOriginSource: {
domainName: this.s3Bucket.bucketWebsiteDomainName,
originProtocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY,
},
behaviors: [{ isDefaultBehavior: true }],
},
],
errorConfigurations: props.isRoutedSpa

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Has this been renamed errorResponses?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup.

sslSupportMethod: cloudfront.SSLMethod.SNI,
minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_1_2016,
certificate,
domainNames: [siteDomain],
defaultRootObject: props.websiteIndexDocument,
defaultBehavior: {
origin: new origins.S3Origin(this.s3Bucket),
responseHeadersPolicy: responseHeadersPolicy,
},
errorResponses: props.isRoutedSpa
? [
{
errorCode: 404,
responseCode: 200,
Comment on lines -90 to -91

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Have these been renamed httpStatus and responseHttpStatus?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup.

httpStatus: 404,
responseHttpStatus: 200,
responsePagePath: `/${props.websiteIndexDocument}`,
},
]
: undefined,
}
);
new cdk.CfnOutput(this, "DistributionId", {
value: this.cloudfrontWebDistribution.distributionId,
value: this.cloudfrontDistribution.distributionId,
});

// Deploy site contents to S3 bucket
Expand Down Expand Up @@ -125,7 +158,7 @@ export class CdnSiteHostingConstruct extends cdk.Construct {
prune: isSingleDeploymentStep,
destinationBucket: this.s3Bucket,
distribution: isInvalidationRequired
? this.cloudfrontWebDistribution
? this.cloudfrontDistribution
: undefined,
distributionPaths: isInvalidationRequired
? distributionPathsToInvalidate
Expand All @@ -145,7 +178,7 @@ export class CdnSiteHostingConstruct extends cdk.Construct {
new s3deploy.BucketDeployment(this, "DeployAndInvalidate", {
sources: props.sources,
destinationBucket: this.s3Bucket,
distribution: this.cloudfrontWebDistribution,
distribution: this.cloudfrontDistribution,
distributionPaths: ["/*"],
});
}
Expand Down
2 changes: 2 additions & 0 deletions lib/cdn-site-hosting/cdn-site-hosting-props.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as cdk from "@aws-cdk/core";
import * as s3deploy from "@aws-cdk/aws-s3-deployment";
import { ResponseSecurityHeadersBehavior } from "@aws-cdk/aws-cloudfront";

export interface SourcesWithDeploymentOptions {
name?: string;
Expand All @@ -17,4 +18,5 @@ export interface CommonCdnSiteHostingProps {
sourcesWithDeploymentOptions?: SourcesWithDeploymentOptions[];
websiteErrorDocument?: string;
websiteIndexDocument: string;
securityHeaders?: ResponseSecurityHeadersBehavior;
}
4 changes: 1 addition & 3 deletions lib/cdn-site-hosting/cdn-site-hosting-with-dns-construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ export class CdnSiteHostingWithDnsConstruct extends cdk.Construct {
new route53.ARecord(this, `${siteDomain}-SiteAliasRecord`, {
recordName: siteDomain,
target: route53.RecordTarget.fromAlias(
new targets.CloudFrontTarget(
this.cdnSiteHosting.cloudfrontWebDistribution
)
new targets.CloudFrontTarget(this.cdnSiteHosting.cloudfrontDistribution)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, this will be used for non-production stacks, so we could use cdk destroy then cdk deploy without concern.

),
zone,
});
Expand Down
102 changes: 101 additions & 1 deletion test/infra/cdn-site-hosting/cdn-site-hosting-construct.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { Template } from "@aws-cdk/assertions";

// hosted-zone requires an environment be attached to the Stack
const testEnv: Environment = {
region: "eu-west-1",
// region for Cloudfront Distribution certificates must be us-east-1
// https://stackoverflow.com/questions/37289994/aws-certificate-manager-do-regions-matter
region: "us-east-1",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is intended to represent the environment in which we are provisioning the entire stack, not just a Cloudfront Distribution certificate.

Let's say we wanted to add more resources to the construct that are region-specific, so we have a mixture of things that are supposed to be statically us-east-1 and things that are supposed to be provisioned in the specified region. If we use us-east-1 as our test case, we would not be able to successfully test that our our region specifications are correct, since it'd be entirely possible that either:

  1. We'd hard-coded everything to a single region, erroneously.
  2. We'd made everything respect the specified region when some things should be hard-coded to us-east-1.

For this reason, I'd be reluctant to hard-code to us-east-1.

(To mitigate point 1, we should ultimately be testing to ensure that it respects different specified regions.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your broader point about us-east-1 would then involve hard-coding that correct region into line 20, IIUC.

account: "abcdefg12345",
};
const fakeCertificateArn = `arn:aws:acm:${testEnv.region}:${testEnv.account}:certificate/123456789012-1234-1234-1234-12345678`;
Expand Down Expand Up @@ -74,6 +76,45 @@ describe("CdnSiteHostingConstruct", () => {
);
});

test("provisions a ResponseHeadersPolicy with default values", () => {
expectCDK(stack).to(countResources("AWS::CloudFront::Distribution", 1));
const template = Template.fromStack(stack);

template.hasResource("AWS::CloudFront::ResponseHeadersPolicy", {
Properties: {
ResponseHeadersPolicyConfig: {
SecurityHeadersConfig: {
ContentSecurityPolicy: {
ContentSecurityPolicy: "default-src self;",
Override: true,
},
ContentTypeOptions: {
Override: true,
},
FrameOptions: {
FrameOption: "DENY",
Override: true,
},
ReferrerPolicy: {
Override: true,
ReferrerPolicy: "strict-origin-when-cross-origin",
},
StrictTransportSecurity: {
AccessControlMaxAgeSec: 31536,
IncludeSubdomains: true,
Override: true,
Preload: true,
},
XSSProtection: {
Override: true,
Protection: false,
},
},
},
},
});
});

test("issues a bucket deployment with CloudFront invalidation for the specified sources", () => {
expectCDK(stack).to(countResources("Custom::CDKBucketDeployment", 1));
expectCDK(stack).to(
Expand Down Expand Up @@ -307,4 +348,63 @@ describe("CdnSiteHostingConstruct", () => {
expect(secondDeployment.DependsOn).toContain(firstDeploymentId);
});
});

describe("When securityHeaders are provided", () => {
let stack: Stack;

beforeAll(() => {
const app = new cdk.App();
stack = new cdk.Stack(app, "TestStack", { env: testEnv });
new CdnSiteHostingConstruct(stack, "MyTestConstruct", {
certificateArn: fakeCertificateArn,
siteSubDomain: fakeSiteSubDomain,
domainName: fakeDomain,
removalPolicy: RemovalPolicy.DESTROY,
sources: [s3deploy.Source.asset("./")],
websiteIndexDocument: "index.html",
securityHeaders: {
contentSecurityPolicy: {
contentSecurityPolicy: "default-src 'none';",
override: true,
},
frameOptions: undefined,
},
});
});

test("provisions a ResponseHeadersPolicy with overridden default values", () => {
expectCDK(stack).to(countResources("AWS::CloudFront::Distribution", 1));
const template = Template.fromStack(stack);

template.hasResource("AWS::CloudFront::ResponseHeadersPolicy", {
Properties: {
ResponseHeadersPolicyConfig: {
SecurityHeadersConfig: {
ContentSecurityPolicy: {
ContentSecurityPolicy: "default-src 'none';",
Override: true,
},
ContentTypeOptions: {
Override: true,
},
ReferrerPolicy: {
Override: true,
ReferrerPolicy: "strict-origin-when-cross-origin",
},
StrictTransportSecurity: {
AccessControlMaxAgeSec: 31536,
IncludeSubdomains: true,
Override: true,
Preload: true,
},
XSSProtection: {
Override: true,
Protection: false,
},
},
},
},
});
});
});
});