Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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

Expand All @@ -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: |
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions .github/workflows/update_pyquarc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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

Expand Down
1 change: 0 additions & 1 deletion deploy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from deploy.stack import AppStack


app = App()
AppStack(app, f"{APP_NAME}-{ENV}")

Expand Down
10 changes: 7 additions & 3 deletions deploy/deploy/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
aws_lambda as lambda_,
aws_apigateway as apigateway,
BundlingOptions,
DockerImage,
)


Expand Down Expand Up @@ -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,
Expand All @@ -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=["*/*"],
Expand Down
58 changes: 49 additions & 9 deletions lambdas/runner/handler.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)

Expand Down Expand Up @@ -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


Expand Down
Loading