├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── pull-request.yml │ └── test-and-publish.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.md ├── aws_jupyter_proxy ├── __init__.py ├── awsconfig.py ├── awsproxy.py ├── etc │ └── aws_jupyter_proxy.json └── handlers.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py └── unit ├── __init__.py ├── test_awsconfig.py ├── test_awsproxy.py └── test_handlers.py /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 7 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: pull-request-build 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | name: Build and test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 3 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: "3.10" 17 | 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install ".[dev]" 22 | - name: test 23 | run: | 24 | pytest tests/unit/ --cov=aws_juptyer_proxy 25 | black --check . 26 | - name: Install and build 27 | run: >- 28 | python -m 29 | pip install ".[dev]" 30 | build 31 | --user 32 | - name: build binary and tarball 33 | run: >- 34 | python -m 35 | build 36 | --sdist 37 | --wheel 38 | --outdir dist/ 39 | -------------------------------------------------------------------------------- /.github/workflows/test-and-publish.yml: -------------------------------------------------------------------------------- 1 | # Github actions that run tests on each commit, and publish to PyPi on tagged releases 2 | # build using https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 3 | 4 | name: build 5 | 6 | on: 7 | push 8 | 9 | jobs: 10 | build: 11 | name: Build and test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python 3 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: "3.10" 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install ".[dev]" 24 | - name: test 25 | run: | 26 | pytest tests/unit/ --cov=aws_juptyer_proxy 27 | black --check . 28 | - name: Install and build 29 | run: >- 30 | python -m 31 | pip install ".[dev]" 32 | build 33 | --user 34 | - name: build binary and tarball 35 | run: >- 36 | python -m 37 | build 38 | --sdist 39 | --wheel 40 | --outdir dist/ 41 | . 42 | - name: Publish distribution to test pypi 43 | if: startsWith(github.ref, 'refs/tags') 44 | uses: pypa/gh-action-pypi-publish@master 45 | with: 46 | user: __token__ 47 | password: ${{ secrets.TEST_PYPI_TOKEN }} 48 | repository_url: https://test.pypi.org/legacy/ 49 | - name: Publish distribution to PyPI 50 | if: startsWith(github.ref, 'refs/tags') 51 | uses: pypa/gh-action-pypi-publish@master 52 | with: 53 | user: __token__ 54 | password: ${{ secrets.PYPI_TOKEN }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | *.egg-info 3 | .idea 4 | .vscode 5 | __pycache__ 6 | /dist 7 | .coverage 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws/aws-jupyter-proxy/issues), or [recently closed](https://github.com/aws/aws-jupyter-proxy/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws/aws-jupyter-proxy/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/aws/aws-jupyter-proxy/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | aws-jupyter-proxy 2 | Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Jupyter Proxy 2 | 3 | ![Build](https://github.com/aws/aws-jupyter-proxy/workflows/build/badge.svg) 4 | [![Version](https://img.shields.io/pypi/v/aws_jupyter_proxy.svg)](https://pypi.org/project/aws-jupyter-proxy/) 5 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 6 | 7 | A Jupyter server extension to proxy requests with AWS SigV4 authentication. 8 | 9 | ## Overview 10 | 11 | This server extension enables the usage of the [AWS JavaScript/TypeScript SDK](https://github.com/aws/aws-sdk-js) to write Jupyter frontend extensions without having to export AWS credentials to the browser. 12 | 13 | A single `/awsproxy` endpoint is added on the Jupyter server which receives incoming requests from the browser, uses the credentials on the server to add [SigV4](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html) authentication to the request, and then proxies the request to the actual AWS service endpoint. 14 | 15 | All requests are proxied back-and-forth as-is, e.g., a 4xx status code from the AWS service will be relayed back as-is to the browser. 16 | 17 | NOTE: This project is still under active development 18 | 19 | ## Install 20 | 21 | Installing the package from PyPI will install and enable the server extension on the Jupyter server. 22 | 23 | ```bash 24 | pip install aws-jupyter-proxy 25 | ``` 26 | 27 | ## Usage 28 | 29 | Using this requries no additional dependencies in the client-side code. Just use the regular AWS JavaScript/TypeScript SDK methods and add any dummy credentials and change the endpoint to the `/awsproxy` endpoint. 30 | 31 | ```typescript 32 | import * as AWS from 'aws-sdk'; 33 | import SageMaker from 'aws-sdk/clients/sagemaker'; 34 | 35 | // Reusable function to add the XSRF token header to a request 36 | function addXsrfToken(request: AWS.Request) { 37 | const cookie = document.cookie.match('\\b' + '_xsrf' + '=([^;]*)\\b'); 38 | const xsrfToken = cookie ? cookie[1] : undefined; 39 | if (xsrfToken !== undefined) { 40 | request.httpRequest.headers['X-XSRFToken'] = xsrfToken; 41 | } 42 | } 43 | 44 | // These credentials are *not* used for the actual AWS service call but you have 45 | // to provide any dummy credentials (Not real ones!) 46 | AWS.config.secretAccessKey = 'IGNOREDIGNORE/IGNOREDIGNOREDIGNOREDIGNOR'; 47 | AWS.config.accessKeyId = 'IGNOREDIGNO'; 48 | 49 | // Change the endpoint in the client to the "awsproxy" endpoint on the Jupyter server. 50 | const proxyEndpoint = 'http://localhost:8888/awsproxy'; 51 | 52 | const sageMakerClient = new SageMaker({ 53 | region: 'us-west-2', 54 | endpoint: proxyEndpoint, 55 | }); 56 | 57 | // Make the API call! 58 | await sageMakerClient 59 | .listNotebookInstances({ 60 | NameContains: 'jaipreet' 61 | }) 62 | .on('build', addXsrfToken) 63 | .promise(); 64 | ``` 65 | 66 | ### Usage with S3 67 | 68 | For S3, use the `s3ForcePathStyle` parameter during the client initialization 69 | 70 | ```typescript 71 | import S3 from 'aws-sdk/clients/s3'; 72 | 73 | const s3Client = new S3({ 74 | region: 'us-west-2', 75 | endpoint: proxyEndpoint, 76 | s3ForcePathStyle: true, 77 | s3DisableBodySigning:false // for https 78 | }); 79 | 80 | await s3Client 81 | .getObject({ 82 | Bucket: 'my-bucket', 83 | Key: 'my-object' 84 | }) 85 | .on('build', addXsrfToken) 86 | .promise(); 87 | ``` 88 | 89 | ### Whitelisting 90 | 91 | On the server, the `AWS_JUPYTER_PROXY_WHITELISTED_SERVICES` environment variable can be used to whitelist the set of services allowed to be proxied through. This is opt-in - Not specifying this 92 | environment variable will whitelist all services. 93 | 94 | ```bash 95 | export AWS_JUPYTER_PROXY_WHITELISTED_SERVICES=sagemaker,s3 96 | jupyter-lab 97 | ``` 98 | 99 | ## Development 100 | 101 | Install all dev dependencies 102 | 103 | ```bash 104 | pip install -e ".[dev]" 105 | jupyter serverextension enable --py aws_jupyter_proxy --sys-prefix 106 | ``` 107 | 108 | Run unit tests using pytest 109 | 110 | ```bash 111 | pytest tests/unit 112 | ``` 113 | 114 | ## License 115 | 116 | This library is licensed under the Apache 2.0 License. 117 | -------------------------------------------------------------------------------- /aws_jupyter_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | from aws_jupyter_proxy.handlers import setup_handlers 2 | 3 | 4 | def _jupyter_server_extension_paths(): 5 | return [{"module": "aws_jupyter_proxy"}] 6 | 7 | 8 | def load_jupyter_server_extension(nbapp): 9 | setup_handlers(nbapp.web_app) 10 | -------------------------------------------------------------------------------- /aws_jupyter_proxy/awsconfig.py: -------------------------------------------------------------------------------- 1 | import json 2 | from botocore.session import get_session 3 | from notebook.base.handlers import APIHandler 4 | 5 | 6 | class AwsConfigHandler(APIHandler): 7 | async def get(self, *args): 8 | response = {"region": self._get_aws_region()} 9 | self.write(json.dumps(response)) 10 | 11 | def _get_aws_region(self): 12 | session = get_session() 13 | return session.get_config_variable("region") or None 14 | -------------------------------------------------------------------------------- /aws_jupyter_proxy/awsproxy.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import os 4 | import re 5 | from collections import namedtuple 6 | from functools import lru_cache 7 | from typing import List, Tuple 8 | from urllib.parse import urlparse, urlunparse, quote 9 | 10 | from botocore.client import ClientEndpointBridge 11 | from botocore.loaders import create_loader 12 | from botocore.model import ServiceModel 13 | from botocore.regions import EndpointResolver 14 | from botocore.session import Session 15 | from notebook.base.handlers import APIHandler 16 | from tornado.httpclient import ( 17 | AsyncHTTPClient, 18 | HTTPRequest, 19 | HTTPResponse, 20 | HTTPClientError, 21 | HTTPError, 22 | ) 23 | from tornado.httputil import HTTPServerRequest, HTTPHeaders 24 | 25 | ServiceInfo = namedtuple( 26 | "ServiceInfo", ["service_name", "host", "endpoint_url", "credential_scope"] 27 | ) 28 | UpstreamAuthInfo = namedtuple( 29 | "UpstreamAuthInfo", ["service_name", "region", "signed_headers"] 30 | ) 31 | 32 | 33 | # maxsize is arbitrarily taken from https://docs.python.org/3/library/functools.html#functools.lru_cache 34 | @lru_cache(maxsize=128) 35 | def get_service_info( 36 | endpoint_resolver: EndpointResolver, 37 | service_name: str, 38 | region: str, 39 | endpoint_override: str, 40 | ) -> ServiceInfo: 41 | service_model_json = create_loader().load_service_model(service_name, "service-2") 42 | 43 | service_data = ClientEndpointBridge(endpoint_resolver).resolve( 44 | service_name=ServiceModel( 45 | service_model_json, service_name=service_name 46 | ).endpoint_prefix, 47 | region_name=region, 48 | ) 49 | 50 | if endpoint_override and re.fullmatch( 51 | r"https:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.(aws.dev|amazonaws.com|aws.a2z.com)\b", 52 | endpoint_override, 53 | ): 54 | service_data["endpoint_url"] = endpoint_override 55 | 56 | return ServiceInfo( 57 | service_name, 58 | service_data["metadata"]["hostname"], 59 | service_data["endpoint_url"], 60 | service_data["metadata"].get("credentialScope"), 61 | ) 62 | 63 | 64 | def create_endpoint_resolver() -> EndpointResolver: 65 | """ 66 | Creates an instance of the botocore EndpointResolver. Used to inject the instance during application initialization 67 | to avoid loading endpoint data on a per-request basis. 68 | :return: the EndpointResolver instance 69 | """ 70 | return EndpointResolver(create_loader().load_data("endpoints")) 71 | 72 | 73 | class AwsProxyHandler(APIHandler): 74 | def initialize(self, endpoint_resolver: EndpointResolver, session: Session): 75 | """ 76 | Hook for Tornado handler initialization. 77 | :param session: the botocore session 78 | :param endpoint_resolver: the application level EndpointResolver instance 79 | """ 80 | self.endpoint_resolver = endpoint_resolver 81 | self.session = session 82 | 83 | async def handle_request(self): 84 | try: 85 | response = await AwsProxyRequest( 86 | self.request, self.endpoint_resolver, self.session 87 | ).execute_downstream() 88 | self.set_status(response.code, response.reason) 89 | self._finish_response(response) 90 | except HTTPClientError as e: 91 | self.set_status(e.code, e.message) 92 | if e.response: 93 | self._finish_response(e.response) 94 | else: 95 | super(APIHandler, self).finish() 96 | 97 | def _finish_response(self, response: HTTPResponse): 98 | for name, value in response.headers.get_all(): 99 | if self._is_blacklisted_response_header(name, value): 100 | continue 101 | self.set_header(name, value) 102 | csp_value = "frame-ancestors 'self'; report-uri /jupyter/default/api/security/csp-report; default-src 'none'; upgrade-insecure-requests; base-uri 'none'" 103 | self.set_header("Content-Security-Policy", csp_value) 104 | super(APIHandler, self).finish(response.body or None) 105 | 106 | async def post(self, *args): 107 | await self.handle_request() 108 | 109 | async def get(self, *args): 110 | await self.handle_request() 111 | 112 | async def delete(self, *args): 113 | await self.handle_request() 114 | 115 | async def patch(self, *args): 116 | await self.handle_request() 117 | 118 | async def put(self, *args): 119 | await self.handle_request() 120 | 121 | async def head(self, *args): 122 | await self.handle_request() 123 | 124 | @staticmethod 125 | def _is_blacklisted_response_header(name: str, value: str) -> bool: 126 | if name == "Transfer-Encoding" and value == "chunked": 127 | # Responses are no longer "chunked" when we send them to the browser. 128 | # If we retain this header, then the browser will wait forever for more chunks. 129 | return True 130 | elif name == "Content-Length": 131 | # Tornado will auto-set the Content-Length 132 | return True 133 | else: 134 | return False 135 | 136 | 137 | class AwsProxyRequest(object): 138 | """ 139 | A class representing a request being proxied from an upstream client (browser) to the downstream AWS service. 140 | """ 141 | 142 | BLACKLISTED_REQUEST_HEADERS: List[str] = ["Origin", "Host"] 143 | 144 | def __init__( 145 | self, 146 | upstream_request: HTTPServerRequest, 147 | endpoint_resolver: EndpointResolver, 148 | session: Session, 149 | ): 150 | """ 151 | :param upstream_request: The original upstream HTTP request from the client(browser) to Jupyter 152 | :param endpoint_resolver: The botocore endpoint_resolver instance 153 | """ 154 | self.upstream_request = upstream_request 155 | self.endpoint_resolver = endpoint_resolver 156 | 157 | self.credentials = session.get_credentials() 158 | 159 | self.upstream_auth_info = self._build_upstream_auth_info() 160 | self.service_info = get_service_info( 161 | endpoint_resolver, 162 | self.upstream_auth_info.service_name, 163 | self.upstream_auth_info.region, 164 | self.upstream_request.headers.get("X-service-endpoint-url", None), 165 | ) 166 | # if the environment variable is not specified, os.getenv returns None, and no whitelist is in effect. 167 | self.whitelisted_services = ( 168 | os.getenv("AWS_JUPYTER_PROXY_WHITELISTED_SERVICES").strip(",").split(",") 169 | if os.getenv("AWS_JUPYTER_PROXY_WHITELISTED_SERVICES") is not None 170 | else None 171 | ) 172 | 173 | async def execute_downstream(self) -> HTTPResponse: 174 | """ 175 | Executes the downstream request (Jupyter to AWS service) and return the response or the error 176 | after adding SigV4 authentication. 177 | 178 | "allow_nonstandard_methods" is used because Tornado rejects POST requests without a body without this parameter, 179 | and some operations send such requests (such as S3.InitiateMultipartUpload) 180 | :return: the HTTPResponse 181 | """ 182 | if ( 183 | self.whitelisted_services is not None 184 | and self.service_info.service_name not in self.whitelisted_services 185 | ): 186 | raise HTTPError( 187 | 403, 188 | message=f"Service {self.service_info.service_name} is not whitelisted for proxying requests", 189 | ) 190 | 191 | base_service_url = urlparse(self.service_info.endpoint_url) 192 | start_index = self.upstream_request.path.index("/awsproxy") + len("/awsproxy") 193 | downstream_request_path = ( 194 | base_service_url.path + self.upstream_request.path[start_index:] or "/" 195 | ) 196 | return await AsyncHTTPClient().fetch( 197 | HTTPRequest( 198 | method=self.upstream_request.method, 199 | url=self._compute_downstream_url(downstream_request_path), 200 | headers=self._compute_downstream_headers(downstream_request_path), 201 | body=self.upstream_request.body or None, 202 | follow_redirects=False, 203 | allow_nonstandard_methods=True, 204 | ) 205 | ) 206 | 207 | def _compute_downstream_url(self, downstream_request_path) -> str: 208 | base_service_url = urlparse(self.service_info.endpoint_url) 209 | return urlunparse( 210 | [ 211 | base_service_url.scheme, 212 | base_service_url.netloc, 213 | downstream_request_path, 214 | base_service_url.params, 215 | self.upstream_request.query, 216 | None, 217 | ] 218 | ) 219 | 220 | def _compute_downstream_headers(self, downstream_request_path) -> HTTPHeaders: 221 | """ 222 | 1. Copy original headers apart from blacklisted ones 223 | 2. Add the Host header based on the service model 224 | 3. Add a security token header if the current session is using temporary credentials 225 | 4. Add the SigV4 Authorization header. 226 | 227 | :param downstream_request_path: the URL path for the downstream service request 228 | :return: the headers to pass to the downstream request 229 | """ 230 | downstream_request_headers = self.upstream_request.headers.copy() 231 | for blacklisted_request_header in self.BLACKLISTED_REQUEST_HEADERS: 232 | try: 233 | del downstream_request_headers[blacklisted_request_header] 234 | except KeyError: 235 | pass 236 | 237 | base_service_url = urlparse(self.service_info.endpoint_url) 238 | downstream_request_headers["Host"] = base_service_url.netloc 239 | 240 | if self.credentials.token: 241 | downstream_request_headers["X-Amz-Security-Token"] = self.credentials.token 242 | 243 | downstream_request_headers["Authorization"] = self._sigv4_auth_header( 244 | downstream_request_path 245 | ) 246 | return downstream_request_headers 247 | 248 | def _sigv4_auth_header(self, downstream_request_path) -> str: 249 | """ 250 | Computes the SigV4 signature following https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html 251 | 252 | :param downstream_request_path: the URL path for the downstream service's request 253 | :return: the Authorization header containing SigV4 credetntials 254 | """ 255 | # ************* TASK 1: CREATE THE CANONICAL REQUEST************* 256 | canonical_method = self.upstream_request.method 257 | canonical_uri = quote(downstream_request_path) 258 | canonical_querystring = self._get_canonical_querystring() 259 | signed_headers, canonical_headers = self._get_signed_canonical_headers() 260 | payload_hash = hashlib.sha256(self.upstream_request.body).hexdigest() 261 | 262 | canonical_request = ( 263 | f"{canonical_method}\n" 264 | f"{canonical_uri}\n" 265 | f"{canonical_querystring}\n" 266 | f"{canonical_headers}\n" 267 | f"{signed_headers}\n" 268 | f"{payload_hash}" 269 | ) 270 | 271 | # ************* TASK 2: CREATE THE STRING TO SIGN************* 272 | algorithm = "AWS4-HMAC-SHA256" 273 | region = self._get_downstream_signing_region() 274 | amz_date = self.upstream_request.headers["X-Amz-Date"] 275 | date_stamp = amz_date[0:8] 276 | 277 | credential_scope = ( 278 | f"{date_stamp}/{region}/{self.service_info.service_name}/aws4_request" 279 | ) 280 | request_digest = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest() 281 | string_to_sign = ( 282 | f"{algorithm}\n" f"{amz_date}\n" f"{credential_scope}\n" f"{request_digest}" 283 | ) 284 | 285 | # ************* TASK 3: CALCULATE THE SIGNATURE ************* 286 | signing_key = get_signature_key( 287 | self.credentials.secret_key, 288 | date_stamp, 289 | region, 290 | self.service_info.service_name, 291 | ) 292 | signature = hmac.new( 293 | signing_key, string_to_sign.encode("utf-8"), hashlib.sha256 294 | ).hexdigest() 295 | 296 | # ************* TASK 4: BUILD THE AUTH HEADER ************* 297 | authorization_header = ( 298 | f"{algorithm} " 299 | f"Credential={self.credentials.access_key}/{credential_scope}, " 300 | f"SignedHeaders={signed_headers}, " 301 | f"Signature={signature}" 302 | ) 303 | 304 | return authorization_header 305 | 306 | def _get_canonical_querystring(self) -> str: 307 | canonical_query_string = "" 308 | corrected_request_query = self.upstream_request.query.replace("+", "%20") 309 | if corrected_request_query != "": 310 | query_string_list = [] 311 | for item in corrected_request_query.split("&"): 312 | query_string_part = item.split("=", maxsplit=1) 313 | if len(query_string_part) == 2: 314 | query_string_list.append(query_string_part) 315 | elif len(query_string_part) == 1: 316 | query_string_part.append("") 317 | query_string_list.append(query_string_part) 318 | else: 319 | raise ValueError(f"Invalid query string split for {item}") 320 | query_string_dict = dict(query_string_list) 321 | sorted_q_string_list = [ 322 | f"{k}={query_string_dict[k]}" for k in sorted(query_string_dict) 323 | ] 324 | canonical_query_string = "&".join(sorted_q_string_list) 325 | return canonical_query_string 326 | 327 | def _get_signed_canonical_headers(self) -> Tuple[str, str]: 328 | canonical_headers = {} 329 | 330 | for signed_header in self.upstream_auth_info.signed_headers: 331 | try: 332 | canonical_headers[signed_header] = self.upstream_request.headers[ 333 | signed_header 334 | ] 335 | except KeyError: 336 | raise HTTPError(400, message=f"Bad Request") 337 | 338 | base_service_url = urlparse(self.service_info.endpoint_url) 339 | canonical_headers["host"] = base_service_url.netloc 340 | if self.credentials.token: 341 | canonical_headers["x-amz-security-token"] = self.credentials.token 342 | 343 | canonical_headers_string = "\n".join( 344 | [ 345 | f"{canonical_header}:{canonical_headers[canonical_header]}" 346 | for canonical_header in sorted(canonical_headers) 347 | ] 348 | ) 349 | canonical_headers_string += "\n" 350 | signed_headers = ";".join(sorted(canonical_headers)) 351 | 352 | return signed_headers, canonical_headers_string 353 | 354 | def _get_downstream_signing_region(self) -> str: 355 | """ 356 | Get the region to sign the downstream request for. The default is the region that the request was originally 357 | signed, but if the service has a credentialScope override specified in the service config then that is used. 358 | :return: the region to sign the request with. 359 | """ 360 | if not self.service_info.credential_scope: 361 | return self.upstream_auth_info.region 362 | 363 | try: 364 | return self.service_info.credential_scope["region"] 365 | except KeyError: 366 | return self.upstream_auth_info.region 367 | 368 | def _build_upstream_auth_info(self) -> UpstreamAuthInfo: 369 | """ 370 | Parses the upstream requests's Authorization header to determine identifying information such as the region and 371 | the service the request was originally signed for. 372 | 373 | Sample header: 374 | AWS4-HMAC-SHA256 \ 375 | Credential=SOMEACCESSKEY/20190814/aws_region/aws_service/aws4_request, \ 376 | SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-target;x-amz-user-agent, \ 377 | Signature=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 378 | 379 | :return: the UpstreamAuthInfo instance 380 | """ 381 | 382 | try: 383 | auth_header_parts = self.upstream_request.headers["Authorization"].split( 384 | " " 385 | ) 386 | 387 | signed_headers = auth_header_parts[2].strip(",").split("=")[1].split(";") 388 | _, _, region, service_name, _ = ( 389 | auth_header_parts[1].split("=")[1].split("/") 390 | ) 391 | except KeyError: 392 | raise HTTPError(400, message=f"Missing Authorization header") 393 | except (IndexError, ValueError): 394 | raise HTTPError(400, message=f"Malformed Authorization header") 395 | 396 | return UpstreamAuthInfo(service_name, region, signed_headers) 397 | 398 | 399 | # Key derivation functions. See: 400 | # http://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-python 401 | def sign(key, msg): 402 | return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() 403 | 404 | 405 | def get_signature_key(key, date_stamp, region_name, service_name): 406 | k_date = sign(("AWS4" + key).encode("utf-8"), date_stamp) 407 | k_region = sign(k_date, region_name) 408 | k_service = sign(k_region, service_name) 409 | k_signing = sign(k_service, "aws4_request") 410 | return k_signing 411 | -------------------------------------------------------------------------------- /aws_jupyter_proxy/etc/aws_jupyter_proxy.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "aws_jupyter_proxy": true 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /aws_jupyter_proxy/handlers.py: -------------------------------------------------------------------------------- 1 | from botocore.session import Session 2 | from notebook.utils import url_path_join 3 | 4 | from aws_jupyter_proxy.awsproxy import create_endpoint_resolver, AwsProxyHandler 5 | from aws_jupyter_proxy.awsconfig import AwsConfigHandler 6 | 7 | awsproxy_handlers = [ 8 | ( 9 | "/awsproxy/awsconfig", 10 | AwsConfigHandler, 11 | None, 12 | ), 13 | ( 14 | r"/awsproxy(.*)", 15 | AwsProxyHandler, 16 | dict(endpoint_resolver=create_endpoint_resolver(), session=Session()), 17 | ), 18 | ] 19 | 20 | 21 | def setup_handlers(web_app): 22 | base_url = web_app.settings["base_url"] 23 | web_app.add_handlers( 24 | ".*", 25 | [ 26 | (url_path_join(base_url, path), handler, data) 27 | for (path, handler, data) in awsproxy_handlers 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="aws_jupyter_proxy", 8 | version="0.3.7", 9 | url="https://github.com/aws/aws-jupyter-proxy", 10 | author="Amazon Web Services", 11 | description="A Jupyter server extension to proxy requests with AWS SigV4 authentication", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | packages=setuptools.find_packages(), 15 | license="Apache License 2.0", 16 | install_requires=["notebook >=6.0, <7.0", "botocore >=1.0, <2.0"], 17 | extras_require={ 18 | "dev": [ 19 | "asynctest", 20 | "black", 21 | "pytest", 22 | "pytest-asyncio", 23 | "pytest-cov", 24 | ] 25 | }, 26 | python_requires=">=3.6", 27 | data_files=[ 28 | ( 29 | "etc/jupyter/jupyter_notebook_config.d", 30 | ["aws_jupyter_proxy/etc/aws_jupyter_proxy.json"], 31 | ) 32 | ], 33 | classifiers=["Development Status :: 4 - Beta"], 34 | include_package_data=True, 35 | ) 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws/aws-jupyter-proxy/f79683d88651686f3f0337a819d993a77b70e14f/tests/__init__.py -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws/aws-jupyter-proxy/f79683d88651686f3f0337a819d993a77b70e14f/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_awsconfig.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | from tornado.testing import AsyncHTTPTestCase 3 | from tornado.web import Application 4 | from aws_jupyter_proxy.awsconfig import AwsConfigHandler 5 | from aws_jupyter_proxy.handlers import awsproxy_handlers 6 | 7 | 8 | class TestHelloApp(AsyncHTTPTestCase): 9 | def get_app(self): 10 | return Application(awsproxy_handlers) 11 | 12 | @patch("aws_jupyter_proxy.awsconfig.get_session") 13 | def test_valid_region(self, mock_get_session): 14 | mock_get_session.return_value.get_config_variable.return_value = "us-west-1" 15 | response = self.fetch("/awsproxy/awsconfig") 16 | self.assertEqual(response.code, 200) 17 | self.assertEqual(response.body, b'{"region": "us-west-1"}') 18 | 19 | @patch("aws_jupyter_proxy.awsconfig.get_session") 20 | def test_default_region(self, mock_get_session): 21 | mock_get_session.return_value.get_config_variable.return_value = "" 22 | response = self.fetch("/awsproxy/awsconfig") 23 | self.assertEqual(response.code, 200) 24 | self.assertEqual(response.body, b'{"region": null}') 25 | 26 | @patch("aws_jupyter_proxy.awsconfig.get_session") 27 | def test_no_region(self, mock_get_session): 28 | mock_get_session.return_value.get_config_variable.return_value = None 29 | response = self.fetch("/awsproxy/awsconfig") 30 | self.assertEqual(response.code, 200) 31 | self.assertEqual(response.body, b'{"region": null}') 32 | -------------------------------------------------------------------------------- /tests/unit/test_awsproxy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from asynctest import Mock, patch, CoroutineMock 3 | from tornado.httpclient import HTTPRequest, HTTPClientError, HTTPError 4 | from tornado.httputil import HTTPServerRequest, HTTPHeaders 5 | 6 | from botocore.credentials import Credentials 7 | 8 | from aws_jupyter_proxy.awsproxy import AwsProxyRequest, create_endpoint_resolver 9 | 10 | 11 | @pytest.fixture 12 | def mock_session(): 13 | session = Mock() 14 | session.get_credentials.return_value = Credentials( 15 | "access_key", "secret_key", "session_token" 16 | ) 17 | return session 18 | 19 | 20 | @pytest.mark.asyncio 21 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 22 | async def test_post_with_body(mock_fetch, mock_session): 23 | # Given 24 | upstream_request = HTTPServerRequest( 25 | method="POST", 26 | uri="/awsproxy", 27 | headers=HTTPHeaders( 28 | { 29 | "Authorization": "AWS4-HMAC-SHA256 " 30 | "Credential=AKIDEXAMPLE/20190816/us-west-2/sagemaker/aws4_request, " 31 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-target;x-amz-user-agent, " 32 | "Signature=cfe54b727d00698b9940531b1c9e456fd70258adc41fb338896455fddd6f3f2f", 33 | "Host": "localhost:8888", 34 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 35 | "X-Amz-Content-Sha256": "a83a35dcbd19cfad5b714cb12b5275a4cfa7e1012b633d9206300f09e058e7fa", 36 | "X-Amz-Target": "SageMaker.ListNotebookInstances", 37 | "X-Amz-Date": "20190816T204930Z", 38 | } 39 | ), 40 | body=b'{"NameContains":"myname"}', 41 | host="localhost:8888", 42 | ) 43 | 44 | # When 45 | await AwsProxyRequest( 46 | upstream_request, create_endpoint_resolver(), mock_session 47 | ).execute_downstream() 48 | 49 | # Then 50 | expected = HTTPRequest( 51 | url="https://api.sagemaker.us-west-2.amazonaws.com/", 52 | method=upstream_request.method, 53 | body=b'{"NameContains":"myname"}', 54 | headers={ 55 | "Authorization": "AWS4-HMAC-SHA256 " 56 | "Credential=access_key/20190816/us-west-2/sagemaker/aws4_request, " 57 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 58 | "x-amz-security-token;x-amz-target;x-amz-user-agent, " 59 | "Signature=" 60 | "215b2e3656147651194acb6cca20d5cb01dd8f396ac941533fc3e52b7cb563dc", 61 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 62 | "X-Amz-Content-Sha256": "a83a35dcbd19cfad5b714cb12b5275a4cfa7e1012b633d9206300f09e058e7fa", 63 | "X-Amz-Target": "SageMaker.ListNotebookInstances", 64 | "X-Amz-Date": "20190816T204930Z", 65 | "X-Amz-Security-Token": "session_token", 66 | "Host": "api.sagemaker.us-west-2.amazonaws.com", 67 | }, 68 | follow_redirects=False, 69 | allow_nonstandard_methods=True, 70 | ) 71 | 72 | assert_http_response(mock_fetch, expected) 73 | 74 | 75 | @pytest.mark.asyncio 76 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 77 | async def test_success_endpoint_override(mock_fetch, mock_session): 78 | # Given 79 | upstream_request = HTTPServerRequest( 80 | method="POST", 81 | uri="/awsproxy", 82 | headers=HTTPHeaders( 83 | { 84 | "Authorization": "AWS4-HMAC-SHA256 " 85 | "Credential=AKIDEXAMPLE/20190816/us-west-2/sagemaker/aws4_request, " 86 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-target;x-amz-user-agent, " 87 | "Signature=cfe54b727d00698b9940531b1c9e456fd70258adc41fb338896455fddd6f3f2f", 88 | "Host": "localhost:8888", 89 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 90 | "X-Amz-Content-Sha256": "a83a35dcbd19cfad5b714cb12b5275a4cfa7e1012b633d9206300f09e058e7fa", 91 | "X-Amz-Target": "SageMaker.ListNotebookInstances", 92 | "X-Amz-Date": "20190816T204930Z", 93 | "X-service-endpoint-url": "https://axis.us-west-2.amazonaws.com", 94 | } 95 | ), 96 | body=b'{"NameContains":"myname"}', 97 | host="localhost:8888", 98 | ) 99 | 100 | # When 101 | await AwsProxyRequest( 102 | upstream_request, create_endpoint_resolver(), mock_session 103 | ).execute_downstream() 104 | 105 | # Then 106 | expected = HTTPRequest( 107 | url="https://axis.us-west-2.amazonaws.com/", 108 | method=upstream_request.method, 109 | body=b'{"NameContains":"myname"}', 110 | headers={ 111 | "Authorization": "AWS4-HMAC-SHA256 " 112 | "Credential=access_key/20190816/us-west-2/sagemaker/aws4_request, " 113 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 114 | "x-amz-security-token;x-amz-target;x-amz-user-agent, " 115 | "Signature=" 116 | "e21ff53bc657eaa68ee08193cccdeb82027692d45d6ea57a5c3364e7fc95954e", 117 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 118 | "X-Amz-Content-Sha256": "a83a35dcbd19cfad5b714cb12b5275a4cfa7e1012b633d9206300f09e058e7fa", 119 | "X-Amz-Target": "SageMaker.ListNotebookInstances", 120 | "X-Amz-Date": "20190816T204930Z", 121 | "X-Service-Endpoint-Url": "https://axis.us-west-2.amazonaws.com", 122 | "Host": "axis.us-west-2.amazonaws.com", 123 | "X-Amz-Security-Token": "session_token", 124 | }, 125 | follow_redirects=False, 126 | allow_nonstandard_methods=True, 127 | ) 128 | 129 | assert_http_response(mock_fetch, expected) 130 | 131 | 132 | @pytest.mark.asyncio 133 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 134 | async def test_wrongurl_endpoint_override(mock_fetch, mock_session): 135 | # Given 136 | upstream_request = HTTPServerRequest( 137 | method="POST", 138 | uri="/awsproxy", 139 | headers=HTTPHeaders( 140 | { 141 | "Authorization": "AWS4-HMAC-SHA256 " 142 | "Credential=AKIDEXAMPLE/20190816/us-west-2/sagemaker/aws4_request, " 143 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-target;x-amz-user-agent, " 144 | "Signature=cfe54b727d00698b9940531b1c9e456fd70258adc41fb338896455fddd6f3f2f", 145 | "Host": "localhost:8888", 146 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 147 | "X-Amz-Content-Sha256": "a83a35dcbd19cfad5b714cb12b5275a4cfa7e1012b633d9206300f09e058e7fa", 148 | "X-Amz-Target": "SageMaker.ListNotebookInstances", 149 | "X-Amz-Date": "20190816T204930Z", 150 | "X-service-endpoint-url": "https://aws.amazon.com", 151 | } 152 | ), 153 | body=b'{"NameContains":"myname"}', 154 | host="localhost:8888", 155 | ) 156 | 157 | # When 158 | await AwsProxyRequest( 159 | upstream_request, create_endpoint_resolver(), mock_session 160 | ).execute_downstream() 161 | 162 | # Then 163 | expected = HTTPRequest( 164 | url="https://api.sagemaker.us-west-2.amazonaws.com/", 165 | method=upstream_request.method, 166 | body=b'{"NameContains":"myname"}', 167 | headers={ 168 | "Authorization": "AWS4-HMAC-SHA256 " 169 | "Credential=access_key/20190816/us-west-2/sagemaker/aws4_request, " 170 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 171 | "x-amz-security-token;x-amz-target;x-amz-user-agent, " 172 | "Signature=" 173 | "215b2e3656147651194acb6cca20d5cb01dd8f396ac941533fc3e52b7cb563dc", 174 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 175 | "X-Amz-Content-Sha256": "a83a35dcbd19cfad5b714cb12b5275a4cfa7e1012b633d9206300f09e058e7fa", 176 | "X-Amz-Target": "SageMaker.ListNotebookInstances", 177 | "X-Amz-Date": "20190816T204930Z", 178 | "X-Service-Endpoint-Url": "https://aws.amazon.com", 179 | "Host": "api.sagemaker.us-west-2.amazonaws.com", 180 | "X-Amz-Security-Token": "session_token", 181 | }, 182 | follow_redirects=False, 183 | allow_nonstandard_methods=True, 184 | ) 185 | 186 | assert_http_response(mock_fetch, expected) 187 | 188 | 189 | @pytest.mark.asyncio 190 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 191 | async def test_nonhttp_endpoint_override(mock_fetch, mock_session): 192 | # Given 193 | upstream_request = HTTPServerRequest( 194 | method="POST", 195 | uri="/awsproxy", 196 | headers=HTTPHeaders( 197 | { 198 | "Authorization": "AWS4-HMAC-SHA256 " 199 | "Credential=AKIDEXAMPLE/20190816/us-west-2/sagemaker/aws4_request, " 200 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-target;x-amz-user-agent, " 201 | "Signature=cfe54b727d00698b9940531b1c9e456fd70258adc41fb338896455fddd6f3f2f", 202 | "Host": "localhost:8888", 203 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 204 | "X-Amz-Content-Sha256": "a83a35dcbd19cfad5b714cb12b5275a4cfa7e1012b633d9206300f09e058e7fa", 205 | "X-Amz-Target": "SageMaker.ListNotebookInstances", 206 | "X-Amz-Date": "20190816T204930Z", 207 | "X-service-endpoint-url": "http://axis.us-west-2.amazonaws.com", 208 | } 209 | ), 210 | body=b'{"NameContains":"myname"}', 211 | host="localhost:8888", 212 | ) 213 | 214 | # When 215 | await AwsProxyRequest( 216 | upstream_request, create_endpoint_resolver(), mock_session 217 | ).execute_downstream() 218 | 219 | # Then 220 | expected = HTTPRequest( 221 | url="https://api.sagemaker.us-west-2.amazonaws.com/", 222 | method=upstream_request.method, 223 | body=b'{"NameContains":"myname"}', 224 | headers={ 225 | "Authorization": "AWS4-HMAC-SHA256 " 226 | "Credential=access_key/20190816/us-west-2/sagemaker/aws4_request, " 227 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 228 | "x-amz-security-token;x-amz-target;x-amz-user-agent, " 229 | "Signature=" 230 | "215b2e3656147651194acb6cca20d5cb01dd8f396ac941533fc3e52b7cb563dc", 231 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 232 | "X-Amz-Content-Sha256": "a83a35dcbd19cfad5b714cb12b5275a4cfa7e1012b633d9206300f09e058e7fa", 233 | "X-Amz-Target": "SageMaker.ListNotebookInstances", 234 | "X-Amz-Date": "20190816T204930Z", 235 | "X-Service-Endpoint-Url": "http://axis.us-west-2.amazonaws.com", 236 | "Host": "api.sagemaker.us-west-2.amazonaws.com", 237 | "X-Amz-Security-Token": "session_token", 238 | }, 239 | follow_redirects=False, 240 | allow_nonstandard_methods=True, 241 | ) 242 | 243 | assert_http_response(mock_fetch, expected) 244 | 245 | 246 | @pytest.mark.asyncio 247 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 248 | async def test_errors_passed_through(mock_fetch, mock_session): 249 | # Given 250 | upstream_request = HTTPServerRequest( 251 | method="GET", 252 | uri="/awsproxy/clusters/myname", 253 | headers=HTTPHeaders( 254 | { 255 | "Authorization": "AWS4-HMAC-SHA256 " 256 | "Credential=AKIDEXAMPLE/20190816/us-east-2/eks/aws4_request, " 257 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 258 | "Signature=f176ff82da6efc539bb8a2860be6ea19a99adf93d87be8ba96f25f1d29c91ba9", 259 | "Host": "localhost:8888", 260 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 261 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 262 | "X-Amz-Date": "20190816T224244Z", 263 | } 264 | ), 265 | body=b"", 266 | host="localhost:8888", 267 | ) 268 | mock_fetch.side_effect = HTTPClientError(code=500, message="Something bad") 269 | 270 | # When 271 | with pytest.raises(HTTPClientError) as e: 272 | await AwsProxyRequest( 273 | upstream_request, create_endpoint_resolver(), mock_session 274 | ).execute_downstream() 275 | 276 | assert 500 == e.value.code 277 | assert "Something bad" == e.value.message 278 | 279 | 280 | @pytest.mark.asyncio 281 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 282 | async def test_get_with_path(mock_fetch, mock_session): 283 | # Given 284 | upstream_request = HTTPServerRequest( 285 | method="GET", 286 | uri="/awsproxy/clusters/myname", 287 | headers=HTTPHeaders( 288 | { 289 | "Authorization": "AWS4-HMAC-SHA256 " 290 | "Credential=AKIDEXAMPLE/20190816/us-east-2/eks/aws4_request, " 291 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 292 | "Signature=f176ff82da6efc539bb8a2860be6ea19a99adf93d87be8ba96f25f1d29c91ba9", 293 | "Host": "localhost:8888", 294 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 295 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 296 | "X-Amz-Date": "20190816T224244Z", 297 | } 298 | ), 299 | body=b"", 300 | host="localhost:8888", 301 | ) 302 | 303 | # When 304 | await AwsProxyRequest( 305 | upstream_request, create_endpoint_resolver(), mock_session 306 | ).execute_downstream() 307 | 308 | # Then 309 | expected = HTTPRequest( 310 | url="https://eks.us-east-2.amazonaws.com/clusters/myname", 311 | method=upstream_request.method, 312 | body=None, 313 | headers={ 314 | "Authorization": "AWS4-HMAC-SHA256 " 315 | "Credential=access_key/20190816/us-east-2/eks/aws4_request, " 316 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 317 | "x-amz-security-token;x-amz-user-agent, " 318 | "Signature=" 319 | "0bab991ebb02f7f2ea44e3687778e930e5f66e1111b958a9c2ff88aba2eaf3da", 320 | "X-Amz-User-Agent": upstream_request.headers["X-Amz-User-Agent"], 321 | "X-Amz-Content-Sha256": upstream_request.headers["X-Amz-Content-Sha256"], 322 | "X-Amz-Date": upstream_request.headers["X-Amz-Date"], 323 | "X-Amz-Security-Token": "session_token", 324 | "Host": "eks.us-east-2.amazonaws.com", 325 | }, 326 | follow_redirects=False, 327 | allow_nonstandard_methods=True, 328 | ) 329 | 330 | assert_http_response(mock_fetch, expected) 331 | 332 | 333 | @pytest.mark.asyncio 334 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 335 | async def test_get_with_query(mock_fetch, mock_session): 336 | # Given 337 | upstream_request = HTTPServerRequest( 338 | method="GET", 339 | uri="/awsproxy/clusters?maxResults=1", 340 | headers=HTTPHeaders( 341 | { 342 | "Authorization": "AWS4-HMAC-SHA256 " 343 | "Credential=AKIDEXAMPLE/20190816/us-east-2/eks/aws4_request, " 344 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 345 | "Signature=24203e07130d74b28845f756f5440603d24400d53d07ddda9d7add99d5ec7c8d", 346 | "Host": "localhost:8888", 347 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 348 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 349 | "X-Amz-Date": "20190816T224244Z", 350 | } 351 | ), 352 | body=b"", 353 | host="localhost:8888", 354 | ) 355 | 356 | # When 357 | await AwsProxyRequest( 358 | upstream_request, create_endpoint_resolver(), mock_session 359 | ).execute_downstream() 360 | 361 | # Then 362 | expected = HTTPRequest( 363 | url="https://eks.us-east-2.amazonaws.com/clusters?maxResults=1", 364 | method=upstream_request.method, 365 | body=None, 366 | headers={ 367 | "Authorization": "AWS4-HMAC-SHA256 " 368 | "Credential=access_key/20190816/us-east-2/eks/aws4_request, " 369 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 370 | "x-amz-security-token;x-amz-user-agent, " 371 | "Signature=" 372 | "61315054f93efa316230cbe77497522b8db692969104ec4c935235e14ad1c23f", 373 | "X-Amz-User-Agent": upstream_request.headers["X-Amz-User-Agent"], 374 | "X-Amz-Content-Sha256": upstream_request.headers["X-Amz-Content-Sha256"], 375 | "X-Amz-Date": upstream_request.headers["X-Amz-Date"], 376 | "X-Amz-Security-Token": "session_token", 377 | "Host": "eks.us-east-2.amazonaws.com", 378 | }, 379 | follow_redirects=False, 380 | allow_nonstandard_methods=True, 381 | ) 382 | 383 | assert_http_response(mock_fetch, expected) 384 | 385 | 386 | @pytest.mark.asyncio 387 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 388 | async def test_delete(mock_fetch, mock_session): 389 | # Given 390 | upstream_request = HTTPServerRequest( 391 | method="DELETE", 392 | uri="/awsproxy/clusters/myname", 393 | headers=HTTPHeaders( 394 | { 395 | "Authorization": "AWS4-HMAC-SHA256 " 396 | "Credential=AKIDEXAMPLE/20190816/us-east-2/eks/aws4_request, " 397 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 398 | "Signature=24203e07130d74b28845f756f5440603d24400d53d07ddda9d7add99d5ec7c8d", 399 | "Host": "localhost:8888", 400 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 401 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 402 | "X-Amz-Date": "20190816T224244Z", 403 | } 404 | ), 405 | body=b"", 406 | host="localhost:8888", 407 | ) 408 | 409 | # When 410 | await AwsProxyRequest( 411 | upstream_request, create_endpoint_resolver(), mock_session 412 | ).execute_downstream() 413 | 414 | # Then 415 | expected = HTTPRequest( 416 | url="https://eks.us-east-2.amazonaws.com/clusters/myname", 417 | method=upstream_request.method, 418 | body=None, 419 | headers={ 420 | "Authorization": "AWS4-HMAC-SHA256 " 421 | "Credential=access_key/20190816/us-east-2/eks/aws4_request, " 422 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 423 | "x-amz-security-token;x-amz-user-agent, " 424 | "Signature=" 425 | "6fde2ff4f8aa9582d8740b6b7108a1a7f24ec3807a15c79f6e688ef4f4eaae35", 426 | "X-Amz-User-Agent": upstream_request.headers["X-Amz-User-Agent"], 427 | "X-Amz-Content-Sha256": upstream_request.headers["X-Amz-Content-Sha256"], 428 | "X-Amz-Date": upstream_request.headers["X-Amz-Date"], 429 | "X-Amz-Security-Token": "session_token", 430 | "Host": "eks.us-east-2.amazonaws.com", 431 | }, 432 | follow_redirects=False, 433 | allow_nonstandard_methods=True, 434 | ) 435 | 436 | assert_http_response(mock_fetch, expected) 437 | 438 | 439 | @pytest.mark.asyncio 440 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 441 | async def test_put(mock_fetch, mock_session): 442 | # Given 443 | upstream_request = HTTPServerRequest( 444 | method="PUT", 445 | uri="/awsproxy/clusters/myname", 446 | headers=HTTPHeaders( 447 | { 448 | "Authorization": "AWS4-HMAC-SHA256 " 449 | "Credential=AKIDEXAMPLE/20190816/us-east-2/eks/aws4_request, " 450 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 451 | "Signature=24203e07130d74b28845f756f5440603d24400d53d07ddda9d7add99d5ec7c8d", 452 | "Host": "localhost:8888", 453 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 454 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 455 | "X-Amz-Date": "20190816T224244Z", 456 | } 457 | ), 458 | body=b'{"Name":"Foo","Id":"Bar"}', 459 | host="localhost:8888", 460 | ) 461 | 462 | # When 463 | await AwsProxyRequest( 464 | upstream_request, create_endpoint_resolver(), mock_session 465 | ).execute_downstream() 466 | 467 | # Then 468 | expected = HTTPRequest( 469 | url="https://eks.us-east-2.amazonaws.com/clusters/myname", 470 | method=upstream_request.method, 471 | body=upstream_request.body, 472 | headers={ 473 | "Authorization": "AWS4-HMAC-SHA256 " 474 | "Credential=access_key/20190816/us-east-2/eks/aws4_request, " 475 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 476 | "x-amz-security-token;x-amz-user-agent, " 477 | "Signature=" 478 | "b6d04b91fa9e1993657806821321eb10a665e89d6de2f390fa39d40d77015971", 479 | "X-Amz-User-Agent": upstream_request.headers["X-Amz-User-Agent"], 480 | "X-Amz-Content-Sha256": upstream_request.headers["X-Amz-Content-Sha256"], 481 | "X-Amz-Date": upstream_request.headers["X-Amz-Date"], 482 | "X-Amz-Security-Token": "session_token", 483 | "Host": "eks.us-east-2.amazonaws.com", 484 | }, 485 | follow_redirects=False, 486 | allow_nonstandard_methods=True, 487 | ) 488 | 489 | assert_http_response(mock_fetch, expected) 490 | 491 | 492 | @pytest.mark.asyncio 493 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 494 | async def test_post_with_query_params_and_body(mock_fetch, mock_session): 495 | # Given 496 | upstream_request = HTTPServerRequest( 497 | method="POST", 498 | uri="/awsproxy/bucket-name-1/Hello.txt?select&select-type=2", 499 | headers=HTTPHeaders( 500 | { 501 | "Authorization": "AWS4-HMAC-SHA256 " 502 | "Credential=AKIDEXAMPLE/20190828/us-west-2/s3/aws4_request, " 503 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 504 | "Signature=451d7037903cee381c6a5d2c61c6ee2b5d36f35650e95abcf5e8af11b57c0cf8", 505 | "Host": "localhost:8888", 506 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 507 | "X-Amz-Content-Sha256": "f5b0fecc75eeaba2a9c92703363f4b9e3efa3f7811d3904f0b71cf05c3895228", 508 | "X-Amz-Date": "20190828T173626Z", 509 | } 510 | ), 511 | body=b'' 512 | b"select * from S3Object" 513 | b"SQL" 514 | b"" 515 | b"Lines" 516 | b"" 517 | b"," 518 | b"", 519 | host="localhost:8888", 520 | ) 521 | 522 | # When 523 | await AwsProxyRequest( 524 | upstream_request, create_endpoint_resolver(), mock_session 525 | ).execute_downstream() 526 | 527 | # Then 528 | expected = HTTPRequest( 529 | url="https://s3.us-west-2.amazonaws.com/bucket-name-1/Hello.txt?select&select-type=2", 530 | method=upstream_request.method, 531 | body=upstream_request.body, 532 | headers={ 533 | "Authorization": "AWS4-HMAC-SHA256 " 534 | "Credential=access_key/20190828/us-west-2/s3/aws4_request, " 535 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 536 | "x-amz-security-token;x-amz-user-agent, " 537 | "Signature=" 538 | "e55d97f45ca6862d9c2518868dd2a8c383007df1c991e42ef5950a46a4c13f8e", 539 | "X-Amz-User-Agent": upstream_request.headers["X-Amz-User-Agent"], 540 | "X-Amz-Content-Sha256": upstream_request.headers["X-Amz-Content-Sha256"], 541 | "X-Amz-Date": upstream_request.headers["X-Amz-Date"], 542 | "X-Amz-Security-Token": "session_token", 543 | "Host": "s3.us-west-2.amazonaws.com", 544 | }, 545 | follow_redirects=False, 546 | allow_nonstandard_methods=True, 547 | ) 548 | 549 | assert_http_response(mock_fetch, expected) 550 | 551 | 552 | @pytest.mark.asyncio 553 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 554 | async def test_post_with_query_params_no_body(mock_fetch, mock_session): 555 | # Given 556 | upstream_request = HTTPServerRequest( 557 | method="POST", 558 | uri="/awsproxy/bucket-name-1/Multipart-0.16441670919496487.txt?uploads", 559 | headers=HTTPHeaders( 560 | { 561 | "Authorization": "AWS4-HMAC-SHA256 " 562 | "Credential=AKIDEXAMPLE/20190828/us-west-2/s3/aws4_request, " 563 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 564 | "Signature=451d7037903cee381c6a5d2c61c6ee2b5d36f35650e95abcf5e8af11b57c0cf8", 565 | "Host": "localhost:8888", 566 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 567 | "X-Amz-Content-Sha256": "a08b81ef3b4ec5e1f65ca97d00928105c3d7eb6d50ae59fc15f0d14b64c9ec3b", 568 | "X-Amz-Date": "20190828T173626Z", 569 | } 570 | ), 571 | body=b"", 572 | host="localhost:8888", 573 | ) 574 | 575 | # When 576 | await AwsProxyRequest( 577 | upstream_request, create_endpoint_resolver(), mock_session 578 | ).execute_downstream() 579 | 580 | # Then 581 | expected = HTTPRequest( 582 | url="https://s3.us-west-2.amazonaws.com/bucket-name-1/Multipart-0.16441670919496487.txt" 583 | "?uploads", 584 | method=upstream_request.method, 585 | body=None, 586 | headers={ 587 | "Authorization": "AWS4-HMAC-SHA256 " 588 | "Credential=access_key/20190828/us-west-2/s3/aws4_request, " 589 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 590 | "x-amz-security-token;x-amz-user-agent, " 591 | "Signature=" 592 | "ba2062818cb4cd80a73dd43d006f141ede069b8ccc2ece16c20504587bd5045b", 593 | "X-Amz-User-Agent": upstream_request.headers["X-Amz-User-Agent"], 594 | "X-Amz-Content-Sha256": upstream_request.headers["X-Amz-Content-Sha256"], 595 | "X-Amz-Date": upstream_request.headers["X-Amz-Date"], 596 | "X-Amz-Security-Token": "session_token", 597 | "Host": "s3.us-west-2.amazonaws.com", 598 | }, 599 | follow_redirects=False, 600 | allow_nonstandard_methods=True, 601 | ) 602 | 603 | assert_http_response(mock_fetch, expected) 604 | 605 | 606 | @pytest.mark.asyncio 607 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 608 | async def test_head_request(mock_fetch, mock_session): 609 | # Given 610 | upstream_request = HTTPServerRequest( 611 | method="HEAD", 612 | uri="/awsproxy/bucket-name-1", 613 | headers=HTTPHeaders( 614 | { 615 | "Authorization": "AWS4-HMAC-SHA256 " 616 | "Credential=AKIDEXAMPLE/20190828/us-west-2/s3/aws4_request, " 617 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 618 | "Signature=0d02795c4feed38e5a4cd80aec3a2c67886b11797a23c307e4f52c2cfe0c137e", 619 | "Host": "localhost:8888", 620 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 621 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 622 | "X-Amz-Date": "20190828T173626Z", 623 | } 624 | ), 625 | body=None, 626 | host="localhost:8888", 627 | ) 628 | 629 | # When 630 | await AwsProxyRequest( 631 | upstream_request, create_endpoint_resolver(), mock_session 632 | ).execute_downstream() 633 | 634 | # Then 635 | expected = HTTPRequest( 636 | url="https://s3.us-west-2.amazonaws.com/bucket-name-1", 637 | method=upstream_request.method, 638 | body=None, 639 | headers={ 640 | "Authorization": "AWS4-HMAC-SHA256 " 641 | "Credential=access_key/20190828/us-west-2/s3/aws4_request, " 642 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 643 | "x-amz-security-token;x-amz-user-agent, " 644 | "Signature=" 645 | "6d724e3bd64390d5d84010d6fc0f8147b3e3917c5befa3f8d1efb691b408e821", 646 | "X-Amz-User-Agent": upstream_request.headers["X-Amz-User-Agent"], 647 | "X-Amz-Content-Sha256": upstream_request.headers["X-Amz-Content-Sha256"], 648 | "X-Amz-Date": upstream_request.headers["X-Amz-Date"], 649 | "X-Amz-Security-Token": "session_token", 650 | "Host": "s3.us-west-2.amazonaws.com", 651 | }, 652 | follow_redirects=False, 653 | allow_nonstandard_methods=True, 654 | ) 655 | 656 | assert_http_response(mock_fetch, expected) 657 | 658 | 659 | @pytest.mark.asyncio 660 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 661 | async def test_get_with_encoded_uri(mock_fetch, mock_session): 662 | # Given 663 | upstream_request = HTTPServerRequest( 664 | method="GET", 665 | uri="/awsproxy/bucket-name-1/ll%3A%3Askeleton%201.png", 666 | headers=HTTPHeaders( 667 | { 668 | "Authorization": "AWS4-HMAC-SHA256 " 669 | "Credential=AKIDEXAMPLE/20190816/us-west-2/s3/aws4_request, " 670 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 671 | "Signature=822f116d22d577aa2dc1033f354fa2a6fd3a2b6a0fd51885472b57daf45d605e", 672 | "Host": "localhost:8888", 673 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 674 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 675 | "X-Amz-Date": "20190816T224244Z", 676 | } 677 | ), 678 | body=b"", 679 | host="localhost:8888", 680 | ) 681 | 682 | # When 683 | await AwsProxyRequest( 684 | upstream_request, create_endpoint_resolver(), mock_session 685 | ).execute_downstream() 686 | 687 | # Then 688 | expected = HTTPRequest( 689 | url="https://s3.us-west-2.amazonaws.com/bucket-name-1/ll%3A%3Askeleton%201.png", 690 | method=upstream_request.method, 691 | body=None, 692 | headers={ 693 | "Authorization": "AWS4-HMAC-SHA256 " 694 | "Credential=access_key/20190816/us-west-2/s3/aws4_request, " 695 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 696 | "x-amz-security-token;x-amz-user-agent, " 697 | "Signature=" 698 | "4715991ba2461bfda29bda8a53747a13448c1303c2e03d8ee8a4992df08f5551", 699 | "X-Amz-User-Agent": upstream_request.headers["X-Amz-User-Agent"], 700 | "X-Amz-Content-Sha256": upstream_request.headers["X-Amz-Content-Sha256"], 701 | "X-Amz-Date": upstream_request.headers["X-Amz-Date"], 702 | "X-Amz-Security-Token": "session_token", 703 | "Host": "s3.us-west-2.amazonaws.com", 704 | }, 705 | follow_redirects=False, 706 | allow_nonstandard_methods=True, 707 | ) 708 | 709 | assert_http_response(mock_fetch, expected) 710 | 711 | 712 | @pytest.mark.asyncio 713 | @patch("os.getenv") 714 | async def test_request_not_whitelisted(mock_getenv, mock_session): 715 | # Given 716 | upstream_request = HTTPServerRequest( 717 | method="HEAD", 718 | uri="/awsproxy/bucket-name-1", 719 | headers=HTTPHeaders( 720 | { 721 | "Authorization": "AWS4-HMAC-SHA256 " 722 | "Credential=AKIDEXAMPLE/20190828/us-west-2/s3/aws4_request, " 723 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 724 | "Signature=0d02795c4feed38e5a4cd80aec3a2c67886b11797a23c307e4f52c2cfe0c137e", 725 | "Host": "localhost:8888", 726 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 727 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 728 | "X-Amz-Date": "20190828T173626Z", 729 | } 730 | ), 731 | body=None, 732 | host="localhost:8888", 733 | ) 734 | mock_getenv.return_value = "sagemaker,eks," 735 | 736 | # When 737 | with pytest.raises(HTTPError) as e: 738 | await AwsProxyRequest( 739 | upstream_request, create_endpoint_resolver(), mock_session 740 | ).execute_downstream() 741 | 742 | # Then 743 | assert 403 == e.value.code 744 | assert "Service s3 is not whitelisted for proxying requests" == e.value.message 745 | 746 | 747 | @pytest.mark.asyncio 748 | @patch("os.getenv") 749 | async def test_nothing_whitelisted(mock_getenv, mock_session): 750 | # Given 751 | upstream_request = HTTPServerRequest( 752 | method="HEAD", 753 | uri="/awsproxy/bucket-name-1", 754 | headers=HTTPHeaders( 755 | { 756 | "Authorization": "AWS4-HMAC-SHA256 " 757 | "Credential=AKIDEXAMPLE/20190828/us-west-2/s3/aws4_request, " 758 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 759 | "Signature=0d02795c4feed38e5a4cd80aec3a2c67886b11797a23c307e4f52c2cfe0c137e", 760 | "Host": "localhost:8888", 761 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 762 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 763 | "X-Amz-Date": "20190828T173626Z", 764 | } 765 | ), 766 | body=None, 767 | host="localhost:8888", 768 | ) 769 | mock_getenv.return_value = "" 770 | 771 | # When 772 | with pytest.raises(HTTPError) as e: 773 | await AwsProxyRequest( 774 | upstream_request, create_endpoint_resolver(), mock_session 775 | ).execute_downstream() 776 | 777 | # Then 778 | assert 403 == e.value.code 779 | assert "Service s3 is not whitelisted for proxying requests" == e.value.message 780 | 781 | 782 | @pytest.mark.asyncio 783 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 784 | @patch("os.getenv") 785 | async def test_request_whitelisted(mock_getenv, mock_fetch, mock_session): 786 | # Given 787 | upstream_request = HTTPServerRequest( 788 | method="HEAD", 789 | uri="/awsproxy/bucket-name-1", 790 | headers=HTTPHeaders( 791 | { 792 | "Authorization": "AWS4-HMAC-SHA256 " 793 | "Credential=AKIDEXAMPLE/20190828/us-west-2/s3/aws4_request, " 794 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 795 | "Signature=0d02795c4feed38e5a4cd80aec3a2c67886b11797a23c307e4f52c2cfe0c137e", 796 | "Host": "localhost:8888", 797 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 798 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 799 | "X-Amz-Date": "20190828T173626Z", 800 | } 801 | ), 802 | body=None, 803 | host="localhost:8888", 804 | ) 805 | mock_getenv.return_value = "s3," 806 | 807 | # When 808 | await AwsProxyRequest( 809 | upstream_request, create_endpoint_resolver(), mock_session 810 | ).execute_downstream() 811 | 812 | # Then 813 | expected = HTTPRequest( 814 | url="https://s3.us-west-2.amazonaws.com/bucket-name-1", 815 | method=upstream_request.method, 816 | body=None, 817 | headers={ 818 | "Authorization": "AWS4-HMAC-SHA256 " 819 | "Credential=access_key/20190828/us-west-2/s3/aws4_request, " 820 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 821 | "x-amz-security-token;x-amz-user-agent, " 822 | "Signature=" 823 | "6d724e3bd64390d5d84010d6fc0f8147b3e3917c5befa3f8d1efb691b408e821", 824 | "X-Amz-User-Agent": upstream_request.headers["X-Amz-User-Agent"], 825 | "X-Amz-Content-Sha256": upstream_request.headers["X-Amz-Content-Sha256"], 826 | "X-Amz-Date": upstream_request.headers["X-Amz-Date"], 827 | "X-Amz-Security-Token": "session_token", 828 | "Host": "s3.us-west-2.amazonaws.com", 829 | }, 830 | follow_redirects=False, 831 | allow_nonstandard_methods=True, 832 | ) 833 | 834 | assert_http_response(mock_fetch, expected) 835 | 836 | 837 | @pytest.mark.asyncio 838 | @patch("tornado.httpclient.AsyncHTTPClient.fetch", new_callable=CoroutineMock) 839 | @patch("os.getenv") 840 | async def test_request_with_base_url(mock_getenv, mock_fetch, mock_session): 841 | # Given 842 | upstream_request = HTTPServerRequest( 843 | method="HEAD", 844 | uri="base-url/awsproxy/bucket-name-1", 845 | headers=HTTPHeaders( 846 | { 847 | "Authorization": "AWS4-HMAC-SHA256 " 848 | "Credential=AKIDEXAMPLE/20190828/us-west-2/s3/aws4_request, " 849 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, " 850 | "Signature=0d02795c4feed38e5a4cd80aec3a2c67886b11797a23c307e4f52c2cfe0c137e", 851 | "Host": "localhost:8888", 852 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 853 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 854 | "X-Amz-Date": "20190828T173626Z", 855 | } 856 | ), 857 | body=None, 858 | host="localhost:8888", 859 | ) 860 | mock_getenv.return_value = "s3," 861 | 862 | # When 863 | await AwsProxyRequest( 864 | upstream_request, create_endpoint_resolver(), mock_session 865 | ).execute_downstream() 866 | 867 | # Then 868 | expected = HTTPRequest( 869 | url="https://s3.us-west-2.amazonaws.com/bucket-name-1", 870 | method=upstream_request.method, 871 | body=None, 872 | headers={ 873 | "Authorization": "AWS4-HMAC-SHA256 " 874 | "Credential=access_key/20190828/us-west-2/s3/aws4_request, " 875 | "SignedHeaders=host;x-amz-content-sha256;x-amz-date;" 876 | "x-amz-security-token;x-amz-user-agent, " 877 | "Signature=" 878 | "6d724e3bd64390d5d84010d6fc0f8147b3e3917c5befa3f8d1efb691b408e821", 879 | "X-Amz-User-Agent": upstream_request.headers["X-Amz-User-Agent"], 880 | "X-Amz-Content-Sha256": upstream_request.headers["X-Amz-Content-Sha256"], 881 | "X-Amz-Date": upstream_request.headers["X-Amz-Date"], 882 | "X-Amz-Security-Token": "session_token", 883 | "Host": "s3.us-west-2.amazonaws.com", 884 | }, 885 | follow_redirects=False, 886 | allow_nonstandard_methods=True, 887 | ) 888 | 889 | assert_http_response(mock_fetch, expected) 890 | 891 | 892 | @pytest.mark.asyncio 893 | @patch("os.getenv") 894 | async def test_missing_authorization_header(mock_getenv, mock_session): 895 | # Given 896 | upstream_request = HTTPServerRequest( 897 | method="HEAD", 898 | uri="/awsproxy/bucket-name-1", 899 | headers=HTTPHeaders( 900 | { 901 | "Host": "localhost:8888", 902 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 903 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 904 | "X-Amz-Date": "20190828T173626Z", 905 | } 906 | ), 907 | body=None, 908 | host="localhost:8888", 909 | ) 910 | mock_getenv.return_value = "" 911 | 912 | # When 913 | with pytest.raises(HTTPError) as e: 914 | await AwsProxyRequest( 915 | upstream_request, create_endpoint_resolver(), mock_session 916 | ).execute_downstream() 917 | 918 | # Then 919 | assert 400 == e.value.code 920 | assert "Missing Authorization header" == e.value.message 921 | 922 | 923 | @pytest.mark.asyncio 924 | @patch("os.getenv") 925 | async def test_missing_region_in_authorization_header(mock_getenv, mock_session): 926 | # Given 927 | upstream_request = HTTPServerRequest( 928 | method="HEAD", 929 | uri="/awsproxy/bucket-name-1", 930 | headers=HTTPHeaders( 931 | { 932 | "Host": "localhost:8888", 933 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 934 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 935 | "X-Amz-Date": "20190828T173626Z", 936 | "Authorization": "AWS4-HMAC-SHA256 Credential=IGNOREDIGNO/20230317/some-service/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent;x-service-endpoint-url;x-xsrftoken, Signature=123456789", 937 | } 938 | ), 939 | body=None, 940 | host="localhost:8888", 941 | ) 942 | mock_getenv.return_value = "" 943 | 944 | # When 945 | with pytest.raises(HTTPError) as e: 946 | await AwsProxyRequest( 947 | upstream_request, create_endpoint_resolver(), mock_session 948 | ).execute_downstream() 949 | 950 | # Then 951 | assert 400 == e.value.code 952 | assert "Malformed Authorization header" == e.value.message 953 | 954 | 955 | @pytest.mark.asyncio 956 | @patch("os.getenv") 957 | async def test_missing_service_in_authorization_header(mock_getenv, mock_session): 958 | # Given 959 | upstream_request = HTTPServerRequest( 960 | method="HEAD", 961 | uri="/awsproxy/bucket-name-1", 962 | headers=HTTPHeaders( 963 | { 964 | "Host": "localhost:8888", 965 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 966 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 967 | "X-Amz-Date": "20190828T173626Z", 968 | "Authorization": "AWS4-HMAC-SHA256 Credential=IGNOREDIGNO/20230317/us-west-2/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-user-agent;x-service-endpoint-url;x-xsrftoken, Signature=123456789", 969 | } 970 | ), 971 | body=None, 972 | host="localhost:8888", 973 | ) 974 | mock_getenv.return_value = "" 975 | 976 | # When 977 | with pytest.raises(HTTPError) as e: 978 | await AwsProxyRequest( 979 | upstream_request, create_endpoint_resolver(), mock_session 980 | ).execute_downstream() 981 | 982 | # Then 983 | assert 400 == e.value.code 984 | assert "Malformed Authorization header" == e.value.message 985 | 986 | 987 | @pytest.mark.asyncio 988 | @patch("os.getenv") 989 | async def test_malformed_authorization_header(mock_getenv, mock_session): 990 | # Given 991 | upstream_request = HTTPServerRequest( 992 | method="HEAD", 993 | uri="/awsproxy/bucket-name-1", 994 | headers=HTTPHeaders( 995 | { 996 | "Host": "localhost:8888", 997 | "X-Amz-User-Agent": "aws-sdk-js/2.507.0 promise", 998 | "X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 999 | "X-Amz-Date": "20190828T173626Z", 1000 | "Authorization": "AWS4-HMAC-SHA256 malformed-content", 1001 | } 1002 | ), 1003 | body=None, 1004 | host="localhost:8888", 1005 | ) 1006 | mock_getenv.return_value = "" 1007 | 1008 | # When 1009 | with pytest.raises(HTTPError) as e: 1010 | await AwsProxyRequest( 1011 | upstream_request, create_endpoint_resolver(), mock_session 1012 | ).execute_downstream() 1013 | 1014 | # Then 1015 | assert 400 == e.value.code 1016 | assert "Malformed Authorization header" == e.value.message 1017 | 1018 | 1019 | def assert_http_response(mock_fetch, expected_http_request): 1020 | mock_fetch.assert_awaited_once() 1021 | actual_http_request: HTTPRequest = mock_fetch.await_args[0][0] 1022 | assert expected_http_request.url == actual_http_request.url 1023 | assert expected_http_request.body == actual_http_request.body 1024 | assert expected_http_request.method == actual_http_request.method 1025 | assert ( 1026 | expected_http_request.follow_redirects == actual_http_request.follow_redirects 1027 | ) 1028 | assert ( 1029 | expected_http_request.allow_nonstandard_methods 1030 | == actual_http_request.allow_nonstandard_methods 1031 | ) 1032 | assert expected_http_request.headers == actual_http_request.headers 1033 | -------------------------------------------------------------------------------- /tests/unit/test_handlers.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | import tornado.web 4 | from asynctest import CoroutineMock 5 | from unittest.mock import patch 6 | from tornado.httpclient import HTTPClientError, HTTPResponse, HTTPRequest 7 | from tornado.httputil import HTTPHeaders 8 | from tornado.testing import AsyncHTTPTestCase 9 | 10 | from aws_jupyter_proxy.handlers import awsproxy_handlers 11 | 12 | 13 | class TestAwsProxyHandler(AsyncHTTPTestCase): 14 | @patch("aws_jupyter_proxy.awsproxy.AwsProxyRequest") 15 | def test_downstream_error_no_body(self, mock_awsproxy): 16 | # Given 17 | mock_instance = mock_awsproxy.return_value 18 | mock_instance.execute_downstream = CoroutineMock() 19 | mock_instance.execute_downstream.side_effect = HTTPClientError(code=500) 20 | 21 | # When 22 | response = self.fetch("/awsproxy") 23 | 24 | # Then 25 | assert 500 == response.code 26 | assert b"" == response.body 27 | 28 | @patch("aws_jupyter_proxy.awsproxy.AwsProxyRequest") 29 | def test_downstream_error_with_body(self, mock_awsproxy): 30 | # Given 31 | mock_execute_downstream = CoroutineMock() 32 | mock_execute_downstream.side_effect = HTTPClientError( 33 | code=403, 34 | response=HTTPResponse( 35 | request=HTTPRequest("/foo"), code=403, buffer=BytesIO(b"AccessDenied") 36 | ), 37 | ) 38 | 39 | mock_instance = mock_awsproxy.return_value 40 | mock_instance.execute_downstream = mock_execute_downstream 41 | 42 | # When 43 | response = self.fetch("/awsproxy") 44 | 45 | # Then 46 | mock_execute_downstream.assert_awaited_once() 47 | assert 403 == response.code 48 | assert b"AccessDenied" == response.body 49 | 50 | @patch("aws_jupyter_proxy.awsproxy.AwsProxyRequest") 51 | def test_downstream_success_blacklisted_headers_removed(self, mock_awsproxy): 52 | # Given 53 | mock_execute_downstream = CoroutineMock() 54 | mock_execute_downstream.return_value = HTTPResponse( 55 | request=HTTPRequest(url="https://awsservice.amazonaws.com/"), 56 | code=200, 57 | headers=HTTPHeaders( 58 | { 59 | "Host": "awsservice.amazonaws.com", 60 | "X-Amz-RequestId": "foo-abc", 61 | "Transfer-Encoding": "chunked", 62 | } 63 | ), 64 | buffer=BytesIO(b"SomeResponse"), 65 | ) 66 | 67 | mock_instance = mock_awsproxy.return_value 68 | mock_instance.execute_downstream = mock_execute_downstream 69 | 70 | # When 71 | response = self.fetch("/awsproxy") 72 | 73 | # Then 74 | mock_execute_downstream.assert_awaited_once() 75 | assert 200 == response.code 76 | assert b"SomeResponse" == response.body 77 | assert "Transfer-Encoding" not in response.headers 78 | assert "foo-abc" == response.headers["X-Amz-RequestId"] 79 | assert "awsservice.amazonaws.com" == response.headers["Host"] 80 | 81 | @patch("aws_jupyter_proxy.awsproxy.AwsProxyRequest") 82 | def test_downstream_success_with_content_security_policy(self, mock_awsproxy): 83 | # Given 84 | mock_execute_downstream = CoroutineMock() 85 | mock_execute_downstream.return_value = HTTPResponse( 86 | request=HTTPRequest(url="https://awsservice.amazonaws.com/"), 87 | code=200, 88 | headers=HTTPHeaders( 89 | { 90 | "Host": "awsservice.amazonaws.com", 91 | "X-Amz-RequestId": "foo-abc", 92 | "Transfer-Encoding": "chunked", 93 | "Content-Security-Policy": "default-src 'none';", 94 | } 95 | ), 96 | buffer=BytesIO(b"SomeResponse"), 97 | ) 98 | 99 | mock_instance = mock_awsproxy.return_value 100 | mock_instance.execute_downstream = mock_execute_downstream 101 | 102 | # When 103 | response = self.fetch("/awsproxy") 104 | 105 | # Then 106 | mock_execute_downstream.assert_awaited_once() 107 | assert 200 == response.code 108 | assert b"SomeResponse" == response.body 109 | assert "Transfer-Encoding" not in response.headers 110 | assert "Content-Security-Policy" in response.headers 111 | assert ( 112 | "frame-ancestors 'self'; report-uri /jupyter/default/api/security/csp-report; default-src 'none'; upgrade-insecure-requests; base-uri 'none'" 113 | == response.headers["Content-Security-Policy"] 114 | ) 115 | assert "foo-abc" == response.headers["X-Amz-RequestId"] 116 | assert "awsservice.amazonaws.com" == response.headers["Host"] 117 | 118 | @patch("aws_jupyter_proxy.awsproxy.AwsProxyRequest") 119 | def test_downstream_success_without_content_security_policy(self, mock_awsproxy): 120 | # Given 121 | mock_execute_downstream = CoroutineMock() 122 | mock_execute_downstream.return_value = HTTPResponse( 123 | request=HTTPRequest(url="https://awsservice.amazonaws.com/"), 124 | code=200, 125 | headers=HTTPHeaders( 126 | { 127 | "Host": "awsservice.amazonaws.com", 128 | "X-Amz-RequestId": "foo-abc", 129 | "Transfer-Encoding": "chunked", 130 | } 131 | ), 132 | buffer=BytesIO(b"SomeResponse"), 133 | ) 134 | 135 | mock_instance = mock_awsproxy.return_value 136 | mock_instance.execute_downstream = mock_execute_downstream 137 | 138 | # When 139 | response = self.fetch("/awsproxy") 140 | 141 | # Then 142 | mock_execute_downstream.assert_awaited_once() 143 | assert 200 == response.code 144 | assert b"SomeResponse" == response.body 145 | assert "Transfer-Encoding" not in response.headers 146 | assert "Content-Security-Policy" in response.headers 147 | assert ( 148 | "frame-ancestors 'self'; report-uri /jupyter/default/api/security/csp-report; default-src 'none'; upgrade-insecure-requests; base-uri 'none'" 149 | == response.headers["Content-Security-Policy"] 150 | ) 151 | assert "foo-abc" == response.headers["X-Amz-RequestId"] 152 | assert "awsservice.amazonaws.com" == response.headers["Host"] 153 | 154 | def get_app(self): 155 | return tornado.web.Application(awsproxy_handlers) 156 | --------------------------------------------------------------------------------