From 2448ead48c2957eb4252083ac34f351b119fc09c Mon Sep 17 00:00:00 2001 From: Daniel Smith Date: Fri, 2 Oct 2026 10:39:14 -0700 Subject: [PATCH 1/6] security: address security-review findings LAB-38 through LAB-44 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LAB-38: dashboard rejects spoofed Host / cross-origin requests; /api/exec is POST + application/json (streamed via fetch, no more GET CSRF vector) - LAB-39: every server-sourced value HTML-escaped before innerHTML - LAB-40: Wherobots API key moved to Secrets Manager; Lambdas fetch at cold start under a one-secret GetSecretValue policy; teardown deletes the secret - LAB-41: wherobots-python-sdk pinned (0.4.0) and vendored into Lambda zips; no network install at cold start - LAB-42: API Gateway stage throttled (burst 10 / rate 5); relay returns generic errors with details to CloudWatch; 256KB payload cap; trust model documented in the relay docstring and a README security section - LAB-43: dependabot.yml for pip + github-actions (repo-setting toggles need an org admin — tracked in the Linear issue) - LAB-44: SRI on highlight.js, internal SCP comment removed, ManagedBy tag now wherobots-labs, bogus teardown policy delete fixed - tests: 7 new (request-gate regression suite + pinned-requirements check) --- .github/dependabot.yml | 12 ++ aws-step-functions-python-sdk/README.md | 33 +++++- .../dashboard/index.html | 88 ++++++++++---- .../dashboard/server.py | 71 +++++++++-- .../lambdas/callback_relay/handler.py | 37 ++++-- .../lambdas/check_status/handler.py | 17 ++- .../lambdas/find_run/handler.py | 17 ++- .../lambdas/submit/handler.py | 26 ++-- .../requirements.txt | 8 +- .../scripts/02_deploy.sh | 112 ++++++++++++++---- .../scripts/99_teardown.sh | 10 +- .../tests/test_reference.py | 83 +++++++++++++ 12 files changed, 408 insertions(+), 106 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..930d3ed --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Dependabot: keep the pinned Python dependency and the CI actions patched. +# Required by the Wherobots Labs charter (automated dependency scanning). +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/aws-step-functions-python-sdk" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/aws-step-functions-python-sdk/README.md b/aws-step-functions-python-sdk/README.md index 617cdd4..4026b83 100644 --- a/aws-step-functions-python-sdk/README.md +++ b/aws-step-functions-python-sdk/README.md @@ -76,9 +76,9 @@ cd labs-reference-implementations/aws-step-functions-python-sdk ``` Use a virtual environment so the Wherobots SDK doesn't pollute your system -Python. `requirements.txt` holds the only local dependency -(`wherobots-python-sdk`, used by the upload step — the dashboard server is -stdlib-only, and the Lambdas install the SDK themselves from PyPI): +Python. `requirements.txt` holds the only dependency, pinned +(`wherobots-python-sdk`, used by the upload step and vendored into the +Lambda zips at deploy time — the dashboard server is stdlib-only): ```bash python3 -m venv .venv @@ -182,12 +182,35 @@ python3 scripts/01_upload_job.py # upload job/hello_wherobots_job.py, save s3: | `scripts/` | Numbered lifecycle scripts (the dashboard runs these) | | `dashboard/` | Local control-plane server + guided UI | +## Security model (read before exposing anything) + +- **The API key never sits in a Lambda env var.** The deploy stores it in + **Secrets Manager**; the Lambdas fetch it at cold start under a role that can + read exactly that one secret. Reading a function's configuration shows only + the secret's ARN. +- **Dependencies are pinned and vendored.** `requirements.txt` pins + `wherobots-python-sdk`, and the deploy vendors it into the Lambda zips — + no network installs at cold start, every instance runs the same code. +- **The callback relay is public by design, throttled, and capability-gated.** + The unguessable single-use task token is the auth; Step Functions rejects + anything else. The token travels as a plain job argument, so anyone who can + read that run's arguments in your Wherobots org (or your Step Functions + execution history) could post a forged callback for that one run — both + surfaces are already inside your trust boundary. The relay caps payload + size, returns generic errors (details go to CloudWatch), and the stage is + throttled (burst 10 / rate 5). For production add an authorizer or WAF. +- **Callback output is treated as untrusted.** The dashboard HTML-escapes + every server-sourced value before rendering it. +- **The dashboard only answers its own page.** Requests must carry a loopback + `Host` and (when present) a same-origin `Origin`; lifecycle scripts run only + via `POST` + `application/json`, so no other website can trigger a deploy, + teardown, or job run while the dashboard is up. + ## Production notes (deliberately out of scope here) -- Bake the SDK into a **Lambda layer** (`pip install wherobots-python-sdk -t python/ && zip -r layer.zip python`) instead of the cold-start pip install. -- Put `WHEROBOTS_API_KEY` in **Secrets Manager**, not a Lambda env var. - Use IaC (Terraform / CDK / SAM) instead of CLI scripts. - Add `Catch` + alerting states around Submit and Poll. +- Put an authorizer (API key / IAM) or WAF in front of the callback route. ## Cost diff --git a/aws-step-functions-python-sdk/dashboard/index.html b/aws-step-functions-python-sdk/dashboard/index.html index 34bb3e4..63c407a 100644 --- a/aws-step-functions-python-sdk/dashboard/index.html +++ b/aws-step-functions-python-sdk/dashboard/index.html @@ -7,8 +7,10 @@ - - + + + + + + One state machine, three test modes — the path each one takes + test-mode dropdown in the dashboard · same graph the AWS console's execution view draws + + + + 01 · NORMAL RUN + success callback wakes the paused state instantly — the fallback lanes never run + + + + + + + + + + + success callback · {"building_count": 1080} + + PreviousStep + + SubmitAndAwaitCallback + waitForTaskToken · woken by the job + FindRun + PollUntilComplete + NextStep + JobFailed + the empty fallback lanes are the point — no polling ever ran · typically 1–3 min end to end + + + + + + 02 · SOFT FAILURE — job throws + the except block posts a failure callback carrying the full traceback + + + + + + + + + + + failure callback — traceback as cause + + PreviousStep + + SubmitAndAwaitCallback + States.TaskFailed · the job said why + FindRun + PollUntilComplete + NextStep + JobFailed + fails within seconds of the throw · ErrorPath/CausePath surface the job's real traceback in the execution output + + + + + + 03 · HARD DEATH (OOM SIM) — no callback, no goodbye + the fallback recovers the verdict + + + + + grey curve → NextStep: a slow cold start exits as COMPLETED + + + + ♥ heartbeat timeout (240 s) + + + poller verdict: FAILED + + PreviousStep + + SubmitAndAwaitCallback + States.Timeout · heartbeats stopped + + FindRun + list_runs by job name + + PollUntilComplete + reusable poller SM + NextStep + JobFailed + os._exit(137) mid-run — no except, no finally · verified live: FAILED verdict in 4m46s end to end + + + + + + callback path + fallback path + failure surfaced + greyed dashed nodes = states that never ran in that mode + + diff --git a/aws-step-functions-python-sdk/dashboard/index.html b/aws-step-functions-python-sdk/dashboard/index.html index 63c407a..8a4aeff 100644 --- a/aws-step-functions-python-sdk/dashboard/index.html +++ b/aws-step-functions-python-sdk/dashboard/index.html @@ -456,7 +456,7 @@

