diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ab0bf17..80c466c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Set environment based on branch run: | @@ -24,14 +24,14 @@ jobs: fi - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v1 + uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - name: Setup python - uses: actions/setup-python@v2 + uses: actions/setup-python@v6 with: python-version: 3.9 @@ -41,9 +41,9 @@ jobs: python helpers/update_pyquarc.py --force - name: Use Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v5 with: - node-version: "14.15.1" + node-version: "20" - name: Install CDK run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d14e2ff..64e98ea 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,18 +10,18 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v2 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v6 with: - python-version: 3.8 + python-version: 3.9 - name: Install Python dependencies run: pip install black flake8 - name: Run linters - uses: wearerequired/lint-action@v1 + uses: wearerequired/lint-action@v2 with: black: true flake8: true diff --git a/.github/workflows/update_pyquarc.yml b/.github/workflows/update_pyquarc.yml index 38f35be..22a9f31 100644 --- a/.github/workflows/update_pyquarc.yml +++ b/.github/workflows/update_pyquarc.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Set environment based on branch run: | @@ -21,14 +21,14 @@ jobs: fi - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v1 + uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - name: Setup python - uses: actions/setup-python@v2 + uses: actions/setup-python@v6 with: python-version: 3.9 diff --git a/deploy/app.py b/deploy/app.py index 3385298..857af67 100644 --- a/deploy/app.py +++ b/deploy/app.py @@ -6,7 +6,6 @@ from deploy.stack import AppStack - app = App() AppStack(app, f"{APP_NAME}-{ENV}") diff --git a/deploy/deploy/stack.py b/deploy/deploy/stack.py index e463c85..f26e773 100644 --- a/deploy/deploy/stack.py +++ b/deploy/deploy/stack.py @@ -5,7 +5,6 @@ aws_lambda as lambda_, aws_apigateway as apigateway, BundlingOptions, - DockerImage, ) @@ -37,8 +36,10 @@ def __init__(self, scope, construct_id, **kwargs): bundling=BundlingOptions( image=lambda_.Runtime.PYTHON_3_9.bundling_image, command=[ - "sh", "-c", "pip install -r requirements.txt -t /asset-output && cp -au . /asset-output" - ] + "sh", + "-c", + "pip install -r requirements.txt -t /asset-output && cp -au . /asset-output", + ], ), ), runtime=lambda_.Runtime.PYTHON_3_9, @@ -58,6 +59,9 @@ def __init__(self, scope, construct_id, **kwargs): ), deploy_options=apigateway.StageOptions( stage_name=config.ENV, + # Throttle the unauthenticated public endpoint to limit abuse. + throttling_rate_limit=20, + throttling_burst_limit=40, ), rest_api_name=f"{construct_id}-restapi", binary_media_types=["*/*"], diff --git a/lambdas/runner/handler.py b/lambdas/runner/handler.py index faebb64..a9ff71a 100644 --- a/lambdas/runner/handler.py +++ b/lambdas/runner/handler.py @@ -1,5 +1,8 @@ import base64 import json +import re +import shutil +import uuid import pyQuARC from os import environ, path from pathlib import Path @@ -122,6 +125,36 @@ def decode_parts(request_parts): return parsed_result +def safe_upload_path(client_filename): + """ + Builds a filesystem path for an uploaded file that is guaranteed to stay + within a fresh, server-generated directory under TMP_DIR. + + The client-supplied filename is never trusted for path construction: only a + sanitized basename is kept (for display purposes), and it is written into a + per-request UUID directory. This defeats absolute paths, traversal (`../`), + and cross-request filename collisions in warm Lambda containers. + + Args: + client_filename (str): the untrusted filename from the request + + Returns: + (str): a safe absolute path to write the upload to + """ + basename = path.basename(client_filename or "") + basename = re.sub(r"[^\w.\-]", "_", basename).lstrip(".") or "upload" + + upload_dir = Path(TMP_DIR) / "uploads" / uuid.uuid4().hex + upload_dir.mkdir(parents=True) + filepath = upload_dir / basename + + # Defense in depth: verify the resolved path is still contained. + if not str(filepath.resolve()).startswith(str(upload_dir.resolve()) + path.sep): + raise ValueError("Invalid filename") + + return str(filepath) + + def wrap_inputs(validated_data): """ This function accepts validatated request parameters and then wrap the inputs based on what kind of input parameters are present before passing to the pyquarc package. @@ -136,8 +169,7 @@ def wrap_inputs(validated_data): wrapped_inputs = {"metadata_format": validated_data.get("format")} if file_content := validated_data.get("file"): - Path(TMP_DIR).mkdir(exist_ok=True) - filepath = path.join(TMP_DIR, validated_data.get("filename")) + filepath = safe_upload_path(validated_data.get("filename")) with open(filepath, "w") as filepointer: filepointer.write(file_content) @@ -189,30 +221,38 @@ def validate(event, response): if auth_key := validated_data.get("auth_key"): environ[AUTH_TOKEN] = auth_key - wrapped_inputs = wrap_inputs(validated_data) final_output = {} try: + wrapped_inputs = wrap_inputs(validated_data) arc = ARC(**wrapped_inputs) results = arc.validate() - # Replace /tmp/ from the filename + # Return only the uploaded file's basename, never the server path if results[0].get("file"): - results[0]["file"] = results[0]["file"][5:] + results[0]["file"] = path.basename(results[0]["file"]) final_output["details"] = results final_output["meta"] = results_parser(results) - final_output["params"] = validated_data + # Never echo secrets back to the caller + final_output["params"] = { + key: value + for key, value in validated_data.items() + if key not in ("auth_key", "file") + } response["body"] = json.dumps(final_output) except Exception as e: response["statusCode"] = 500 response["body"] = str(e) + finally: + # Clear the AUTH_TOKEN env var and remove uploaded files so + # subsequent requests in a warm container start fresh. + if AUTH_TOKEN in environ: + environ.pop(AUTH_TOKEN) + shutil.rmtree(path.join(TMP_DIR, "uploads"), ignore_errors=True) else: response["statusCode"] = 400 response["body"] = str(validator.get_errors()) - # Clear out the AUTH_TOKEN env var, so the subsequent requests start fresh - if AUTH_TOKEN in environ: - environ.pop(AUTH_TOKEN) return response