Skip to content
This repository was archived by the owner on Jun 28, 2022. It is now read-only.

Commit

Permalink
feat: add headers to lambda@edge request origin only
Browse files Browse the repository at this point in the history
  • Loading branch information
arantespp committed Feb 26, 2021
1 parent 32f2b1b commit 477a278
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 91 deletions.
111 changes: 98 additions & 13 deletions packages/cli/src/deploy/staticApp/staticApp.template.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import * as faker from 'faker';

const region = faker.random.word();

/**
* Mock to snapshots don't fail.
*/
Expand Down Expand Up @@ -55,18 +57,17 @@ jest.mock('crypto', () => ({
import {
getStaticAppTemplate,
generateCspString,
getLambdaEdgeOriginResponseZipFile,
getLambdaEdgeOriginRequestZipFile,
CLOUDFRONT_DISTRIBUTION_LOGICAL_ID,
LAMBDA_EDGE_IAM_ROLE_LOGICAL_ID,
LAMBDA_EDGE_VERSION_ORIGIN_REQUEST_LOGICAL_ID,
} from './staticApp.template';

const defaultCspString =
"default-src 'self'; connect-src 'self' https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com/; font-src 'self' https://fonts.gstatic.com/; object-src 'none'";

describe('testing getLambdaEdgeOriginRequestZipFile', () => {
const gtmId = faker.random.word();
const region = faker.random.word();
const bucketName = faker.random.word();
const s3Path = `${faker.random.word()}/`;
const uri = `${faker.random.word()}.html`;
Expand Down Expand Up @@ -145,6 +146,71 @@ describe('testing getLambdaEdgeOriginRequestZipFile', () => {

const defaultEvent = { Records: [record] };

describe('handling headers', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { 'cache-control': _, ...requestHeaders } = record.cf.request.headers;

const securityHeaders = {
'content-security-policy': [
{
key: 'Content-Security-Policy',
value:
"default-src 'self'; connect-src 'self' https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com/; font-src 'self' https://fonts.gstatic.com/; object-src 'none'",
},
],
'referrer-policy': [{ key: 'Referrer-Policy', value: 'same-origin' }],
'strict-transport-security': [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubdomains; preload',
},
],
'x-content-type-options': [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
],
'x-frame-options': [{ key: 'X-Frame-Options', value: 'DENY' }],
'x-xss-protection': [{ key: 'X-XSS-Protection', value: '1; mode=block' }],
};

const cachingHeaders = {
'cache-control': [{ key: 'Cache-Control', value: 'max-age=30' }],
};

const handler = (event: any = defaultEvent) =>
eval(
getLambdaEdgeOriginRequestZipFile({ region }).replace(
'let body = undefined;',
'let body = "<html />";',
),
)(event);

const assertions = (response: any) => {
expect(response.headers).toEqual(expect.objectContaining(requestHeaders));
expect(response.headers).toEqual(
expect.objectContaining(securityHeaders),
);
expect(response.headers).toEqual(expect.objectContaining(cachingHeaders));
};

test('request (without body) should forward the headers', async () => {
const newRecord = JSON.parse(JSON.stringify(record));
const newUri = '/script.js';
newRecord.cf.request.uri = newUri;
const response = await handler({ Records: [newRecord] });
expect(response.body).toBeUndefined();
assertions(response);
});

test('response (with body) should forward the headers', async () => {
const newRecord = JSON.parse(JSON.stringify(record));
const newUri = `/${faker.random.word()}/`;
newRecord.cf.request.uri = newUri;
const response = await handler({ Records: [newRecord] });
expect(response.body).toBeDefined();
assertions(response);
});
});

describe('Issue #24 https://github.com/ttoss/carlin/issues/11. It tests requestUri method.', () => {
const handler = (event: any = {}) =>
eval(getLambdaEdgeOriginRequestZipFile({ region }))(event);
Expand Down Expand Up @@ -276,6 +342,24 @@ describe('testing getLambdaEdgeOriginRequestZipFile', () => {
);
});
});

test('getLambdaEdgeOriginRequestZipFile should have been added to CloudFormation template', () => {
expect(
getStaticAppTemplate({ region, cloudfront: true }).Resources[
CLOUDFRONT_DISTRIBUTION_LOGICAL_ID
].Properties.DistributionConfig.DefaultCacheBehavior
.LambdaFunctionAssociations,
).toEqual(
expect.arrayContaining([
{
EventType: 'origin-request',
LambdaFunctionARN: {
'Fn::GetAtt': `${LAMBDA_EDGE_VERSION_ORIGIN_REQUEST_LOGICAL_ID}.FunctionArn`,
},
},
]),
);
});
});

describe("fix issue 'Filter CSP directives' #11 https://github.com/ttoss/carlin/issues/11", () => {
Expand All @@ -291,16 +375,17 @@ describe("fix issue 'Filter CSP directives' #11 https://github.com/ttoss/carlin/
});
});

describe("fix issue 'Add default CSP to Lambda@Edge origin response' #10 https://github.com/ttoss/carlin/issues/10", () => {
test('should add csp to Lambda@Edge Origin response', () => {
expect(getLambdaEdgeOriginResponseZipFile({ csp: {} })).toContain(
describe("fix issue 'Add default CSP to Lambda@Edge origin request' #10 https://github.com/ttoss/carlin/issues/10", () => {
test('should add csp to Lambda@Edge Origin request', () => {
expect(getLambdaEdgeOriginRequestZipFile({ csp: {}, region })).toContain(
`"${defaultCspString}"`,
);
});

test('should add csp to Lambda@Edge Origin response when a directive is passed', () => {
const code = getLambdaEdgeOriginResponseZipFile({
test('should add csp to Lambda@Edge Origin request when a directive is passed', () => {
const code = getLambdaEdgeOriginRequestZipFile({
csp: { 'new-directive-src': "'some text'" },
region,
});

defaultCspString.split('; ').forEach((csp) => expect(code).toContain(csp));
Expand Down Expand Up @@ -336,8 +421,8 @@ describe("fix issue 'Add Google Marketing Platform to CSP' #3 https://github.com
).toContain("default-src 'some text'");
});

test('should add csp to Lambda@Edge Origin response', () => {
expect(getLambdaEdgeOriginResponseZipFile()).toContain(
test('should add csp to Lambda@Edge Origin request', () => {
expect(getLambdaEdgeOriginRequestZipFile({ region })).toContain(
`"${defaultCspString}"`,
);
});
Expand All @@ -346,7 +431,7 @@ describe("fix issue 'Add Google Marketing Platform to CSP' #3 https://github.com
describe("fix issue 'PWA doesn't redirect correctly when browser URL has a path' #1 https://github.com/ttoss/carlin/issues/1", () => {
test('cloudfront', () => {
expect(
getStaticAppTemplate({ cloudfront: true, region: 'us-east-1' }).Resources[
getStaticAppTemplate({ cloudfront: true, region }).Resources[
CLOUDFRONT_DISTRIBUTION_LOGICAL_ID
].Properties.DistributionConfig.Origins[0].OriginPath,
).toEqual(`/${PACKAGE_VERSION}`);
Expand Down Expand Up @@ -376,9 +461,9 @@ describe("fix issue 'PWA doesn't redirect correctly when browser URL has a path'

test('cloudfront CustomErrorResponses, spa=true', () => {
expect(
getStaticAppTemplate({ cloudfront: true, spa: true, region: 'us-east-1' })
.Resources[CLOUDFRONT_DISTRIBUTION_LOGICAL_ID].Properties
.DistributionConfig.CustomErrorResponses,
getStaticAppTemplate({ cloudfront: true, spa: true, region }).Resources[
CLOUDFRONT_DISTRIBUTION_LOGICAL_ID
].Properties.DistributionConfig.CustomErrorResponses,
).toEqual([
expect.objectContaining({
ErrorCode: 403,
Expand Down
109 changes: 31 additions & 78 deletions packages/cli/src/deploy/staticApp/staticApp.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,15 @@ export const generateCspString = ({
);
};

const LAMBDA_EDGE_ORIGIN_REQUEST_LOGICAL_ID = 'LambdaEdgeOriginRequest';

export const LAMBDA_EDGE_VERSION_ORIGIN_REQUEST_LOGICAL_ID =
'LambdaEdgeVersionOriginRequest';

/**
*
*/
export const assignHeaders = ({
csp,
maxAge = 30,
}: {
csp?: CSP;
maxAge?: number;
}) => {
export const assignCachingHeaders = ({ maxAge = 30 }: { maxAge?: number }) => {
return `
const maxAge = ${maxAge};
Expand All @@ -137,6 +136,25 @@ export const assignHeaders = ({
value: \`max-age=\${maxAge}\`
}
];
`;
};

/**
* Security headers are implemented by default using [Lambda@Edge](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html).
* We've used [this tutorial](https://aws.amazon.com/blogs/networking-and-content-delivery/adding-http-security-headers-using-lambdaedge-and-amazon-cloudfront/) as a guide to add the following headers:
*
* - [Strict Transport Security](https://infosec.mozilla.org/guidelines/web_security#http-strict-transport-security)
* - [Content-Security-Policy](https://infosec.mozilla.org/guidelines/web_security#content-security-policy)
* - [X-Content-Type-Options](https://infosec.mozilla.org/guidelines/web_security#x-content-type-options)
* - [X-Frame-Options](https://infosec.mozilla.org/guidelines/web_security#x-frame-options)
* - [X-XSS-Protection](https://infosec.mozilla.org/guidelines/web_security#x-xss-protection)
* - [Referrer-Policy](https://infosec.mozilla.org/guidelines/web_security#referrer-policy)
*
* The Lambda code may be seen [here](http://localhost:3000/docs/commands/deploy-static-app#lamdbaedge-origin-request-code) and if you want more information about the
* security headers, you may want to check [this link](https://infosec.mozilla.org/guidelines/web_security).
*/
const assignSecurityHeaders = ({ csp }: { csp?: CSP }) => {
return `
headers['strict-transport-security'] = [
{
key: 'Strict-Transport-Security',
Expand Down Expand Up @@ -176,11 +194,6 @@ export const assignHeaders = ({
`;
};

const LAMBDA_EDGE_ORIGIN_REQUEST_LOGICAL_ID = 'LambdaEdgeOriginRequest';

const LAMBDA_EDGE_VERSION_ORIGIN_REQUEST_LOGICAL_ID =
'LambdaEdgeVersionOriginRequest';

/**
* Created to allow [Google Marketing Platform](https://marketingplatform.google.com/about/) though
* [Google Tag Manager (GTM)](https://marketingplatform.google.com/about/tag-manager/) ([issue #3](https://github.com/ttoss/carlin/issues/3)).
Expand Down Expand Up @@ -213,7 +226,6 @@ const LAMBDA_EDGE_VERSION_ORIGIN_REQUEST_LOGICAL_ID =
* 1. Append the body script after `<body>` string.
* 1. Return the response with the new body and headers.
*/

export const getLambdaEdgeOriginRequestZipFile = ({
gtmId,
region,
Expand Down Expand Up @@ -314,6 +326,12 @@ const s3 = new S3({ region: "${region}" });
exports.handler = async (event, context) => {
const request = { ...event.Records[0].cf.request };
const headers = request.headers;
${assignSecurityHeaders({ csp: fullCsp })}
${assignCachingHeaders({})}
const { origin } = request;
const bucket = origin.s3.domainName.split(".")[0];
Expand Down Expand Up @@ -350,12 +368,8 @@ exports.handler = async (event, context) => {
return request;
}
let headers = { };
let body = undefined;
${assignHeaders({ csp: fullCsp })}
${
gtmId
? `
Expand Down Expand Up @@ -426,37 +440,6 @@ exports.handler = async (event, context) => {
);
};

const LAMBDA_EDGE_ORIGIN_RESPONSE_LOGICAL_ID = 'LambdaEdgeOriginResponse';

const LAMBDA_EDGE_VERSION_ORIGIN_RESPONSE_LOGICAL_ID =
'LambdaEdgeVersionOriginResponse';

/**
* This method is only triggered if origin request is not created, because
* origin request return a "body".
*
* Add some headers to improve security
* {@link https://aws.amazon.com/blogs/networking-and-content-delivery/adding-http-security-headers-using-lambdaedge-and-amazon-cloudfront/}.
*/
export const getLambdaEdgeOriginResponseZipFile = ({
csp = {},
}: { csp?: CSP } = {}) => {
return formatCode(`
'use strict';
exports.handler = async (event, context) => {
const request = event.Records[0].cf.request;
const response = event.Records[0].cf.response;
const headers = response.headers;
${assignHeaders({
csp: updateCspObject({ csp, currentCsp: getDefaultCsp() }),
})}
return response;
};
`);
};

const getBaseTemplate = ({
cloudfront,
spa,
Expand Down Expand Up @@ -669,30 +652,6 @@ const getCloudFrontEdgeLambdas = ({
},
},
},
[LAMBDA_EDGE_ORIGIN_RESPONSE_LOGICAL_ID]: {
Type: 'AWS::Lambda::Function',
Properties: {
Code: { ZipFile: getLambdaEdgeOriginResponseZipFile({ csp }) },
Description: 'Lambda@Edge function serving as origin response.',
Handler: 'index.handler',
MemorySize: 128,
Role: { 'Fn::GetAtt': `${LAMBDA_EDGE_IAM_ROLE_LOGICAL_ID}.Arn` },
Runtime: 'nodejs12.x',
Timeout: 5,
},
},
[LAMBDA_EDGE_VERSION_ORIGIN_RESPONSE_LOGICAL_ID]: {
Type: 'Custom::LatestLambdaVersion',
Properties: {
FunctionName: {
Ref: LAMBDA_EDGE_ORIGIN_RESPONSE_LOGICAL_ID,
},
Nonce: `${Date.now()}`,
ServiceToken: {
'Fn::GetAtt': `${PUBLISH_LAMBDA_VERSION_LOGICAL_ID}.Arn`,
},
},
},
};

return lambdaEdgeResources;
Expand Down Expand Up @@ -792,12 +751,6 @@ const getCloudFrontTemplate = ({
},
]
: []),
{
EventType: 'origin-response',
LambdaFunctionARN: {
'Fn::GetAtt': `${LAMBDA_EDGE_VERSION_ORIGIN_RESPONSE_LOGICAL_ID}.FunctionArn`,
},
},
],
TargetOriginId: { Ref: STATIC_APP_BUCKET_LOGICAL_ID },
ViewerProtocolPolicy: 'redirect-to-https',
Expand Down
4 changes: 4 additions & 0 deletions packages/website/carlin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ module.exports = () => {
'PUBLISH_LAMBDA_VERSION_ZIP_FILE',
],
readObjectFile: ['utils/readObjectFile.js', 'readObjectFile'],
assignSecurityHeaders: [
'deploy/staticApp/staticApp.template.js',
'assignSecurityHeaders',
],
}),
stackName: toHtml(
getComment(['deploy/stackName.js', 'getStackName']).split(
Expand Down
2 changes: 2 additions & 0 deletions packages/website/docs/commands/deploy-static-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ to the required version.

## Security

<InnerHTML html={carlin.comments.assignSecurityHeaders} />

## Deployments

The differences among all deployments occurs at the template level. Each option adds a piece of instructions on the final CloudFormation template, that we'll present to you.
Expand Down

0 comments on commit 477a278

Please sign in to comment.