Wherobots Jobs from Step Functions

@@ -600,7 +600,8 @@

Wherobots Jobs from Step Functions

const MODE_NOTES = { normal: `What a normal run shows The job counts Overture buildings, then posts a success callback — the paused state - wakes instantly and NextStep receives {"building_count": 1084}. The fallback + wakes instantly and NextStep receives {"building_count": 1080} (the exact + count tracks the current Overture release). The fallback stages never run: the timeline stays one teal group ending in green, and that absence is the point. Typically 1–3 minutes end to end — the slowest job start observed in testing was 52 s.`, soft: `What the soft-failure test shows From 19bcd20f9ac5538601fbfa15e0751d2c5db82c7e Mon Sep 17 00:00:00 2001 From: Daniel Smith Date: Fri, 2 Oct 2026 11:23:20 -0700 Subject: [PATCH 5/6] chore: keep the test-paths graphic out of the security PR (ships separately) --- .../assets/flow-test-paths.svg | 137 ------------------ 1 file changed, 137 deletions(-) delete mode 100644 aws-step-functions-python-sdk/assets/flow-test-paths.svg diff --git a/aws-step-functions-python-sdk/assets/flow-test-paths.svg b/aws-step-functions-python-sdk/assets/flow-test-paths.svg deleted file mode 100644 index 2804d72..0000000 --- a/aws-step-functions-python-sdk/assets/flow-test-paths.svg +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - - - - - - - - - - - - - One state machine, three test modes — the path each one takes - test-mode dropdown in the dashboard · same graph the AWS console's execution view draws - - - - 01 · NORMAL RUN - success callback wakes the paused state instantly — the fallback lanes never run - - - - - - - - - - - success callback · {"building_count": 1080} - - PreviousStep - - SubmitAndAwaitCallback - waitForTaskToken · woken by the job - FindRun - PollUntilComplete - NextStep - JobFailed - the empty fallback lanes are the point — no polling ever ran · typically 1–3 min end to end - - - - - - 02 · SOFT FAILURE — job throws - the except block posts a failure callback carrying the full traceback - - - - - - - - - - - failure callback — traceback as cause - - PreviousStep - - SubmitAndAwaitCallback - States.TaskFailed · the job said why - FindRun - PollUntilComplete - NextStep - JobFailed - fails within seconds of the throw · ErrorPath/CausePath surface the job's real traceback in the execution output - - - - - - 03 · HARD DEATH (OOM SIM) — no callback, no goodbye - the fallback recovers the verdict - - - - - grey curve → NextStep: a slow cold start exits as COMPLETED - - - - ♥ heartbeat timeout (240 s) - - - poller verdict: FAILED - - PreviousStep - - SubmitAndAwaitCallback - States.Timeout · heartbeats stopped - - FindRun - list_runs by job name - - PollUntilComplete - reusable poller SM - NextStep - JobFailed - os._exit(137) mid-run — no except, no finally · verified live: FAILED verdict in 4m46s end to end - - - - - - callback path - fallback path - failure surfaced - greyed dashed nodes = states that never ran in that mode - - From a93d9b2615eec13b4c18e8a7d1debd40c0d866a8 Mon Sep 17 00:00:00 2001 From: Daniel Smith Date: Fri, 2 Oct 2026 11:29:32 -0700 Subject: [PATCH 6/6] fix: shed legacy sendtask policy on redeploy/teardown; reject non-object JSON bodies with a 400 instead of hanging --- aws-step-functions-python-sdk/dashboard/server.py | 10 ++++++++-- aws-step-functions-python-sdk/scripts/02_deploy.sh | 8 ++++++++ aws-step-functions-python-sdk/scripts/99_teardown.sh | 3 +++ aws-step-functions-python-sdk/tests/test_reference.py | 10 ++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/aws-step-functions-python-sdk/dashboard/server.py b/aws-step-functions-python-sdk/dashboard/server.py index 61426a9..5572569 100755 --- a/aws-step-functions-python-sdk/dashboard/server.py +++ b/aws-step-functions-python-sdk/dashboard/server.py @@ -248,17 +248,23 @@ def request_allowed(self): return True def json_body(self): - """Parse a POST body; None (after a 415/400 response) if not JSON.""" + """Parse a POST body into a dict; None means a 4xx response was already + sent. Non-object JSON (null, arrays, strings) is rejected here so None + can never be a legitimate parse result left without a response.""" ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower() if ctype != "application/json": self.send_json({"error": "Content-Type must be application/json"}, 415) return None try: length = int(self.headers.get("Content-Length") or 0) - return json.loads(self.rfile.read(length) or b"{}") if length else {} + parsed = json.loads(self.rfile.read(length) or b"{}") if length else {} except ValueError: self.send_json({"error": "invalid JSON body"}, 400) return None + if not isinstance(parsed, dict): + self.send_json({"error": "JSON body must be an object"}, 400) + return None + return parsed def do_GET(self): if not self.request_allowed(): diff --git a/aws-step-functions-python-sdk/scripts/02_deploy.sh b/aws-step-functions-python-sdk/scripts/02_deploy.sh index 4e239b2..23f02a7 100755 --- a/aws-step-functions-python-sdk/scripts/02_deploy.sh +++ b/aws-step-functions-python-sdk/scripts/02_deploy.sh @@ -141,6 +141,14 @@ EOF run aws iam put-role-policy --role-name "$LAMBDA_ROLE" \ --policy-name "${RESOURCE_PREFIX}-secret-policy" \ --policy-document "$SECRET_POLICY" +# Upgrade path: earlier versions attached SendTask* to this shared role before +# the relay got its own. Remove the legacy policy so a redeploy over an old +# stack actually sheds the excess permission (no-op on a fresh account). +if quiet aws iam get-role-policy --role-name "$LAMBDA_ROLE" \ + --policy-name "${RESOURCE_PREFIX}-sendtask-policy"; then + run aws iam delete-role-policy --role-name "$LAMBDA_ROLE" \ + --policy-name "${RESOURCE_PREFIX}-sendtask-policy" +fi LAMBDA_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/${LAMBDA_ROLE}" echo diff --git a/aws-step-functions-python-sdk/scripts/99_teardown.sh b/aws-step-functions-python-sdk/scripts/99_teardown.sh index 7732561..4a76694 100755 --- a/aws-step-functions-python-sdk/scripts/99_teardown.sh +++ b/aws-step-functions-python-sdk/scripts/99_teardown.sh @@ -64,6 +64,9 @@ echo echo "── IAM roles ──────────────────────────────────────────────────────────" run aws iam delete-role-policy --role-name "${RESOURCE_PREFIX}-lambda-role" \ --policy-name "${RESOURCE_PREFIX}-secret-policy" +# Legacy policy from pre-relay-role versions; "already gone" on current stacks. +run aws iam delete-role-policy --role-name "${RESOURCE_PREFIX}-lambda-role" \ + --policy-name "${RESOURCE_PREFIX}-sendtask-policy" run aws iam detach-role-policy --role-name "${RESOURCE_PREFIX}-lambda-role" \ --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole run aws iam delete-role --role-name "${RESOURCE_PREFIX}-lambda-role" diff --git a/aws-step-functions-python-sdk/tests/test_reference.py b/aws-step-functions-python-sdk/tests/test_reference.py index 3152cc5..e37cdb3 100644 --- a/aws-step-functions-python-sdk/tests/test_reference.py +++ b/aws-step-functions-python-sdk/tests/test_reference.py @@ -220,6 +220,16 @@ def test_exec_post_requires_json_content_type(self): body='{"script":"teardown"}') self.assertEqual(status, 415) + def test_json_null_body_gets_a_response_not_a_hang(self): + # json.loads("null") is None; the handler must still answer with a 400 + # rather than treating it as already-responded and leaving the client + # waiting on an HTTP/1.1 connection. + status, _ = self._request( + "POST", "/api/exec", {"Host": f"127.0.0.1:{self.port}", + "Content-Type": "application/json"}, + body="null") + self.assertEqual(status, 400) + def test_exec_post_unknown_script_is_rejected(self): status, _ = self._request( "POST", "/api/exec", {"Host": f"127.0.0.1:{self.port}",