├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app.py ├── cdk.json ├── images └── architecture.png ├── mdl_style.rb ├── requirements.txt └── src ├── amplify_add_on_stack.py ├── functions ├── cache_invalidation │ └── lambda_function.py └── password_retrieval │ └── lambda_function.py └── web_acl_stack.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | __pycache__ 3 | .pytest_cache 4 | .venv 5 | *.egg-info 6 | .coverage 7 | .markdownlint* 8 | # CDK asset staging directory 9 | .cdk.staging 10 | cdk.out 11 | cdk.context.json 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pycqa/isort 3 | rev: 5.10.1 4 | hooks: 5 | - id: isort 6 | args: ["--profile", "black"] 7 | verbose: true 8 | - repo: https://github.com/psf/black 9 | rev: 21.12b0 # Replace with any tag/version: https://github.com/psf/black/tags 10 | hooks: 11 | - id: black 12 | language_version: python3 # Should be a command that runs python3.7+ 13 | verbose: true 14 | - repo: https://gitlab.com/pycqa/flake8 15 | rev: 4.0.1 16 | hooks: 17 | - id: flake8 18 | args: ['--max-line-length=150', '--ignore=E203, A002', '--max-cognitive-complexity=14', '--max-expression-complexity=7' ] 19 | additional_dependencies: [ 20 | flake8-bugbear, # Detect potential bugs 21 | flake8-builtins, # Check for built-ins being used as variables 22 | flake8-cognitive-complexity, # Check max function complexity 23 | flake8-comprehensions, # Suggestions for better list/set/dict comprehensions 24 | flake8-eradicate, # Find dead/commented out code 25 | flake8-expression-complexity, # Check max expression complexity 26 | flake8-fixme, # Check for FIXME, TODO, and XXX left in comments 27 | flake8-logging-format, # Validate (lack of) logging format strings 28 | flake8-mutable, # Check for mutable default arguments 29 | flake8-pie, # Misc. linting rules 30 | flake8-pytest-style, # Check against pytest style guide 31 | flake8-return, # Check return statements 32 | flake8-simplify, # Suggestions to simplify code 33 | flake8-use-fstring, # Encourages use of f-strings vs old style 34 | pep8-naming, # Check PEP8 class naming 35 | ] 36 | verbose: true 37 | - repo: https://github.com/Lucas-C/pre-commit-hooks-safety 38 | rev: v1.2.2 39 | hooks: 40 | - id: python-safety-dependencies-check 41 | verbose: true 42 | - repo: https://github.com/awslabs/git-secrets 43 | rev: 80230afa8c8bdeac766a0fece36f95ffaa0be778 44 | hooks: 45 | - id: git-secrets 46 | verbose: true 47 | entry: 'git-secrets --register-aws' 48 | language: script 49 | name: git-secrets-register-aws-provider 50 | - id: git-secrets 51 | verbose: true 52 | entry: 'git-secrets --scan' 53 | language: script 54 | name: git-secrets-scan 55 | - repo: https://github.com/markdownlint/markdownlint 56 | rev: v0.11.0 57 | hooks: 58 | - id: markdownlint 59 | name: Markdownlint 60 | description: Run markdownlint on your Markdown files 61 | entry: mdl . 62 | language: ruby 63 | files: \.(md|mdown|markdown)$ 64 | verbose: true 65 | args: 66 | - "-s" 67 | - "mdl_style.rb" 68 | - repo: https://github.com/PyCQA/bandit 69 | rev: '1.7.1' 70 | hooks: 71 | - id: bandit 72 | entry: bandit 73 | exclude: ^tests/ 74 | verbose: true -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 4 | For more information see the 5 | [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 6 | opensource-codeofconduct@amazon.com with any additional questions or comments. 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. 4 | Whether it's a bug report, new feature, correction, or additional 5 | documentation, we greatly value feedback and contributions from our community. 6 | 7 | Please read through this document before submitting any 8 | issues or pull requests to ensure we have all the necessary 9 | information to effectively respond to your bug report or contribution. 10 | 11 | ## Reporting Bugs/Feature Requests 12 | 13 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 14 | 15 | When filing an issue, please check existing open, or recently closed, 16 | issues to make sure somebody else hasn't already 17 | reported the issue. 18 | Please try to include as much information as you can. 19 | Details like these are incredibly useful: 20 | 21 | * A reproducible test case or series of steps 22 | * The version of our code being used 23 | * Any modifications you've made relevant to the bug 24 | * Anything unusual about your environment or deployment 25 | 26 | ## Contributing via Pull Requests 27 | 28 | Contributions via pull requests are much appreciated. 29 | This repository use [pre-commit](https://pre-commit.com/) hooks for linting. 30 | Before sending us a pull request, please ensure that: 31 | 32 | 1. You are working against the latest source on the *main* branch. 33 | 1. You check existing open, and recently merged, 34 | pull requests to make sure someone else hasn't addressed the problem already. 35 | 1. You open an issue to discuss any significant work - we would hate 36 | for your time to be wasted. 37 | 38 | To send us a pull request, please: 39 | 40 | 1. Fork the repository. 41 | 1. Modify the source; please focus on the specific change you are contributing. 42 | If you also reformat all the code, it will be hard for us to focus on your change. 43 | 1. Ensure local tests pass. 44 | 1. Commit to your fork using clear commit messages. 45 | 1. Send us a pull request, answering any default questions in the pull request interface. 46 | 1. Pay attention to any automated CI failures reported in the pull request, 47 | and stay involved in the conversation. 48 | 49 | > Note: you may need to update the commit if `pre-commit` changes/suggests changes to files 50 | 51 | GitHub provides additional document on 52 | [forking a repository](https://help.github.com/articles/fork-a-repo/) and 53 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 54 | 55 | ## Finding contributions to work on 56 | 57 | Looking at the existing issues is a great way to find something to contribute on. 58 | As our projects, by default, use the default GitHub issue labels 59 | (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), 60 | looking at any 'help wanted' issues is a great place to start. 61 | 62 | ## Code of Conduct 63 | 64 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 65 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) 66 | or contact 67 | opensource-codeofconduct@amazon.com with any additional questions or comments. 68 | 69 | ## Security issue notifications 70 | 71 | If you discover a potential security issue in this project we ask 72 | that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). 73 | Please do **not** create a public github issue. 74 | 75 | ## Licensing 76 | 77 | See the [LICENSE](LICENSE) file for our project's licensing. 78 | We will ask you to confirm the licensing of your contribution. 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Enable WAF for Amplify Hosted web applications 2 | 3 | Many AWS Amplify Web Applications do not have a firewall attached at all, 4 | simply because the integration with AWS WAF does not exist natively. 5 | Thus, this template can be a quick and effective way to improve the security of your web application. 6 | 7 | Following the steps in this pattern will allow users to create an Amazon CloudFront Distribution 8 | associated with an AWS WAFv2 WebACL configured with basic AWS Managed Rulesets. 9 | It will also demonstrate how an extra layer of security can be applied to the AWS Amplify application 10 | to stop users from circumventing the AWS WAFv2 configuration. 11 | All traffic into the application must now go through the new Amazon CloudFront Distribution. 12 | 13 | The pattern is supplied with a self-contained sample CDK construct which can be used as-is 14 | or modified to enable WAF integration on an existing Amplify web application. 15 | The code also enables automated cache invalidation of the newly created CloudFront distribution 16 | every time new code is deployed for the Amplify-hosted Web App. 17 | 18 | ## Prerequisites 19 | 20 | --- 21 | 22 | - An active Amazon Web Services (AWS) account 23 | - An existing AWS Amplify hosted web application 24 | - A workspace with CDK v2 CLI, AWS CLI and Python 3.8+ installed 25 | - Familiarity with AWS CDK - python 26 | - Familiarity with AWS CLI 27 | 28 | ## Limitations 29 | 30 | --- 31 | 32 | - You can no longer use custom domains with AWS Amplify but will need to use custom domain with Amazon CloudFront 33 | - Automated secrets rotation is not enabled so stack re-deployment is 34 | required to regenerate new secrets for AWS Amplify app basic authentication 35 | - A new instance of the stack needs to be deployed for each AWS Amplify app and branch that needs AWS WAF protection enabled 36 | 37 | ## Architecture 38 | 39 | --- 40 | 41 | ### Target architecture 42 | 43 | Deploying the supplied CDK code will deploy the architecture seen below into the target AWS account. 44 | The Amplify Application is not created by the CDK code and is expected to pre-exist within the target AWS account. 45 | 46 | Deploying an AWS WAF using the supplied CDK code is optional, 47 | you can integrate an existing WAF as long as its attachable to a CloudFront distribution. 48 | 49 | ![Architecture Diagram](images/architecture.png) 50 | 51 | ### Target technology stack 52 | 53 | - AWS Web Application Firewall 54 | - Amazon CloudFront 55 | - AWS Secrets Manager 56 | 57 | This pattern provides deployment through AWS CDK which can easily be automated in CI/CD pipelines. 58 | 59 | > Note : The deployment of this CDK code adds configuration 60 | > to existing Amplify App. This should not create issues for the 61 | > IaC operations performed on the Amplify resource outside this stack. 62 | 63 | ## Tools 64 | 65 | --- 66 | 67 | - [AWS CDK v2](https://docs.aws.amazon.com/cdk/v2/guide/home.html) 68 | (tested with v2.43.1) - 69 | AWS Cloud Development Kit (AWS CDK) is a software development framework for 70 | defining cloud infrastructure as code and provisioning it through AWS CloudFormation 71 | - [Python (v3+)](https://www.python.org/) - 72 | Python is a high-level, interpreted, general-purpose programming language. 73 | - [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html) - 74 | The AWS Command Line Interface (AWS CLI) is an open-source 75 | tool for interacting with 76 | AWS services through commands in your command-line shell. 77 | With minimal configuration, you can run AWS CLI commands that 78 | implement functionality equivalent to that provided by the 79 | browser-based AWS Management Console from a command prompt. 80 | - [AWS CloudFormation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html) - 81 | AWS CloudFormation helps you model and setup your AWS resources, 82 | provision them quickly and consistently, and manage them throughout their lifecycle. 83 | 84 | ## Deployment Steps 85 | 86 | --- 87 | 88 | Follow below steps to enable WAF on existing Amplify Application using AWS CDK constructs 89 | 90 | - Download the source code from Github and setup virtual env 91 | 92 | > We recommend using AWS Cloud9 as the IDE for this pattern, 93 | > but you can also use another IDE (for example, Visual Studio Code or IntelliJ IDEA). 94 | 95 | - Run the following command in a terminal to clone 96 | the sample CDK application's repository into your IDE: 97 | 98 | ```console 99 | git clone https://github.com/aws-samples/aws-cdk-amplify-with-waf.git 100 | ``` 101 | 102 | - Change directory to the newly downloaded source code. 103 | 104 | To create the virtualenv for the CDK application its 105 | required that there is a python3 106 | (or python for Windows) executable in your path with access to the venv 107 | package. 108 | 109 | - To manually create and activate a virtualenv on MacOS and Linux: 110 | 111 | ```console 112 | python3 -m venv .venv 113 | source .venv/bin/activate 114 | ``` 115 | 116 | - To manually create and activate a virtualenv on Windows: 117 | 118 | ```console 119 | python -m venv .venv 120 | .venv\Scripts\activate.bat 121 | ``` 122 | 123 | > Additionally on Windows change the `python3` to `python` 124 | in the entry point (`app` key) in `cdk.json` 125 | 126 | - Install the dependencies 127 | 128 | ```console 129 | pip install -r requirements.txt 130 | ``` 131 | 132 | - Bootstrap the CDK app 133 | 134 | - Ensure that you have the correct AWS CLI credentials 135 | configured for the account in which you want to deploy the stacks. 136 | Alternatively pass the correct profile using 137 | `--profile PROFILE_NAME` in all cdk commands 138 | 139 | ```console 140 | cdk bootstrap aws://ACCOUNT-NUMBER/REGION-1 aws://ACCOUNT-NUMBER/REGION-2 141 | ``` 142 | 143 | > Region-1 must be the `us-east-1` region to deploy the Web ACL 144 | > 145 | > Region-2 will be the region in which Amplify App exists 146 | 147 | - Update below parameters in `cdk.json` file. 148 | 149 | **app_id** : Amplify App Id for the existing amplify app to which you want to associate the WAF. 150 | This is the last part of the Amplify App Arn 151 | usually in the format `arn:PARTITION:amplify:REGION:ACCOUNT_ID:apps/APP_ID`. 152 | The Amplify App Arn can be found from the AWS Console. 153 | 154 | **branch_name** : Branch corresponding to the deployment which 155 | needs to be protected using WAF 156 | 157 | **web_acl_arn** : Provide ARN of existing WebACL if you want an existing WebACL 158 | associated with the Amplify App. If you do not have an existing WebACL 159 | to attach, deploy the CustomWebAcl stack from this cdk app to 160 | create a WebACL with a pre-defined set of AWS Managed rules. 161 | 162 | - (Optionally) Deploy WebACL stack 163 | 164 | - Optionally deploy the Web ACL creation stack if not using existing Web ACL. 165 | Use the output to update web_acl_arn in cdk.json. 166 | If using an existing Web ACL skip to next step. 167 | 168 | ```console 169 | cdk deploy CustomWebAclStack 170 | ``` 171 | 172 | - Deploy Custom Amplify Distribution stack 173 | 174 | - Deploy the CDK stack that enables WAF protection for 175 | Amplify as described in the architecture diagram 176 | 177 | ```console 178 | cdk deploy CustomAmplifyDistributionStack 179 | ``` 180 | 181 | - Verify the deployment 182 | 183 | - Use the output from the CustomAmplifyDistribution stack to test the Web Application. 184 | The Web Application should now be accessible using the CloudFront URL. 185 | 186 | - Try accessing the direct Amplify endpoint for the Web App which 187 | should now prompt for basic authentication. 188 | 189 | - Update the Web Application and commit changes to verify that 190 | Amplify deploys the app successfully and the 191 | Custom CloudFront distribution is invalidated automatically. 192 | 193 | ## References 194 | 195 | - [AWS WAF](https://aws.amazon.com/waf/) 196 | - [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) 197 | - [Amazon CloudFront](https://aws.amazon.com/cloudfront/) 198 | - [AWS Amplify](https://aws.amazon.com/amplify/) 199 | - [Customise CloudFront domain](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html) 200 | 201 | ## [Contributing Guide](CONTRIBUTING.md) 202 | 203 | --- 204 | 205 | Read our contributing guide to learn about our development process, 206 | how to propose bugfixes and improvements, and how to 207 | integrate your changes in this repository 208 | 209 | ## [Code of Conduct](CODE_OF_CONDUCT.md) 210 | 211 | --- 212 | 213 | ## [License](LICENSE) 214 | 215 | --- 216 | 217 | This library is licensed under the MIT-0 License. See the LICENSE file. 218 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from aws_cdk import App, Aspects 3 | from cdk_nag import AwsSolutionsChecks 4 | 5 | from src.amplify_add_on_stack import CustomAmplifyDistributionStack 6 | from src.web_acl_stack import CustomWebAclStack 7 | 8 | app = App() 9 | 10 | CustomWebAclStack( 11 | app, 12 | "CustomWebAclStack", 13 | description="This stack creates WebACL to be attached to a CloudFront distribution \ 14 | for a Web App hosted with Amplify", 15 | env={"region": "us-east-1"}, 16 | ) 17 | CustomAmplifyDistributionStack( 18 | app, 19 | "CustomAmplifyDistributionStack", 20 | description="This stack creates a custom CloudFront distribution pointing to \ 21 | Amplify app's default CloudFront distribution. \ 22 | It also enables Basic Auth protection on specified branch. \ 23 | Creates event based setup for invalidating custom CloudFront distribution when \ 24 | a new version of Amplify App is deployed.", 25 | web_acl_arn=app.node.try_get_context("web_acl_arn"), 26 | app_id=app.node.try_get_context("app_id"), 27 | branch_name=app.node.try_get_context("branch_name"), 28 | ) 29 | 30 | Aspects.of(app).add(AwsSolutionsChecks()) 31 | app.synth() 32 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "python3 app.py", 3 | "watch": { 4 | "include": ["**"], 5 | "exclude": [ 6 | "README.md", 7 | "cdk*.json", 8 | "requirements*.txt", 9 | "source.bat", 10 | "**/__init__.py", 11 | "python/__pycache__", 12 | "tests" 13 | ] 14 | }, 15 | "context": { 16 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 17 | "@aws-cdk/core:stackRelativeExports": true, 18 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 19 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 20 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 21 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 22 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 23 | "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], 24 | "web_acl_arn":"<>", 25 | "app_id":"<>", 26 | "branch_name":"<>" 27 | } 28 | } -------------------------------------------------------------------------------- /images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cdk-amplify-with-waf/043b5ca12759cd782cbdd62d4dda5c8ab777b9d4/images/architecture.png -------------------------------------------------------------------------------- /mdl_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD013', :line_length => 120, :tables => false -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aws-cdk-lib==2.143.1 2 | constructs==10.3.0 3 | cdk-nag==2.28.129 -------------------------------------------------------------------------------- /src/amplify_add_on_stack.py: -------------------------------------------------------------------------------- 1 | import os 2 | from urllib.parse import quote 3 | 4 | import aws_cdk.aws_cloudfront as cloudfront 5 | import aws_cdk.aws_cloudfront_origins as origins 6 | from aws_cdk import Aws, CfnOutput, CustomResource, Duration, Stack 7 | from aws_cdk import aws_events as events 8 | from aws_cdk import aws_events_targets as targets 9 | from aws_cdk import aws_iam as iam 10 | from aws_cdk import aws_secretsmanager as secrets 11 | from aws_cdk import custom_resources as custom 12 | from aws_cdk.aws_lambda import Code, Function, Runtime, Tracing 13 | from aws_cdk.aws_logs import RetentionDays 14 | from cdk_nag import NagSuppressions 15 | from constructs import Construct 16 | 17 | dirname = os.path.dirname(__file__) 18 | 19 | 20 | class CustomAmplifyDistributionStack(Stack): 21 | def __init__( 22 | self, 23 | scope: Construct, 24 | id: str, 25 | web_acl_arn: str, 26 | app_id: str, 27 | branch_name: str, 28 | **kwargs, 29 | ): 30 | super().__init__(scope, id, **kwargs) 31 | 32 | amplify_username = secrets.Secret( 33 | self, 34 | "rAmplifyUsername", 35 | description=f"Username created for Amplify app with id {app_id}", 36 | generate_secret_string=secrets.SecretStringGenerator( 37 | password_length=12, exclude_punctuation=True 38 | ), 39 | ) 40 | 41 | amplify_password = secrets.Secret( 42 | self, 43 | "rAmplifyPassword", 44 | description=f"Password created for Amplify app with id {app_id}", 45 | generate_secret_string=secrets.SecretStringGenerator( 46 | password_length=32, exclude_characters=":" 47 | ), 48 | ) 49 | 50 | # Lambda Baic Execution Permissions 51 | lambda_exec_policy = iam.ManagedPolicy.from_managed_policy_arn( 52 | self, 53 | "lambda-exec-policy-00", 54 | managed_policy_arn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", 55 | ) 56 | 57 | # Amplify Credential Retrieval Lambda Execution Role 58 | amplify_credentials_retrieval_function_role = iam.Role( 59 | self, 60 | "rAmplifyCredentialsRetrievalFunctionRole", 61 | description="Role used by amplify_credentials_retrieval_function lambda function", 62 | assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 63 | ) 64 | 65 | amplify_credentials_retrieval_function_role.add_managed_policy( 66 | lambda_exec_policy 67 | ) 68 | 69 | amplify_password.grant_read(amplify_credentials_retrieval_function_role) 70 | amplify_username.grant_read(amplify_credentials_retrieval_function_role) 71 | 72 | # Function to retrieve base64 encoded authorisation string 73 | amplify_credentials_retrieval_function = Function( 74 | self, 75 | "rAmplifyCredentialsRetrievalFunction", 76 | description="custom function to retrieve value of scecrets that contain amplify auth info", # noqa 501 77 | runtime=Runtime.PYTHON_3_12, 78 | handler="lambda_function.lambda_handler", 79 | code=Code.from_asset( 80 | path=os.path.join(dirname, "functions/password_retrieval") 81 | ), 82 | timeout=Duration.seconds(30), 83 | memory_size=128, 84 | role=amplify_credentials_retrieval_function_role, 85 | tracing=Tracing.ACTIVE, 86 | log_retention=RetentionDays.SIX_MONTHS, 87 | environment={ 88 | "USERNAME_SECRET_ARN": amplify_username.secret_full_arn, 89 | "CREDENTIALS_SECRET_ARN": amplify_password.secret_full_arn, 90 | }, 91 | ) 92 | 93 | password_provider = custom.Provider( 94 | self, 95 | "rPasswordProvider", 96 | on_event_handler=amplify_credentials_retrieval_function, 97 | ) 98 | 99 | amplify_auth_value = CustomResource( 100 | self, 101 | "rPasswordRequestResource", 102 | service_token=password_provider.service_token, 103 | properties={}, 104 | ) 105 | 106 | app_branch_update = custom.AwsCustomResource( 107 | self, 108 | "rAmplifyAppBranchUpdate", 109 | policy=custom.AwsCustomResourcePolicy.from_sdk_calls( 110 | resources=[ 111 | f"arn:aws:amplify:{Aws.REGION}:{Aws.ACCOUNT_ID}:apps/{app_id}/branches/{quote(branch_name, safe='')}", 112 | ] 113 | ), 114 | on_create=custom.AwsSdkCall( 115 | service="Amplify", 116 | action="updateBranch", 117 | parameters={ 118 | "appId": app_id, 119 | "branchName": branch_name, 120 | "enableBasicAuth": True, 121 | "basicAuthCredentials": amplify_auth_value.get_att_string( 122 | "EncodedCredentials" 123 | ), 124 | }, 125 | physical_resource_id=custom.PhysicalResourceId.of( 126 | "amplify-branch-update" 127 | ), 128 | ), 129 | on_update=custom.AwsSdkCall( 130 | service="Amplify", 131 | action="updateBranch", 132 | parameters={ 133 | "appId": app_id, 134 | "branchName": branch_name, 135 | "enableBasicAuth": True, 136 | "basicAuthCredentials": amplify_auth_value.get_att_string( 137 | "EncodedCredentials" 138 | ), 139 | }, 140 | physical_resource_id=custom.PhysicalResourceId.of( 141 | "amplify-branch-update" 142 | ), 143 | ), 144 | ) 145 | 146 | app_branch_update.node.add_dependency(amplify_auth_value) 147 | app_branch_update.node.add_dependency(amplify_auth_value) 148 | 149 | # Format amplify branch 150 | formatted_amplify_branch = branch_name.replace("/", "-") 151 | 152 | # Define cloudfront distribution 153 | amplify_app_distribution = cloudfront.Distribution( 154 | self, 155 | "rCustomCloudFrontDistribution", 156 | default_behavior=cloudfront.BehaviorOptions( 157 | origin=origins.HttpOrigin( 158 | domain_name=f"{formatted_amplify_branch}.{app_id}.amplifyapp.com", 159 | custom_headers={ 160 | "Authorization": amplify_auth_value.get_att_string( 161 | "EncodedSuffix" 162 | ) 163 | }, 164 | ), 165 | viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 166 | ), 167 | price_class=cloudfront.PriceClass.PRICE_CLASS_ALL, 168 | web_acl_id=web_acl_arn, 169 | ) 170 | 171 | amplify_app_distribution.node.add_dependency(amplify_auth_value) 172 | 173 | self.amplify_app_distribution = amplify_app_distribution 174 | 175 | # CloudFront cache invalidation Lambda Execution Role 176 | cache_invalidation_function_role = iam.Role( 177 | self, 178 | "rCacheInvalidationFunctionCustomRole", 179 | description="Role used by cache_invalidation lambda function", 180 | assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 181 | ) 182 | 183 | cache_invalidation_function_role.add_managed_policy(lambda_exec_policy) 184 | 185 | cache_invalidation_function_custom_policy = iam.ManagedPolicy( 186 | self, 187 | "rCacheInvalidationFunctionCustomPolicy", 188 | statements=[ 189 | iam.PolicyStatement( 190 | effect=iam.Effect.ALLOW, 191 | actions=[ 192 | "cloudfront:CreateInvalidation", 193 | ], 194 | resources=[ 195 | f"arn:aws:cloudfront::{Aws.ACCOUNT_ID}:distribution/{amplify_app_distribution.distribution_id}" 196 | ], 197 | ), 198 | ], 199 | ) 200 | 201 | cache_invalidation_function_role.add_managed_policy( 202 | cache_invalidation_function_custom_policy 203 | ) 204 | 205 | # Function to trigger CloudFront invalidation 206 | cache_invalidation_function = Function( 207 | self, 208 | "rCacheInvalidationFunction", 209 | description="custom function to trigger cloudfront cache invalidation", # noqa 501 210 | runtime=Runtime.PYTHON_3_12, 211 | handler="lambda_function.lambda_handler", 212 | code=Code.from_asset( 213 | path=os.path.join(dirname, "functions/cache_invalidation") 214 | ), 215 | timeout=Duration.seconds(30), 216 | memory_size=128, 217 | role=cache_invalidation_function_role, 218 | tracing=Tracing.ACTIVE, 219 | log_retention=RetentionDays.SIX_MONTHS, 220 | environment={ 221 | "DISTRIBUTION_ID": amplify_app_distribution.distribution_id, 222 | }, 223 | ) 224 | 225 | events.Rule( 226 | self, 227 | "rInvokeCacheInvalidation", 228 | description="Rule is triggered when the Amplify app is redeployed, which creates a CloudFront cache invalidation request", # noqa E501 229 | event_pattern=events.EventPattern( 230 | source=["aws.amplify"], 231 | detail_type=["Amplify Deployment Status Change"], 232 | detail={ 233 | "appId": [app_id], 234 | "branchName": [branch_name], 235 | "jobStatus": ["SUCCEED"], 236 | }, 237 | ), 238 | targets=[ 239 | targets.LambdaFunction(cache_invalidation_function, retry_attempts=2) 240 | ], 241 | ) 242 | 243 | CfnOutput( 244 | self, 245 | "oCloudFrontDistributionDomain", 246 | value=amplify_app_distribution.distribution_domain_name, 247 | ) 248 | 249 | # Stack Suppressions 250 | NagSuppressions.add_resource_suppressions( 251 | amplify_username, 252 | suppressions=[ 253 | { 254 | "id": "AwsSolutions-SMG4", 255 | "reason": "user to retrigger rotation by recreating stack", 256 | } 257 | ], 258 | ) 259 | 260 | NagSuppressions.add_resource_suppressions( 261 | amplify_password, 262 | suppressions=[ 263 | { 264 | "id": "AwsSolutions-SMG4", 265 | "reason": "user to retrigger rotation by recreating stack", 266 | } 267 | ], 268 | ) 269 | 270 | NagSuppressions.add_resource_suppressions( 271 | amplify_app_distribution, 272 | suppressions=[ 273 | { 274 | "id": "AwsSolutions-CFR1", 275 | "reason": "geo restictions to be enabled using WAF by user", 276 | }, 277 | { 278 | "id": "AwsSolutions-CFR3", 279 | "reason": "user to override the logging property as required", 280 | }, 281 | { 282 | "id": "AwsSolutions-CFR4", 283 | "reason": "user to override when using a custom domain and certificate", 284 | }, 285 | ], 286 | ) 287 | 288 | NagSuppressions.add_resource_suppressions( 289 | cache_invalidation_function_role, 290 | suppressions=[ 291 | { 292 | "id": "AwsSolutions-IAM4", 293 | "reason": "CDK generated service role and policy", 294 | }, 295 | { 296 | "id": "AwsSolutions-IAM5", 297 | "reason": "CDK generated service role and policy", 298 | }, 299 | { 300 | "id": "AwsSolutions-L1", 301 | "reason": "CDK generated custom resource", 302 | }, 303 | ], 304 | apply_to_children=True, 305 | ) 306 | 307 | NagSuppressions.add_resource_suppressions( 308 | password_provider, 309 | suppressions=[ 310 | { 311 | "id": "AwsSolutions-IAM4", 312 | "reason": "CDK generated service role and policy", 313 | }, 314 | { 315 | "id": "AwsSolutions-IAM5", 316 | "reason": "CDK generated service role and policy", 317 | }, 318 | { 319 | "id": "AwsSolutions-L1", 320 | "reason": "CDK generated custom resource", 321 | }, 322 | ], 323 | apply_to_children=True, 324 | ) 325 | 326 | NagSuppressions.add_resource_suppressions( 327 | amplify_credentials_retrieval_function_role, 328 | suppressions=[ 329 | { 330 | "id": "AwsSolutions-IAM4", 331 | "reason": "CDK generated service role and policy", 332 | }, 333 | { 334 | "id": "AwsSolutions-IAM5", 335 | "reason": "CDK generated service role and policy", 336 | }, 337 | ], 338 | apply_to_children=True, 339 | ) 340 | 341 | NagSuppressions.add_resource_suppressions( 342 | amplify_credentials_retrieval_function_role, 343 | suppressions=[ 344 | { 345 | "id": "AwsSolutions-IAM4", 346 | "reason": "CDK generated service role and policy", 347 | }, 348 | { 349 | "id": "AwsSolutions-IAM5", 350 | "reason": "CDK generated service role and policy", 351 | }, 352 | ], 353 | apply_to_children=True, 354 | ) 355 | 356 | NagSuppressions.add_resource_suppressions_by_path( 357 | self, 358 | path=f"/{self.stack_name}/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/Resource", 359 | suppressions=[ 360 | { 361 | "id": "AwsSolutions-IAM4", 362 | "reason": "CDK generated service role and policy", 363 | }, 364 | ], 365 | ) 366 | 367 | NagSuppressions.add_resource_suppressions_by_path( 368 | self, 369 | path=f"/{self.stack_name}/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/DefaultPolicy/Resource", 370 | suppressions=[ 371 | { 372 | "id": "AwsSolutions-IAM5", 373 | "reason": "CDK generated service role and policy", 374 | }, 375 | ], 376 | ) 377 | 378 | NagSuppressions.add_resource_suppressions_by_path( 379 | self, 380 | path=f"/{self.stack_name}/AWS679f53fac002430cb0da5b7982bd2287/ServiceRole/Resource", 381 | suppressions=[ 382 | { 383 | "id": "AwsSolutions-IAM4", 384 | "reason": "CDK generated service role and policy", 385 | }, 386 | ], 387 | ) 388 | 389 | NagSuppressions.add_resource_suppressions_by_path( 390 | self, 391 | path=f"/{self.stack_name}/AWS679f53fac002430cb0da5b7982bd2287/Resource", 392 | suppressions=[ 393 | { 394 | "id": "AwsSolutions-L1", 395 | "reason": "CDK generated custom resource", 396 | }, 397 | ], 398 | ) 399 | -------------------------------------------------------------------------------- /src/functions/cache_invalidation/lambda_function.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | 4 | import boto3 5 | from botocore.exceptions import ClientError 6 | 7 | # Setup the client 8 | service_client = boto3.client("cloudfront") 9 | 10 | 11 | def lambda_handler(event, context): 12 | 13 | try: 14 | service_client.create_invalidation( 15 | DistributionId=os.environ["DISTRIBUTION_ID"], 16 | InvalidationBatch={ 17 | "Paths": {"Quantity": 1, "Items": ["/*"]}, 18 | "CallerReference": str(uuid.uuid4()), 19 | }, 20 | ) 21 | 22 | print("Cache invalidation request submitted successfully") 23 | except ClientError as e: 24 | print(e.response["Error"]["Message"]) 25 | -------------------------------------------------------------------------------- /src/functions/password_retrieval/lambda_function.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | 4 | import boto3 5 | 6 | # Setup the client 7 | service_client = boto3.client("secretsmanager") 8 | 9 | 10 | def lambda_handler(event, context): 11 | current_username = service_client.get_secret_value( 12 | SecretId=os.environ["USERNAME_SECRET_ARN"], VersionStage="AWSCURRENT" 13 | ) 14 | 15 | current_password = service_client.get_secret_value( 16 | SecretId=os.environ["CREDENTIALS_SECRET_ARN"], VersionStage="AWSCURRENT" 17 | ) 18 | 19 | credentials_suffix = ( 20 | f"{current_username['SecretString']}:{current_password['SecretString']}" 21 | ) 22 | 23 | # Encode suffix in base64 24 | bytes_encoded_suffix = base64.b64encode(bytes(credentials_suffix, "utf-8")) 25 | encoded_suffix = bytes_encoded_suffix.decode("utf-8") 26 | 27 | return { 28 | "Data": { 29 | "EncodedSuffix": f"Basic {encoded_suffix}", # For CloudFront Authorization header 30 | "EncodedCredentials": encoded_suffix, # For Amplify Basic Auth credentials 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /src/web_acl_stack.py: -------------------------------------------------------------------------------- 1 | from aws_cdk import Aws, CfnOutput, Fn, Stack 2 | from aws_cdk import aws_logs as logs 3 | from aws_cdk import aws_wafv2 as waf 4 | from constructs import Construct 5 | 6 | 7 | class CustomWebAclStack(Stack): 8 | def __init__( 9 | self, 10 | scope: Construct, 11 | id: str, 12 | **kwargs, 13 | ): 14 | super().__init__(scope, id, **kwargs) 15 | waf_rules = [ 16 | # 1, AWS Bot Control rule group 17 | waf.CfnWebACL.RuleProperty( 18 | name="AWS-BotControl", 19 | priority=1, 20 | override_action=waf.CfnWebACL.OverrideActionProperty(none={}), 21 | statement=waf.CfnWebACL.StatementProperty( 22 | managed_rule_group_statement=waf.CfnWebACL.ManagedRuleGroupStatementProperty( 23 | name="AWSManagedRulesBotControlRuleSet", vendor_name="AWS" 24 | ) 25 | ), 26 | visibility_config=waf.CfnWebACL.VisibilityConfigProperty( 27 | cloud_watch_metrics_enabled=True, 28 | metric_name=f"AWSManagedRulesBotControlRuleSetMetrics-{Aws.STACK_NAME}", 29 | sampled_requests_enabled=True, 30 | ), 31 | ), 32 | # 2 Amazon IP reputation list managed rule group 33 | waf.CfnWebACL.RuleProperty( 34 | name="AWS-AmazonIpReputationList", 35 | priority=2, 36 | override_action=waf.CfnWebACL.OverrideActionProperty(none={}), 37 | statement=waf.CfnWebACL.StatementProperty( 38 | managed_rule_group_statement=waf.CfnWebACL.ManagedRuleGroupStatementProperty( 39 | name="AWSManagedRulesAmazonIpReputationList", vendor_name="AWS" 40 | ) 41 | ), 42 | visibility_config=waf.CfnWebACL.VisibilityConfigProperty( 43 | cloud_watch_metrics_enabled=True, 44 | metric_name=f"AWSManagedRulesAmazonIpReputationListMetrics-{Aws.STACK_NAME}", 45 | sampled_requests_enabled=True, 46 | ), 47 | ), 48 | # 3, Anonymous IP list managed rule group 49 | waf.CfnWebACL.RuleProperty( 50 | name="AWS-ManagedRulesAnonymousIpList", 51 | priority=3, 52 | override_action=waf.CfnWebACL.OverrideActionProperty(none={}), 53 | statement=waf.CfnWebACL.StatementProperty( 54 | managed_rule_group_statement=waf.CfnWebACL.ManagedRuleGroupStatementProperty( 55 | name="AWSManagedRulesAnonymousIpList", vendor_name="AWS" 56 | ) 57 | ), 58 | visibility_config=waf.CfnWebACL.VisibilityConfigProperty( 59 | cloud_watch_metrics_enabled=True, 60 | metric_name=f"AWSManagedRulesAnonymousIpListMetrics-{Aws.STACK_NAME}", 61 | sampled_requests_enabled=True, 62 | ), 63 | ), 64 | # 4, AWS general rules (Core rule set) 65 | waf.CfnWebACL.RuleProperty( 66 | name="AWS-ManagedRulesCommonRuleSet", 67 | priority=4, 68 | override_action=waf.CfnWebACL.OverrideActionProperty(none={}), 69 | statement=waf.CfnWebACL.StatementProperty( 70 | managed_rule_group_statement=waf.CfnWebACL.ManagedRuleGroupStatementProperty( 71 | name="AWSManagedRulesCommonRuleSet", vendor_name="AWS" 72 | ) 73 | ), 74 | visibility_config=waf.CfnWebACL.VisibilityConfigProperty( 75 | cloud_watch_metrics_enabled=True, 76 | metric_name=f"AWSManagedRulesCommonRuleSetMetrics-{Aws.STACK_NAME}", 77 | sampled_requests_enabled=True, 78 | ), 79 | ), 80 | # 5, AWS Known Bad inputs rules 81 | waf.CfnWebACL.RuleProperty( 82 | name="AWS-ManagedRulesKnownBadInputsRuleSet", 83 | priority=5, 84 | override_action=waf.CfnWebACL.OverrideActionProperty(none={}), 85 | statement=waf.CfnWebACL.StatementProperty( 86 | managed_rule_group_statement=waf.CfnWebACL.ManagedRuleGroupStatementProperty( 87 | name="AWSManagedRulesKnownBadInputsRuleSet", 88 | vendor_name="AWS", 89 | ) 90 | ), 91 | visibility_config=waf.CfnWebACL.VisibilityConfigProperty( 92 | cloud_watch_metrics_enabled=True, 93 | metric_name=f"AWSManagedRulesKnownBadInputsRuleSetMetrics-{Aws.STACK_NAME}", 94 | sampled_requests_enabled=True, 95 | ), 96 | ), 97 | # 6, Admin protection managed rule group 98 | waf.CfnWebACL.RuleProperty( 99 | name="AWS-AdminProtection", 100 | priority=6, 101 | override_action=waf.CfnWebACL.OverrideActionProperty(none={}), 102 | statement=waf.CfnWebACL.StatementProperty( 103 | managed_rule_group_statement=waf.CfnWebACL.ManagedRuleGroupStatementProperty( 104 | name="AWSManagedRulesAdminProtectionRuleSet", vendor_name="AWS" 105 | ) 106 | ), 107 | visibility_config=waf.CfnWebACL.VisibilityConfigProperty( 108 | cloud_watch_metrics_enabled=True, 109 | metric_name=f"AWSManagedRulesAdminProtectionRuleSetMetrics-{Aws.STACK_NAME}", 110 | sampled_requests_enabled=True, 111 | ), 112 | ), 113 | ] 114 | 115 | # Define Web Application Firewall ACL 116 | web_acl = waf.CfnWebACL( 117 | self, 118 | "rWebACL", 119 | default_action=waf.CfnWebACL.DefaultActionProperty(allow={}), 120 | scope="CLOUDFRONT", 121 | visibility_config=waf.CfnWebACL.VisibilityConfigProperty( 122 | cloud_watch_metrics_enabled=True, 123 | sampled_requests_enabled=True, 124 | metric_name=f"WebAclMetrics-{Aws.STACK_NAME}", 125 | ), 126 | rules=waf_rules, 127 | ) 128 | 129 | # Web ACL log group 130 | web_acl_lg = logs.LogGroup( 131 | self, 132 | "rAmplifWebAclLogGroup", 133 | retention=logs.RetentionDays.SIX_MONTHS, 134 | log_group_name=f"aws-waf-logs-{Aws.STACK_NAME}", 135 | ) 136 | 137 | waf.CfnLoggingConfiguration( 138 | self, 139 | "rAmplifWebAclLoggingConfig", 140 | log_destination_configs=[ 141 | Fn.select(0, Fn.split(":*", web_acl_lg.log_group_arn)) 142 | ], 143 | resource_arn=web_acl.attr_arn, 144 | ) 145 | 146 | self.custom_web_acl = web_acl 147 | 148 | CfnOutput(self, "oWebAclId", value=web_acl.attr_arn) 149 | --------------------------------------------------------------------------------