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

Set security headers on CloudFront responses #414

Merged
8 commits merged into from
Jun 22, 2020
Merged
Show file tree
Hide file tree
Changes from 7 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
10 changes: 10 additions & 0 deletions BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,16 @@ AWS SAM CLI profile option: optional specific profile from your AWS credential f

Set this to `true` if you want to enable development mode. It's `false` by default, and unless you're actively developing on the developer portal itself locally, you should generally leave it unset as it disables most protections, including CORS.

### `edgeLambdaRebuildToken: string`

*Default: `'defaultRebuildToken'`*

Change this value if you want to update the edge lambda or its replicator lambda in the next deployment. In general, you shouldn't need to set it unless either 1. you're developing against the project and need to make changes to it, or 2. the project updated that part of the code and you just pulled its changes in preparation to update your deployment.

> Why is this not handled internally? At the time of writing, edge lambdas are difficult to delete due to how long it takes for all their replicas to delete, and you can't delete lambdas with active replicas. This process could take anywhere from a couple hours to several days (well past the largest timeout supported by Lambda), and neither CloudFront nor Lambda currently offer any hooks to know when all the replicas are gone. So edge lambda versions are only created, never deleted, by the template.
>
> For this reason, it's better to require the user to explicitly choose when to update the lambda, so that during development, you're not flooded with versions, and during production, you can be better aware of when things change (it's on your account, after all).

### `samTemplate: string`

*Default: `cloudformation/template.yaml` relative to the repo's root*
Expand Down
100 changes: 100 additions & 0 deletions cloudformation/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ Parameters:
Description: Provide a token different from the last deployment's token to re-upload the dev portal site's static assets. You can provide a timestamp or GUID on each deployment to always re-upload the assets.
Default: 'defaultRebuildToken'

EdgeLambdaRebuildToken:
Type: String
Description: Provide a token different from the last deployment's token to update the edge lambda. You can provide a timestamp or GUID on each deployment to always update it.
Default: 'defaultRebuildToken'

StaticAssetRebuildMode:
Type: String
Description: By default, a static asset rebuild doesn't overwrite custom-content. Provide the value `overwrite-content` to replace the custom-content with your local version. Don't do this unless you know what you're doing -- all custom changes in your s3 bucket will be lost.
Expand Down Expand Up @@ -1714,6 +1719,93 @@ Resources:
DevelopmentMode: !Ref DevelopmentMode
FeedbackEnabled: !If [ EnableFeedbackSubmission, 'true', 'false' ]

LambdaEdgeFunctionRole:
Type: AWS::IAM::Role
Properties:
Path: "/"
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
-
Sid: "AllowLambdaServiceToAssumeRole"
Effect: "Allow"
Action:
- "sts:AssumeRole"
Principal:
Service:
- "lambda.amazonaws.com"
- "edgelambda.amazonaws.com"

CloudFrontSecurityHeadersLambda:
Type: AWS::Serverless::Function
Properties:
CodeUri: ../lambdas/cloudfront-security
Handler: replicator.handler
MemorySize: 128
Role: !GetAtt CloudFrontEdgeReplicatorRole.Arn
Runtime: nodejs12.x
Timeout: 300
AutoPublishAlias: Live
Environment:
Variables:
Bucket: !Ref ArtifactsS3BucketName
Layers:
- !Ref LambdaCommonLayer

CloudFrontSecurityHeadersSetup:
Type: AWS::CloudFormation::CustomResource
Properties:
ServiceToken: !GetAtt CloudFrontSecurityHeadersLambda.Arn
Name: !Ref CloudFrontSecurityHeadersLambda
RoleArn: !GetAtt LambdaEdgeFunctionRole.Arn
RebuildToken: !Ref EdgeLambdaRebuildToken

CloudFrontEdgeReplicatorRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Path: '/'
Policies:
- PolicyName: root
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource: arn:aws:logs:*:*:*
- Effect: Allow
Action:
- lambda:CreateFunction
- lambda:GetFunction
- lambda:UpdateFunctionCode
- lambda:PublishVersion
Resource: arn:aws:lambda:*:*:*
- Effect: Allow
Action:
- iam:PassRole
Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/*'
- Effect: Allow
Action:
- s3:GetObject
- s3:DeleteObject
- s3:PutObject
Resource: !Join
- ''
- - 'arn:aws:s3:::'
- !Ref ArtifactsS3BucketName
- '/*'

CloudFrontOriginAccessIdentity:
Type: 'AWS::CloudFront::CloudFrontOriginAccessIdentity'
Condition: 'NotDevelopmentMode'
Expand All @@ -1738,6 +1830,10 @@ Resources:
QueryString: true
TargetOriginId: 'dev-portal-site-s3-bucket'
ViewerProtocolPolicy: redirect-to-https
LambdaFunctionAssociations:
-
EventType: origin-response
LambdaFunctionARN: !GetAtt CloudFrontSecurityHeadersSetup.EdgeArn
DefaultRootObject: index.html
Enabled: true
Comment: !Sub '${AWS::StackName} distribution'
Expand Down Expand Up @@ -1767,6 +1863,10 @@ Resources:
QueryString: true
TargetOriginId: 'dev-portal-site-s3-bucket'
ViewerProtocolPolicy: redirect-to-https
LambdaFunctionAssociations:
-
EventType: origin-response
LambdaFunctionARN: !GetAtt CloudFrontSecurityHeadersSetup.EdgeArn
DefaultRootObject: index.html
Enabled: true
Comment: !Sub '${AWS::StackName} distribution'
Expand Down
4 changes: 4 additions & 0 deletions dev-portal/example-deployer.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,8 @@ module.exports = {
// cognitoDomainAcmCertArn: 'arn:aws:acm:us-east-1:123456789012:certificate/98765432-9876-9876-9876-987654321098',
// useRoute53Nameservers: true,
// feedbackEmail: 'admin@domain.example',

// Toggle this any time the edge lambda or its replicator lambda need updated. You will be told in
// the migration instructions to do so if you need to.
// edgeLambdaResetToken: 'reset',
}
5 changes: 5 additions & 0 deletions dev-portal/example-dev-deployer.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ module.exports = {

// Set development mode for local use.
developmentMode: true,

// Toggle this any time the edge lambda or its replicator lambda are updated. In general, unless
// either you're modifying them yourself or they were changed upstream and you just pulled those
// changes, you shouldn't need to do anything about this value.
// edgeLambdaResetToken: 'reset',
}
6 changes: 5 additions & 1 deletion dev-portal/src/components/SwaggerUiLayout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ function InfoReplacement ({ specSelectors }) {
const docsUrl = externalDocs.get('url')

return <Observer>
{() => <Container fluid textAlign='left' className='fixfloat' style={{ padding: '40px 0px' }}>
{/*
If no API is loaded, let's just swallow the state and move on. (Swagger UI doesn't offer any
way to clean up after itself.)
*/}
{() => store.api == null ? null : <Container fluid textAlign='left' className='fixfloat' style={{ padding: '40px 0px' }}>
<div style={{ display: 'flex' }}>
<div style={{ flex: '0 0 auto', marginRight: '20px' }}>
<Image size='small' src={store.api.logo} />
Expand Down
8 changes: 4 additions & 4 deletions dev-portal/src/pages/Apis.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ export default observer(class ApisPage extends React.Component {
containerRef = React.createRef()
hasRoot = false

componentDidMount () { this.updateApi() }
componentDidUpdate () { this.updateApi() }
componentDidMount () { this.updateApi(true) }
componentDidUpdate () { this.updateApi(false) }
componentWillUnmount () { this.containerRef = null }

updateApi () {
return getApi(this.props.match.params.apiId || 'ANY', true, this.props.match.params.stage)
updateApi (isInitial) {
return getApi(this.props.match.params.apiId || 'ANY', true, this.props.match.params.stage, isInitial)
.then(api => {
if (this.containerRef == null) return
const elem = this.containerRef.current
Expand Down
2 changes: 1 addition & 1 deletion lambdas/backend/routes/admin/catalog/visibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ async function deleteFile (file) {
}

exports.post = async (req, res) => {
console.log(`POST /admin-catalog-visibility for Cognito ID: ${util.getCognitoIdentityId(req)}`)
console.log(`POST /admin/catalog/visibility for Cognito ID: ${util.getCognitoIdentityId(req)}`)

// for apigateway managed APIs, provide "apiId_stageName"
// in the apiKey field
Expand Down
29 changes: 29 additions & 0 deletions lambdas/cloudfront-security/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict'

const headersToAdd = {
// Don't include subdomains - the user might have a subdomain hosted on something else and not
// support it.
'strict-transport-security': [{ value: 'max-age=63072000' }],
'content-security-policy': [{
value: [
"default-src 'none'",
'connect-src *',
"prefetch-src 'self'",
"font-src 'self' data: fonts.gstatic.com",
"img-src 'self' data:",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' fonts.googleapis.com"
].join(';')
}],
'x-content-type-options': [{ value: 'nosniff' }],
'x-frame-options': [{ value: 'DENY' }],
'x-xss-protection': [{ value: '1; mode=block' }],
'referrer-policy': [{ value: 'same-origin' }]
}

exports.handler = (event, context, callback) => {
const response = event.Records[0].cf.response
console.log('Response headers:', response.headers)
Object.assign(response.headers, headersToAdd)
callback(null, response)
}
13 changes: 13 additions & 0 deletions lambdas/cloudfront-security/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "serverless-developer-cloudfront-security",
"private": true,
"version": "3.0.3",
"description": "Developer Portal component responsible for managing the edge lambda",
"main": "index.js",
"config": {},
"license": "Apache-2.0",
"dependencies": {
"archiver": "^4.0.1"
},
"devDependencies": {}
}
103 changes: 103 additions & 0 deletions lambdas/cloudfront-security/replicator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use strict'

// Note: this *never* deletes old lambdas. In the future, this could be addressed using a specially
// coded lambda to periodically attempt to delete it.

const path = require('path')
const { PassThrough } = require('stream')
const archiver = require('archiver')
const AWS = require('aws-sdk')
const notifyCFN = require('dev-portal-common/notify-cfn')
const { transact } = require('dev-portal-common/deployment-data')

exports.handler = async (event, context) => {
try {
console.log(`Running event ${event.RequestType} for replicator`)

const execResult = /^(.+)-[^-]+-([^-]+)$/.exec(event.ResourceProperties.Name)
if (execResult == null) {
throw new Error(`Unexpected name: ${event.ResourceProperties.Name}`)
}

const availableLength = 64 /* max lambda name length */ - 2 /* for dashes */ -
execResult[1].length - execResult[2].length

let generatedName = 'CloudFormationEdgeLambda'
if (generatedName.length > availableLength) generatedName = generatedName.slice(0, availableLength)
const functionName = `${execResult[1]}-${generatedName}-${execResult[2]}`

const lambdaEdge = new AWS.Lambda({ apiVersion: '2015-03-31', region: 'us-east-1' })

if (event.RequestType === 'Create' || event.RequestType === 'Update') {
console.log('Creating zip archive')
const archive = archiver('zip')
const zipPromise = (async () => {
const output = new PassThrough()
archive.pipe(output)
archive.on('error', e => { output.emit('error', e) })
const codeBuffer = []
for await (const data of output) codeBuffer.push(data)
return Buffer.concat(codeBuffer)
})()

archive.file(path.resolve(__dirname, 'index.js'), { name: 'index.js' })

await archive.finalize()
const zipped = await zipPromise

console.log('Zip file created')

let versionResult

if (event.RequestType === 'Create') {
console.log('Creating function')
const createResult = await lambdaEdge.createFunction({
Code: {
ZipFile: zipped
},
Description: '',
FunctionName: functionName,
Handler: 'index.handler',
MemorySize: 128,
Role: event.ResourceProperties.RoleArn,
Runtime: 'nodejs12.x',
Timeout: 1
}).promise()

console.log('Publishing initial version')
versionResult = await lambdaEdge.publishVersion({
FunctionName: createResult.FunctionArn
}).promise()
} else {
console.log('Updating function and publishing new version')
versionResult = await lambdaEdge.updateFunctionCode({
ZipFile: zipped,
FunctionName: functionName,
Publish: true
}).promise()
}

console.log('Saving to S3')
await transact({
type: 'linkEdge',
name: event.ResourceProperties.Name,
newArn: functionName
})

console.log('Notifying CFN')
return notifyCFN.ofSuccess({
event,
context,
physicalResourceId: functionName,
responseData: { EdgeArn: versionResult.FunctionArn }
})
} else {
console.log('No action required (deletion must be done manually)')
console.log('Notifying CFN')
return notifyCFN.ofSuccess({ event, context, physicalResourceId: functionName })
}
} catch (e) {
console.error('Error occurred:', e)
return notifyCFN.ofFailure({ event, context, error: e })
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading