From db04b2753de70fdd388a773bb1d96e04a2838ae5 Mon Sep 17 00:00:00 2001 From: Slesa Adhikari Date: Thu, 23 Jul 2026 14:42:44 -0500 Subject: [PATCH 1/3] Fix arbitrary file write in /validate endpoint (VDP-2966) The unauthenticated POST /validate endpoint passed a client-controlled filename directly into os.path.join(TMP_DIR, filename). Because os.path.join discards the base when the second argument is absolute, and no traversal/separator checks existed, a caller could write uploaded content to arbitrary locations in the Lambda's writable area (/tmp), poisoning the shared pyQuARC cache (CACHE_DIR=/tmp) or filling the disk. Changes: - Add safe_upload_path(): keep only a sanitized basename of the client filename and write into a fresh per-request /tmp/uploads// directory, with a post-resolution containment check. Neutralizes absolute paths, ../ traversal, nested paths, and cross-request collisions in warm containers. Also fixes a crash when filename is omitted (path.join(TMP_DIR, None) -> TypeError). - Move wrap_inputs() inside try/finally so the per-request AUTH_TOKEN env var is always cleared and uploads are always removed, preventing token leakage into a subsequent request in a warm container. - Stop echoing auth_key and raw file content back in the response params. - Return only the uploaded file's basename in results, never the server path. - Throttle the public API stage (rate 20/burst 40) as defense in depth. CWE-22, CWE-73. --- deploy/deploy/stack.py | 3 ++ lambdas/runner/handler.py | 58 +++++++++++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/deploy/deploy/stack.py b/deploy/deploy/stack.py index e463c85..a41a039 100644 --- a/deploy/deploy/stack.py +++ b/deploy/deploy/stack.py @@ -58,6 +58,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 From f56c12f5219b8cd07569103166ee810d0aad17d9 Mon Sep 17 00:00:00 2001 From: Slesa Adhikari Date: Thu, 23 Jul 2026 15:00:34 -0500 Subject: [PATCH 2/3] Update GitHub Actions to Node 24 majors and Python 3.9 The runners dropped Python 3.8, so the lint workflow failed with "Version 3.8 with arch x64 not found", and the older action majors run on the now-deprecated Node 20. - lint.yml: setup-python@v1 (3.8) -> @v6 (3.9), matching the Lambda runtime and the deploy workflows; checkout@v2 -> @v5; wearerequired/lint-action@v1 -> @v2. - deploy.yml / update_pyquarc.yml: checkout@v2 -> @v5, configure-aws-credentials@v1 -> @v4, setup-python@v2 -> @v6. - deploy.yml: setup-node@v2 -> @v5 and bump the CDK build Node from the EOL 14.15.1 to 20 LTS. --- .github/workflows/deploy.yml | 10 +++++----- .github/workflows/lint.yml | 8 ++++---- .github/workflows/update_pyquarc.yml | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) 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 From a9801e878481bc6b152022add9926195ed0e5c68 Mon Sep 17 00:00:00 2001 From: Slesa Adhikari Date: Thu, 23 Jul 2026 15:04:43 -0500 Subject: [PATCH 3/3] Fix black and flake8 lint errors The lint workflow flagged pre-existing issues in the deploy package: - flake8 F401: remove unused DockerImage import in stack.py. - black: reformat the BundlingOptions command list onto separate lines and drop the extra blank line in app.py. All 8 tracked Python files now pass `black --check --line-length 100` and `flake8`. --- deploy/app.py | 1 - deploy/deploy/stack.py | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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..76ac630 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,