diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a79b31d..82e7739f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,10 +39,13 @@ jobs: go-version: "1.23.3" cache: true - - name: Set up Just - uses: extractions/setup-just@v2 - with: - just-version: "1.39.0" + - name: Set up Just (crates.io — casey/just's GitHub releases + are currently unresolvable by setup-just; revert to the + extractions/setup-just action when upstream heals) + uses: dtolnay/rust-toolchain@stable + + - name: Install Just + run: cargo install just --version 1.39.0 --locked - name: Run the canonical gate run: just ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e254f333..dc91a2aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,14 +39,18 @@ jobs: go-version: "1.23.3" cache: true - - name: Run the CI gate (never release a broken build) - uses: extractions/setup-just@v2 - with: - just-version: "1.39.0" + # casey/just's GitHub releases are currently unresolvable by + # setup-just; crates.io install instead. Revert when upstream heals. + - name: Set up Rust (for just) + uses: dtolnay/rust-toolchain@stable + + - name: Install Just + run: cargo install just --version 1.39.0 --locked - - name: just ci + - name: Run the CI gate (never release a broken build) run: just ci + - name: Install syft (SBOM tool — GoReleaser's `sboms` pipe shells out to it) run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin diff --git a/CHANGELOG.md b/CHANGELOG.md index dd04cc4d..b09ccb32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,49 @@ All notable changes to **stunt** are documented here. The format is based on ## [Unreleased] +## [0.45.0] — 2026-08-17 + +### Testing + +- **Fuzz testing for the engine and every adapter.** Go-native fuzz + targets plus a deterministic all-adapter safety sweep now guard the one + invariant a mock must uphold: **client input never produces a 5xx** + (Starlark has no try/except, so any builtin raise on attacker-shaped + input is an unhandled 500; real APIs answer bad input with 4xx). + - `TestAdapterInputSafety` drives every reference adapter's routes + with adversarial-but-deterministic requests — garbage path params + (negative/huge/unicode), JSON-null and malformed bodies, batch + arrays, bracket-form bodies, garbage auth, and ~30 cursor/limit + query param names (case-sensitive variants included) poisoned at + once. Note the sweep runs with garbage auth, so for auth-gated + adapters it proves the gate itself never 5xxs; deep post-auth paths + get their coverage from the curated fuzz target. + - `FuzzMatchRoute` / `FuzzParseFormBody` (router + bracket-form + parser), `FuzzParseMultipart` (the multipart decoder's total- + contract), `FuzzValidateHeader` (webhook header injection), and + `FuzzAdapterRequests` (coverage-guided full-dispatch fuzzing — + method, path, query, and body — over a curated adapter set: stripe, + cloudflare-D1, salesforce-SOQL, powerplatform-OData, emailoctopus, + eth-jsonrpc, shopify). + - Fuzz seed corpora run in plain `go test` forever; `just fuzz` runs + coverage-guided rounds locally (found inputs land in + `testdata/fuzz/` — commit them). + - **First-run findings, all fixed:** `paginate` raised on invalid + cursors (a tampered token 500'd — now returns `(None, None)` and + every cursor-exposing adapter, ~50 in total, answers its provider's + 400 shape); `query_select`/`paginate` raised on out-of-int64 limits + and could overflow `start+limit` into a panicking negative slice + bound with a valid cursor (clamped against the remaining items — a + huge limit means no effective limit); `crypto.base64_decode`/ + `base64url_decode` raised on malformed input (now total, returning + `None`); JSON-RPC batch elements that weren't objects, or a + non-string `method`, crashed eth-jsonrpc/erc4337 (now per-element + `-32600` Invalid Request per the spec); anaplan built blob names + from raw path params (now validates identifiers, 400); printify/jira + parsed unbounded ints from client ids/params (now int64-bounded); + the router captured an empty param name for a `{}` manifest typo + (now never matches). + ## [0.44.0] — 2026-08-17 ### Adapters diff --git a/README.md b/README.md index 67681dff..11ae30d7 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,10 @@ stunt catalog search stripe # browse the adapter registry **Reference adapters in this repo** — 95 of them (Stripe, Salesforce, Discord, Twilio, Square, Adyen, AWS S3, Google/Microsoft/Apple families, blockchain RPCs, …; all unofficial, -synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search`. Highlights: +synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search`. Every one +passes an adversarial input-safety sweep (garbage params, null/malformed bodies, ~30 tampered +cursor/limit param names — never a 5xx) plus coverage-guided fuzzing of the engine's parsers +and dispatch (`just fuzz` for longer rounds). Highlights: | Adapter | Simulates | Backing | |---|---|---| diff --git a/adapters/README.md b/adapters/README.md index e3bfadb6..79047433 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -188,11 +188,23 @@ page, next_cursor = paginate(docs, limit, cursor) | Argument | Type | Default | Notes | |----------|------|---------|-------| | `items` | iterable | required | The full result list (typically `store_collection(...).list()`), filtered first | -| `limit` | int | `None` | Page size. `None` or `<= 0` **disables paging** (returns the whole list, `next_cursor = None`) — so unmodified handlers keep their prior behavior | +| `limit` | int | `None` | Page size. `None` or `<= 0` **disables paging** (returns the whole list, `next_cursor = None`) — so unmodified handlers keep their prior behavior. An out-of-int64 value is clamped (a client-sent huge limit means "no limit") | | `cursor` | str | `None` | Opaque offset token returned by a prior call; `None`/`""` for the first page | `next_cursor` is the opaque token for the next page, or `None` when no items remain. +A **syntactically invalid cursor returns `(None, None)` instead of raising** — cursors +are client input, handlers have no try/except, and a raise would be an unhandled 500. +Guard the page and answer with the provider's own 400 (`Invalid pageToken`, +`invalid_cursor`, `InvalidQueryParameterValue`, …) — every cursor-exposing reference +adapter does: + +```python +page, next_cursor = _list_page(req, docs) +if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") +``` + The builtin does the universal slicing; **the adapter owns the provider envelope and cursor mapping**. Stripe, for example, has no cursor field — clients set `starting_after` to the last returned id — so a thin wrapper translates the id to an offset and reports `has_more`: @@ -282,7 +294,7 @@ A receiver that verifies a webhook signature (Stripe `Stripe-Signature`, GitHub | Module | Functions | Notes | |--------|-----------|-------| -| `crypto` | `hmac_sha256(key, data, encoding="hex")`, `hmac_sha1(...)`, `sha256(data, encoding="hex")`, `base64_encode(data)`, `base64_decode(s)`, `base64url_encode(data)`, `base64url_decode(s)`, `ecdsa_sign_p256(private_key_pem, data, encoding="hex")`, `ecdsa_verify_p256(public_key_pem, data, signature, encoding="hex")`, `rsa_sign(private_key_pem, data, encoding="hex")`, `rsa_verify(public_key_pem, data, signature, encoding="hex")`, `rsa_public_jwk(public_key_pem)→{kty,n,e}`, `ec_public_jwk(public_key_pem)→{kty,crv,x,y}`, `ed25519_sign(private_key_pem, data, encoding="hex")`, `ed25519_verify(public_key_pem, data, signature, encoding="hex")` | `encoding` is `"hex"` (default), `"base64"`, or `"base64url"`. MAC, hash, and asymmetric signature (ECDSA P-256 raw r‖s; RSA-SHA256 PKCS#1 v1.5; Ed25519 over the raw message). Keys arrive as PEM strings the adapter supplies (ship a fixed keypair for determinism). `rsa_public_jwk`/`ec_public_jwk` return the public key's JWK params (base64url) for serving JWKS — RS256 issuers (Entra ID, Cognito) and ES256 issuers (Sign in with Apple, APNs) respectively. `base64url_decode` accepts padded input (JWT segments are unpadded). No encryption/KDF/key-gen | +| `crypto` | `hmac_sha256(key, data, encoding="hex")`, `hmac_sha1(...)`, `sha256(data, encoding="hex")`, `base64_encode(data)`, `base64_decode(s)`, `base64url_encode(data)`, `base64url_decode(s)`, `ecdsa_sign_p256(private_key_pem, data, encoding="hex")`, `ecdsa_verify_p256(public_key_pem, data, signature, encoding="hex")`, `rsa_sign(private_key_pem, data, encoding="hex")`, `rsa_verify(public_key_pem, data, signature, encoding="hex")`, `rsa_public_jwk(public_key_pem)→{kty,n,e}`, `ec_public_jwk(public_key_pem)→{kty,crv,x,y}`, `ed25519_sign(private_key_pem, data, encoding="hex")`, `ed25519_verify(public_key_pem, data, signature, encoding="hex")` | `encoding` is `"hex"` (default), `"base64"`, or `"base64url"`. MAC, hash, and asymmetric signature (ECDSA P-256 raw r‖s; RSA-SHA256 PKCS#1 v1.5; Ed25519 over the raw message). Keys arrive as PEM strings the adapter supplies (ship a fixed keypair for determinism). `rsa_public_jwk`/`ec_public_jwk` return the public key's JWK params (base64url) for serving JWKS — RS256 issuers (Entra ID, Cognito) and ES256 issuers (Sign in with Apple, APNs) respectively. `base64url_decode` accepts padded input (JWT segments are unpadded). `base64_decode`/`base64url_decode` are **total** — malformed input returns `None` instead of raising (the argument is usually client input: auth material, cursors, ids). No encryption/KDF/key-gen | | `clock` | `now_unix()`, `now_rfc3339()` | Wall clock from the engine's injectable `clock.Clock` — real today; the virtual mode is the seam for future record/replay | **Rule:** MAC the `events_body(...)` bytes verbatim — never a re-marshalled copy — so the signer and verifier agree on the exact bytes. diff --git a/adapters/adyen-style/scripts/payments.star b/adapters/adyen-style/scripts/payments.star index f10386b2..a3ab1313 100644 --- a/adapters/adyen-style/scripts/payments.star +++ b/adapters/adyen-style/scripts/payments.star @@ -279,6 +279,8 @@ def on_list_payments(req): # Apply cursor pagination (pageSize + cursor) after building the list. page, next_cursor = _list_page(req, items) + if page == None: + return _adyen_err(400, "400", "Invalid cursor parameter.", "validation") body = { "paymentData": page, } diff --git a/adapters/anaplan-style/scripts/catalog.star b/adapters/anaplan-style/scripts/catalog.star index f302905f..9f3d2839 100644 --- a/adapters/anaplan-style/scripts/catalog.star +++ b/adapters/anaplan-style/scripts/catalog.star @@ -27,6 +27,8 @@ def _list_catalog(req, ws, mid, name): }) page, next_cursor = _list_page(req, items) + if page == None: + return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."}) paging = { "currentPageSize": len(page), "offset": _to_int(req.get("query", {}).get("offset", "")), diff --git a/adapters/anaplan-style/scripts/files.star b/adapters/anaplan-style/scripts/files.star index 41424d95..c3410bb7 100644 --- a/adapters/anaplan-style/scripts/files.star +++ b/adapters/anaplan-style/scripts/files.star @@ -66,6 +66,11 @@ def on_upload_file(req): ws = req["params"]["workspaceId"] mid = req["params"]["modelId"] fid = req["params"]["fileId"] + if not _id_ok(ws) or not _id_ok(mid) or not _id_ok(fid): + return respond(400, { + "status": "FAILURE", + "statusMessage": "Invalid identifier.", + }) key = _file_key(ws, mid, fid) bkey = _blob_key(ws, mid, fid) @@ -145,6 +150,8 @@ def on_list_files(req): }) page, next_cursor = _list_page(req, items) + if page == None: + return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."}) paging = { "currentPageSize": len(page), "offset": _to_int(req.get("query", {}).get("offset", "")), @@ -224,6 +231,8 @@ def on_list_chunks(req): }) page, next_cursor = _list_page(req, items) + if page == None: + return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."}) paging = { "currentPageSize": len(page), "offset": _to_int(req.get("query", {}).get("offset", "")), diff --git a/adapters/anaplan-style/scripts/lib.star b/adapters/anaplan-style/scripts/lib.star index 64c0b25d..8f50d765 100644 --- a/adapters/anaplan-style/scripts/lib.star +++ b/adapters/anaplan-style/scripts/lib.star @@ -232,3 +232,17 @@ def _seed(): "active": True, "size": (2*1024*1024), }) + +# _id_ok guards the blob-store name charset: path ids are client input and +# the store rejects names outside [A-Za-z0-9][A-Za-z0-9._-]* (a raise there +# is an unhandled 500). Real Anaplan ids are alphanumeric. +def _id_ok(s): + if s == None or s == "": + return False + for i in range(len(s)): + c = s[i] + ok = (c >= "a" and c <= "z") or (c >= "A" and c <= "Z") or (c >= "0" and c <= "9") or c == "_" or c == "-" or c == "." + if not ok: + return False + f = s[0] + return (f >= "a" and f <= "z") or (f >= "A" and f <= "Z") or (f >= "0" and f <= "9") diff --git a/adapters/anaplan-style/scripts/models.star b/adapters/anaplan-style/scripts/models.star index 74c7a63b..a616e3d6 100644 --- a/adapters/anaplan-style/scripts/models.star +++ b/adapters/anaplan-style/scripts/models.star @@ -26,6 +26,8 @@ def on_list_models(req): models = [] page, next_cursor = _list_page(req, models) + if page == None: + return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."}) paging = { "currentPageSize": len(page), "offset": _to_int(req.get("query", {}).get("offset", "")), @@ -75,6 +77,8 @@ def on_list_modules(req): ] page, next_cursor = _list_page(req, modules) + if page == None: + return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."}) paging = { "currentPageSize": len(page), "offset": _to_int(req.get("query", {}).get("offset", "")), diff --git a/adapters/anaplan-style/scripts/tasks.star b/adapters/anaplan-style/scripts/tasks.star index 99561b4e..be1e0e46 100644 --- a/adapters/anaplan-style/scripts/tasks.star +++ b/adapters/anaplan-style/scripts/tasks.star @@ -146,6 +146,8 @@ def on_list_exports(req): }) page, next_cursor = _list_page(req, exports) + if page == None: + return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."}) paging = { "currentPageSize": len(page), "offset": _to_int(req.get("query", {}).get("offset", "")), diff --git a/adapters/anaplan-style/scripts/workspaces.star b/adapters/anaplan-style/scripts/workspaces.star index e9268225..35adaafa 100644 --- a/adapters/anaplan-style/scripts/workspaces.star +++ b/adapters/anaplan-style/scripts/workspaces.star @@ -20,6 +20,8 @@ def on_list_workspaces(req): }) page, next_cursor = _list_page(req, items) + if page == None: + return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."}) paging = { "currentPageSize": len(page), "offset": _to_int(req.get("query", {}).get("offset", "")), diff --git a/adapters/apps-script-style/scripts/projects.star b/adapters/apps-script-style/scripts/projects.star index 43e1e421..c54c982b 100644 --- a/adapters/apps-script-style/scripts/projects.star +++ b/adapters/apps-script-style/scripts/projects.star @@ -18,6 +18,8 @@ def on_list_projects(req): items.append(_project_resource(p)) page, next_token = _list_page(req, items) + if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = {"projects": page} if next_token != None: result["nextPageToken"] = next_token diff --git a/adapters/avalara-style/scripts/companies.star b/adapters/avalara-style/scripts/companies.star index 98fc5cd4..05453daa 100644 --- a/adapters/avalara-style/scripts/companies.star +++ b/adapters/avalara-style/scripts/companies.star @@ -41,6 +41,8 @@ def on_list_companies(req): # Apply OData $top/$skip paging. page, next_link = _list_page(req, value, "/v2/companies") + if page == None: + return _avalara_err(400, "InvalidCursor", "The cursor parameter is invalid.") resp = { "@recordsetCount": len(value), diff --git a/adapters/avalara-style/scripts/definitions.star b/adapters/avalara-style/scripts/definitions.star index aa492225..de91dee9 100644 --- a/adapters/avalara-style/scripts/definitions.star +++ b/adapters/avalara-style/scripts/definitions.star @@ -45,6 +45,8 @@ def on_list_nexuses(req): # Apply OData $top/$skip paging. page, next_link = _list_page(req, value, "/v2/definitions/nexuses") + if page == None: + return _avalara_err(400, "InvalidCursor", "The cursor parameter is invalid.") resp = { "@recordsetCount": len(value), @@ -72,6 +74,8 @@ def on_list_taxcodes(req): # Apply OData $top/$skip paging. page, next_link = _list_page(req, value, "/v2/definitions/taxcodes") + if page == None: + return _avalara_err(400, "InvalidCursor", "The cursor parameter is invalid.") resp = { "@recordsetCount": len(value), diff --git a/adapters/avalara-style/scripts/transactions.star b/adapters/avalara-style/scripts/transactions.star index 1d895183..ff6d1f2e 100644 --- a/adapters/avalara-style/scripts/transactions.star +++ b/adapters/avalara-style/scripts/transactions.star @@ -113,6 +113,8 @@ def on_list_transactions(req): # Apply OData $top/$skip paging after filtering. page, next_link = _list_page(req, value, "/v2/transactions") + if page == None: + return _avalara_err(400, "InvalidCursor", "The cursor parameter is invalid.") resp = { "@recordsetCount": len(value), diff --git a/adapters/aws-s3-style/scripts/objects.star b/adapters/aws-s3-style/scripts/objects.star index 611a5f98..b509f6d5 100644 --- a/adapters/aws-s3-style/scripts/objects.star +++ b/adapters/aws-s3-style/scripts/objects.star @@ -367,6 +367,8 @@ def _list_objects_v2(bucket, req): # Apply S3 ListObjectsV2 pagination (max-keys + continuation-token). page, next_cursor = _list_page(req, entries) + if page == None: + return _invalid_argument("continuation-token", "invalid", "The continuation token is not valid.") truncated = next_cursor != "" # Effective MaxKeys to echo (requested value, or S3 default). diff --git a/adapters/azure-devops-style/scripts/git.star b/adapters/azure-devops-style/scripts/git.star index f73a29c6..f9638e94 100644 --- a/adapters/azure-devops-style/scripts/git.star +++ b/adapters/azure-devops-style/scripts/git.star @@ -31,6 +31,8 @@ def on_list_repos(req): # Apply OData $top/$skip paging (after filtering by project). page, continuation = _list_page(req, items) + if page == None: + return respond(400, {"message": "Invalid continuation token."}) resp = {"value": page, "count": len(page)} if continuation != None: @@ -139,6 +141,8 @@ def on_list_commits(req): }) page, continuation = _list_page(req, items) + if page == None: + return respond(400, {"message": "Invalid continuation token."}) resp = {"value": page, "count": len(page)} if continuation != None: resp["continuationToken"] = continuation diff --git a/adapters/azure-devops-style/scripts/pipelines.star b/adapters/azure-devops-style/scripts/pipelines.star index 91d6ac86..7e703700 100644 --- a/adapters/azure-devops-style/scripts/pipelines.star +++ b/adapters/azure-devops-style/scripts/pipelines.star @@ -29,6 +29,8 @@ def on_list_pipelines(req): items.append(_pipeline_resource(p)) page, continuation = _list_page(req, items) + if page == None: + return respond(400, {"message": "Invalid continuation token."}) resp = {"value": page, "count": len(page)} if continuation != None: resp["continuationToken"] = continuation @@ -67,6 +69,8 @@ def on_list_runs(req): items.append(_run_resource(_advance_run(r))) page, continuation = _list_page(req, items) + if page == None: + return respond(400, {"message": "Invalid continuation token."}) resp = {"value": page, "count": len(page)} if continuation != None: resp["continuationToken"] = continuation diff --git a/adapters/azure-devops-style/scripts/projects.star b/adapters/azure-devops-style/scripts/projects.star index fa17dfb6..0748d2dd 100644 --- a/adapters/azure-devops-style/scripts/projects.star +++ b/adapters/azure-devops-style/scripts/projects.star @@ -24,6 +24,8 @@ def on_list_projects(req): # Apply OData $top/$skip paging. page, continuation = _list_page(req, items) + if page == None: + return respond(400, {"message": "Invalid continuation token."}) resp = {"value": page, "count": len(page)} if continuation != None: diff --git a/adapters/azure-devops-style/scripts/work.star b/adapters/azure-devops-style/scripts/work.star index 70fb62df..947ee170 100644 --- a/adapters/azure-devops-style/scripts/work.star +++ b/adapters/azure-devops-style/scripts/work.star @@ -34,6 +34,8 @@ def on_iterations(req): # Apply OData $top/$skip paging. page, continuation = _list_page(req, items) + if page == None: + return respond(400, {"message": "Invalid continuation token."}) resp = {"value": page, "count": len(page)} if continuation != None: diff --git a/adapters/azure-storage-style/scripts/blobs.star b/adapters/azure-storage-style/scripts/blobs.star index 148a58b4..da67ce34 100644 --- a/adapters/azure-storage-style/scripts/blobs.star +++ b/adapters/azure-storage-style/scripts/blobs.star @@ -82,6 +82,8 @@ def _list_blobs(req): # Apply paging (maxresults + marker) after prefix filtering. matching, next_marker = _list_page(req, matching) + if matching == None: + return _az_error(400, "InvalidQueryParameterValue", "Value for one of the query parameters specified in the request URI is invalid.") xml = '\n' xml = xml + '\n' diff --git a/adapters/azure-storage-style/scripts/containers.star b/adapters/azure-storage-style/scripts/containers.star index f65343e0..d461bc0f 100644 --- a/adapters/azure-storage-style/scripts/containers.star +++ b/adapters/azure-storage-style/scripts/containers.star @@ -21,6 +21,8 @@ def on_list_containers(req): # Apply paging (maxresults + marker) after collecting the full list. containers, next_marker = _list_page(req, containers) + if containers == None: + return _az_error(400, "InvalidQueryParameterValue", "Value for one of the query parameters specified in the request URI is invalid.") xml = '\n' xml = xml + '\n' diff --git a/adapters/braze-style/scripts/segments.star b/adapters/braze-style/scripts/segments.star index 6a34c51f..e2337205 100644 --- a/adapters/braze-style/scripts/segments.star +++ b/adapters/braze-style/scripts/segments.star @@ -8,6 +8,8 @@ def on_list_segments(req): return err page, next_cursor = _list_page(req, _SEGMENTS) + if page == None: + return respond(400, {"errors": [{"message": "Invalid cursor parameter."}]}) body = { "message": "success", "segments": page, diff --git a/adapters/chainlink-style/scripts/automation.star b/adapters/chainlink-style/scripts/automation.star index 5000cf70..6e0c7aa2 100644 --- a/adapters/chainlink-style/scripts/automation.star +++ b/adapters/chainlink-style/scripts/automation.star @@ -116,6 +116,8 @@ def on_list_upkeeps(req): upkeeps.append(_upkeep_view(doc)) page, next_cursor = _list_page(req, upkeeps) + if page == None: + return _cl_err(400, "invalid_cursor", "Invalid cursor token") body = {"data": page} if next_cursor != None: body["nextCursor"] = next_cursor @@ -323,6 +325,8 @@ def on_list_performs(req): rev.append(entries[i]) page, next_cursor = _list_page(req, rev) + if page == None: + return _cl_err(400, "invalid_cursor", "Invalid cursor token") body = {"data": page, "count": len(rev)} if next_cursor != None: body["nextCursor"] = next_cursor diff --git a/adapters/chainlink-style/scripts/ccip.star b/adapters/chainlink-style/scripts/ccip.star index f5de979d..7e0bbc07 100644 --- a/adapters/chainlink-style/scripts/ccip.star +++ b/adapters/chainlink-style/scripts/ccip.star @@ -30,6 +30,8 @@ def on_list_messages(req): ] page, next_cursor = _list_page(req, messages) + if page == None: + return _cl_err(400, "invalid_cursor", "Invalid cursor token") body = {"data": page} if next_cursor != None: body["nextCursor"] = next_cursor diff --git a/adapters/chainlink-style/scripts/feeds.star b/adapters/chainlink-style/scripts/feeds.star index c69705ef..c0c613ce 100644 --- a/adapters/chainlink-style/scripts/feeds.star +++ b/adapters/chainlink-style/scripts/feeds.star @@ -29,6 +29,8 @@ def on_list_feeds(req): feeds.append(_feed_public(doc)) page, next_cursor = _list_page(req, feeds) + if page == None: + return _cl_err(400, "invalid_cursor", "Invalid cursor token") body = {"data": page} if next_cursor != None: body["nextCursor"] = next_cursor @@ -84,6 +86,8 @@ def on_list_rounds(req): rounds.append(_feed_round(doc, k)) page, next_cursor = _list_page(req, rounds) + if page == None: + return _cl_err(400, "invalid_cursor", "Invalid cursor token") body = {"data": page, "count": len(rounds)} if next_cursor != None: body["nextCursor"] = next_cursor diff --git a/adapters/cloudflare-style/scripts/d1.star b/adapters/cloudflare-style/scripts/d1.star index c9f82d6b..fff9dc8a 100644 --- a/adapters/cloudflare-style/scripts/d1.star +++ b/adapters/cloudflare-style/scripts/d1.star @@ -42,6 +42,8 @@ def on_list_databases(req): result.append(_db_result(d)) page, next_cursor = _list_page(req, result) + if page == None: + return _cf_err(400, 400, "Invalid cursor token") return _cf_ok_with_info(page, len(result), next_cursor) # on_create_database creates a new D1 database. diff --git a/adapters/cloudflare-style/scripts/dns.star b/adapters/cloudflare-style/scripts/dns.star index 7ab2ca43..bd52046d 100644 --- a/adapters/cloudflare-style/scripts/dns.star +++ b/adapters/cloudflare-style/scripts/dns.star @@ -50,6 +50,8 @@ def on_list_dns_records(req): records = _apply_dns_record_filters(req, records) page, next_cursor = _list_page(req, records) + if page == None: + return _cf_err(400, 400, "Invalid cursor token") return _cf_ok_with_info(page, len(records), next_cursor) # on_create_dns_record creates a DNS record. diff --git a/adapters/cloudflare-style/scripts/r2.star b/adapters/cloudflare-style/scripts/r2.star index 1d625faa..a41feacb 100644 --- a/adapters/cloudflare-style/scripts/r2.star +++ b/adapters/cloudflare-style/scripts/r2.star @@ -27,6 +27,8 @@ def on_list_buckets(req): # Pagination is via the per_page + cursor query params; the next cursor # is returned as a top-level "cursor" field alongside buckets. page, next_cursor = _list_page(req, result) + if page == None: + return _cf_err(400, 400, "Invalid cursor token") res = {"buckets": page} if next_cursor != None and next_cursor != "": res["cursor"] = next_cursor diff --git a/adapters/cloudflare-style/scripts/rules.star b/adapters/cloudflare-style/scripts/rules.star index 5f55e281..63275e65 100644 --- a/adapters/cloudflare-style/scripts/rules.star +++ b/adapters/cloudflare-style/scripts/rules.star @@ -55,6 +55,8 @@ def on_list_firewall_rules(req): result.append(_fw_result(r)) page, next_cursor = _list_page(req, result) + if page == None: + return _cf_err(400, 400, "Invalid cursor token") return _cf_ok_with_info(page, len(result), next_cursor) # on_create_firewall_rules creates one firewall rule (single object) or a @@ -310,6 +312,8 @@ def on_list_page_rules(req): result = _apply_page_rule_filters(req, result) page, next_cursor = _list_page(req, result) + if page == None: + return _cf_err(400, 400, "Invalid cursor token") return _cf_ok_with_info(page, len(result), next_cursor) # on_create_page_rule creates a page rule. diff --git a/adapters/cloudflare-style/scripts/workers.star b/adapters/cloudflare-style/scripts/workers.star index 7a0499de..a2af2e4b 100644 --- a/adapters/cloudflare-style/scripts/workers.star +++ b/adapters/cloudflare-style/scripts/workers.star @@ -32,6 +32,8 @@ def on_list_scripts(req): result.append(_worker_result(w)) page, next_cursor = _list_page(req, result) + if page == None: + return _cf_err(400, 400, "Invalid cursor token") return _cf_ok_with_info(page, len(result), next_cursor) # on_deploy_script deploys (creates or updates) a Worker script. diff --git a/adapters/cloudflare-style/scripts/zones.star b/adapters/cloudflare-style/scripts/zones.star index 81997126..848dcb07 100644 --- a/adapters/cloudflare-style/scripts/zones.star +++ b/adapters/cloudflare-style/scripts/zones.star @@ -33,6 +33,8 @@ def on_list_zones(req): result = _apply_zone_filters(req, result) page, next_cursor = _list_page(req, result) + if page == None: + return _cf_err(400, 400, "Invalid cursor token") return _cf_ok_with_info(page, len(result), next_cursor) # on_create_zone creates a new zone. diff --git a/adapters/cloudkit-style/scripts/records.star b/adapters/cloudkit-style/scripts/records.star index 33e2eec0..8ccfea72 100644 --- a/adapters/cloudkit-style/scripts/records.star +++ b/adapters/cloudkit-style/scripts/records.star @@ -82,6 +82,8 @@ def on_query(req): result.append(_record_response(record)) page, next_cursor = _list_page(req, result) + if page == None: + return _err(400, "INVALID_CURSOR", "Invalid continuation marker.") resp = {"records": page} if next_cursor != None: resp["continuationMarker"] = next_cursor diff --git a/adapters/cloudkit-style/scripts/zones.star b/adapters/cloudkit-style/scripts/zones.star index 5f5e5075..8a9ba8a5 100644 --- a/adapters/cloudkit-style/scripts/zones.star +++ b/adapters/cloudkit-style/scripts/zones.star @@ -24,6 +24,8 @@ def on_list_zones(req): zones = query_select(zones, [["zoneName", "startswith", prefix]]) page, next_cursor = _list_page(req, zones) + if page == None: + return _err(400, "INVALID_CURSOR", "Invalid continuation marker.") resp = {"zones": page} if next_cursor != None: resp["continuationMarker"] = next_cursor diff --git a/adapters/discord-style/scripts/bot.star b/adapters/discord-style/scripts/bot.star index 026c63bb..8a534c02 100644 --- a/adapters/discord-style/scripts/bot.star +++ b/adapters/discord-style/scripts/bot.star @@ -76,6 +76,8 @@ def on_guild_channels(req): # limit disables paging (returns the whole list), matching the bare-array # behavior Discord exposes for this endpoint. page, next_cursor = _list_page(req, result) + if page == None: + return respond(400, {"message": "Invalid query parameter.", "code": 50034}) headers = None link = _next_link(req, next_cursor) if link != None: diff --git a/adapters/discord-style/scripts/messages.star b/adapters/discord-style/scripts/messages.star index e0c967aa..99eed7d1 100644 --- a/adapters/discord-style/scripts/messages.star +++ b/adapters/discord-style/scripts/messages.star @@ -111,6 +111,8 @@ def on_list_messages(req): # the `after` cursor token round-trips via the Link header. result = _reverse(result) page, next_cursor = _list_page(req, result, 50) + if page == None: + return respond(400, {"message": "Invalid query parameter.", "code": 50034}) headers = None link = _next_link(req, next_cursor) if link != None: diff --git a/adapters/drive-style/scripts/files.star b/adapters/drive-style/scripts/files.star index 8c3d82ec..6806c0ec 100644 --- a/adapters/drive-style/scripts/files.star +++ b/adapters/drive-style/scripts/files.star @@ -154,6 +154,8 @@ def on_list(req): return _drive_err(400, "Invalid query filter: " + qerr, "INVALID_ARGUMENT") # Apply Drive-style paging (pageSize / pageToken) after filtering. page, next_token = _list_page(req, visible) + if page == None: + return _drive_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = {"files": page} if next_token != None: result["nextPageToken"] = next_token diff --git a/adapters/drive-style/scripts/misc.star b/adapters/drive-style/scripts/misc.star index 07eea997..05d4db8a 100644 --- a/adapters/drive-style/scripts/misc.star +++ b/adapters/drive-style/scripts/misc.star @@ -70,6 +70,8 @@ def on_changes(req): seq = "0" page, next_token = _list_page(req, ordered) + if page == None: + return _drive_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = { "kind": "drive#changeList", "changes": [_change_token_view(e) for e in page], diff --git a/adapters/dropbox-style/scripts/files.star b/adapters/dropbox-style/scripts/files.star index e72a7e9c..965818cb 100644 --- a/adapters/dropbox-style/scripts/files.star +++ b/adapters/dropbox-style/scripts/files.star @@ -202,6 +202,8 @@ def on_list_folder(req): entries.append(d) page, next_cursor = _list_page(req, entries) + if page == None: + return respond(400, {"error_summary": "invalid_cursor", "error": {".tag": "invalid_cursor"}}) return respond(200, { "entries": page, "cursor": next_cursor if next_cursor != None else "", diff --git a/adapters/entra-id-style/scripts/graph.star b/adapters/entra-id-style/scripts/graph.star index 472cd12a..2a16d7b5 100644 --- a/adapters/entra-id-style/scripts/graph.star +++ b/adapters/entra-id-style/scripts/graph.star @@ -40,6 +40,8 @@ def on_list_users(req): uc = store_collection("users") docs = uc.list() page, next_link = _list_page(req, docs, "/v1.0/users") + if page == None: + return respond(400, {"error": {"code": "BadRequest", "message": "Invalid skiptoken.", "innerError": {}}}) value = [] for d in page: value.append(_user_entity(d)) @@ -138,6 +140,8 @@ def on_list_applications(req): ac = store_collection("applications") docs = ac.list() page, next_link = _list_page(req, docs, "/v1.0/applications") + if page == None: + return respond(400, {"error": {"code": "BadRequest", "message": "Invalid skiptoken.", "innerError": {}}}) value = [] for d in page: value.append({ @@ -172,6 +176,8 @@ def on_list_service_principals(req): spc = store_collection("service_principals") docs = spc.list() page, next_link = _list_page(req, docs, "/v1.0/servicePrincipals") + if page == None: + return respond(400, {"error": {"code": "BadRequest", "message": "Invalid skiptoken.", "innerError": {}}}) value = [] for d in page: value.append({ diff --git a/adapters/erc4337-style/scripts/rpc.star b/adapters/erc4337-style/scripts/rpc.star index 531856b5..f22a9831 100644 --- a/adapters/erc4337-style/scripts/rpc.star +++ b/adapters/erc4337-style/scripts/rpc.star @@ -43,11 +43,14 @@ def on_jsonrpc(req): # _dispatch handles a single JSON-RPC request. def _dispatch(rpc): - if rpc == None: + if rpc == None or type(rpc) != "dict": return _rpc_err(None, -32600, "Invalid Request") method = rpc.get("method", "") - if method == None: + if method == None or type(method) != "string": + # JSON-RPC 2.0: method MUST be a String — anything else is an + # Invalid Request element, and string-concatenating a number in + # the not-found message would raise. method = "" params = rpc.get("params", []) if params == None: diff --git a/adapters/eth-jsonrpc-style/scripts/rpc.star b/adapters/eth-jsonrpc-style/scripts/rpc.star index 0837b1df..17b905e6 100644 --- a/adapters/eth-jsonrpc-style/scripts/rpc.star +++ b/adapters/eth-jsonrpc-style/scripts/rpc.star @@ -51,11 +51,14 @@ def on_jsonrpc(req): # _dispatch handles a single JSON-RPC request object and returns a response. def _dispatch(rpc): - if rpc == None: + if rpc == None or type(rpc) != "dict": return _rpc_err(None, -32600, "Invalid Request") method = rpc.get("method", "") - if method == None: + if method == None or type(method) != "string": + # JSON-RPC 2.0: method MUST be a String — anything else is an + # Invalid Request element, and string-concatenating a number in + # the not-found message would raise. method = "" params = rpc.get("params", []) if params == None: diff --git a/adapters/firebase-style/scripts/fcm.star b/adapters/firebase-style/scripts/fcm.star index 576e1c07..2bee89d7 100644 --- a/adapters/firebase-style/scripts/fcm.star +++ b/adapters/firebase-style/scripts/fcm.star @@ -158,6 +158,8 @@ def on_list_messages(req): continue result.append(m) page, next_cursor = _list_page(req, result) + if page == None: + return _err(400, "INVALID_ARGUMENT", "Invalid page token.") body = {"messages": page} if next_cursor != None: body["nextPageToken"] = next_cursor diff --git a/adapters/firebase-style/scripts/firestore.star b/adapters/firebase-style/scripts/firestore.star index 67837d45..84c4d596 100644 --- a/adapters/firebase-style/scripts/firestore.star +++ b/adapters/firebase-style/scripts/firestore.star @@ -55,6 +55,8 @@ def _list_path(req, project, path): if d.get("collection", "") == path and d.get("project", "") == project: result.append(_document_entity(d, project)) page, next_cursor = _list_page(req, result) + if page == None: + return _err(400, "INVALID_ARGUMENT", "Invalid page token.") body = {"documents": page} if next_cursor != None: body["nextPageToken"] = next_cursor diff --git a/adapters/ga4-style/scripts/admin.star b/adapters/ga4-style/scripts/admin.star index 4527232a..777588bb 100644 --- a/adapters/ga4-style/scripts/admin.star +++ b/adapters/ga4-style/scripts/admin.star @@ -33,6 +33,8 @@ def on_list_accounts(req): }) page, next_cursor = _list_page(req, accounts) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) return respond(200, _page_body("accounts", page, next_cursor)) # on_list_properties returns all GA4 properties. @@ -74,6 +76,8 @@ def on_list_properties(req): }) page, next_cursor = _list_page(req, properties) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) return respond(200, _page_body("properties", page, next_cursor)) # on_list_datastreams returns all data streams for a property. @@ -108,6 +112,8 @@ def on_list_datastreams(req): }) page, next_cursor = _list_page(req, streams) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) return respond(200, _page_body("dataStreams", page, next_cursor)) # --- seed hierarchy --- diff --git a/adapters/gcalendar-style/scripts/calendars.star b/adapters/gcalendar-style/scripts/calendars.star index bffaa31d..30a8c62c 100644 --- a/adapters/gcalendar-style/scripts/calendars.star +++ b/adapters/gcalendar-style/scripts/calendars.star @@ -54,6 +54,8 @@ def on_list_calendars(req): items.append(entry) page, next_cursor = _list_page(req, items) + if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = { "kind": "calendar#calendarList", diff --git a/adapters/gcalendar-style/scripts/events.star b/adapters/gcalendar-style/scripts/events.star index 426909a7..d28c707d 100644 --- a/adapters/gcalendar-style/scripts/events.star +++ b/adapters/gcalendar-style/scripts/events.star @@ -65,6 +65,8 @@ def on_list_events(req): # Apply Google Calendar pagination (maxResults + pageToken). page, next_cursor = _list_page(req, items) + if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = { "kind": "calendar#events", @@ -266,6 +268,8 @@ def on_list_instances(req): # Apply Google Calendar pagination (maxResults + pageToken). page, next_cursor = _list_page(req, instances) + if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = { "kind": "calendar#events", diff --git a/adapters/github-style/scripts/actions.star b/adapters/github-style/scripts/actions.star index bf56ed23..218d1dc0 100644 --- a/adapters/github-style/scripts/actions.star +++ b/adapters/github-style/scripts/actions.star @@ -87,6 +87,8 @@ def on_list_runs(req): result = _apply_run_filters(req, result) page, next_link = _list_page(req, result) + if page == None: + return _gh_err(400, "Invalid cursor") return respond(200, { "total_count": len(result), "workflow_runs": page, diff --git a/adapters/github-style/scripts/app.star b/adapters/github-style/scripts/app.star index c6ae1586..7f88b0b4 100644 --- a/adapters/github-style/scripts/app.star +++ b/adapters/github-style/scripts/app.star @@ -79,6 +79,8 @@ def on_list_installations(req): }, ] page, next_link = _list_page(req, docs) + if page == None: + return _gh_err(400, "Invalid cursor") return respond(200, page, _gh_link_headers(next_link)) # on_create_installation_token exchanges an app JWT for an installation diff --git a/adapters/github-style/scripts/issues.star b/adapters/github-style/scripts/issues.star index 9d7c6e11..a4972699 100644 --- a/adapters/github-style/scripts/issues.star +++ b/adapters/github-style/scripts/issues.star @@ -38,6 +38,8 @@ def on_list_issues(req): result = _apply_issue_filters(req, result) page, next_link = _list_page(req, result) + if page == None: + return _gh_err(400, "Invalid cursor") return respond(200, page, _gh_link_headers(next_link)) # on_create_issue creates a new issue. @@ -242,6 +244,8 @@ def on_list_comments(req): for c in docs: views.append(_comment_view(c)) page, next_link = _list_page(req, views) + if page == None: + return _gh_err(400, "Invalid cursor") return respond(200, page, _gh_link_headers(next_link)) # on_add_label adds a label to an issue (or PR — same surface). Returns the @@ -363,6 +367,8 @@ def on_list_issue_events(req): for e in docs: views.append(_issue_event_view(e)) page, next_link = _list_page(req, views) + if page == None: + return _gh_err(400, "Invalid cursor") return respond(200, page, _gh_link_headers(next_link)) # --- helpers --- diff --git a/adapters/github-style/scripts/pulls.star b/adapters/github-style/scripts/pulls.star index 7172a58a..6e41e55d 100644 --- a/adapters/github-style/scripts/pulls.star +++ b/adapters/github-style/scripts/pulls.star @@ -35,6 +35,8 @@ def on_list_pulls(req): result = _apply_pull_filters(req, result) page, next_link = _list_page(req, result) + if page == None: + return _gh_err(400, "Invalid cursor") return respond(200, page, _gh_link_headers(next_link)) # on_create_pull creates a new PR. @@ -246,6 +248,8 @@ def on_list_reviews(req): for r in docs: views.append(_review_view(r)) page, next_link = _list_page(req, views) + if page == None: + return _gh_err(400, "Invalid cursor") return respond(200, page, _gh_link_headers(next_link)) # on_create_review submits a review (POST): event APPROVE -> state APPROVED, diff --git a/adapters/gmail-style/scripts/drafts.star b/adapters/gmail-style/scripts/drafts.star index ed70e08c..daf81d4e 100644 --- a/adapters/gmail-style/scripts/drafts.star +++ b/adapters/gmail-style/scripts/drafts.star @@ -27,6 +27,8 @@ def on_list_drafts(req): # Apply Gmail pagination (maxResults + pageToken). page, next_cursor = _list_page(req, drafts) + if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = { "drafts": page, diff --git a/adapters/gmail-style/scripts/messages.star b/adapters/gmail-style/scripts/messages.star index 30e65b63..bfb93508 100644 --- a/adapters/gmail-style/scripts/messages.star +++ b/adapters/gmail-style/scripts/messages.star @@ -36,6 +36,8 @@ def on_list_messages(req): # Apply Gmail pagination (maxResults + pageToken) after filtering. page, next_cursor = _list_page(req, messages) + if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = { "messages": page, diff --git a/adapters/google-admin-style/scripts/groups.star b/adapters/google-admin-style/scripts/groups.star index 4509bc47..199ccad4 100644 --- a/adapters/google-admin-style/scripts/groups.star +++ b/adapters/google-admin-style/scripts/groups.star @@ -28,6 +28,8 @@ def on_list_groups(req): groups = _apply_group_filters(req, groups) page, next_token = _list_page(req, groups) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) result = { "kind": "admin#directory#groups", "groups": page, @@ -153,6 +155,8 @@ def on_list_members(req): members = _apply_member_filters(req, members) page, next_token = _list_page(req, members) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) result = { "kind": "admin#directory#members", "members": page, diff --git a/adapters/google-admin-style/scripts/users.star b/adapters/google-admin-style/scripts/users.star index a0d75520..2dacf919 100644 --- a/adapters/google-admin-style/scripts/users.star +++ b/adapters/google-admin-style/scripts/users.star @@ -30,6 +30,8 @@ def on_list_users(req): users = _apply_user_filters(req, users) page, next_token = _list_page(req, users) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) result = { "kind": "admin#directory#users", "users": page, @@ -187,6 +189,8 @@ def on_list_tokens(req): }) page, next_token = _list_page(req, tokens) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) result = { "kind": "admin#directory#tokenList", "items": page, diff --git a/adapters/google-iam-style/scripts/service_accounts.star b/adapters/google-iam-style/scripts/service_accounts.star index 0aec0056..bafa2e98 100644 --- a/adapters/google-iam-style/scripts/service_accounts.star +++ b/adapters/google-iam-style/scripts/service_accounts.star @@ -30,6 +30,8 @@ def on_list_service_accounts(req): accounts.append(_sa_entity(d)) page, next_token = _list_page(req, accounts) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) resp = {"accounts": page} if next_token != None: resp["nextPageToken"] = next_token @@ -158,6 +160,8 @@ def on_list_keys(req): }) page, next_token = _list_page(req, keys) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) resp = {"keys": page} if next_token != None: resp["nextPageToken"] = next_token diff --git a/adapters/gsearchconsole-style/scripts/sites.star b/adapters/gsearchconsole-style/scripts/sites.star index d436fc2f..2812d112 100644 --- a/adapters/gsearchconsole-style/scripts/sites.star +++ b/adapters/gsearchconsole-style/scripts/sites.star @@ -24,6 +24,8 @@ def on_list_sites(req): # Apply Search Console pagination (maxResults + pageToken) after listing. page, next_token = _list_page(req, items) + if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = {"siteEntry": page} if next_token != None: result["nextPageToken"] = next_token @@ -115,6 +117,8 @@ def on_list_sitemaps(req): # Apply Search Console pagination (maxResults + pageToken). page, next_token = _list_page(req, items) + if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = {"sitemap": page} if next_token != None: result["nextPageToken"] = next_token diff --git a/adapters/gtasks-style/scripts/lists.star b/adapters/gtasks-style/scripts/lists.star index 20d08da0..dc04f314 100644 --- a/adapters/gtasks-style/scripts/lists.star +++ b/adapters/gtasks-style/scripts/lists.star @@ -17,6 +17,8 @@ def on_list_tasklists(req): # Apply Google Tasks pagination (maxResults + pageToken). page, next_cursor = _list_page(req, items) + if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = {"items": page} if next_cursor != None: diff --git a/adapters/gtasks-style/scripts/tasks.star b/adapters/gtasks-style/scripts/tasks.star index 7fad42d0..25a0beeb 100644 --- a/adapters/gtasks-style/scripts/tasks.star +++ b/adapters/gtasks-style/scripts/tasks.star @@ -27,6 +27,8 @@ def on_list_tasks(req): # Apply Google Tasks pagination (maxResults + pageToken) after filtering. page, next_cursor = _list_page(req, items) + if page == None: + return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT") result = {"items": page} if next_cursor != None: diff --git a/adapters/instagram-style/scripts/publish.star b/adapters/instagram-style/scripts/publish.star index b8d3206d..1f7b7f1b 100644 --- a/adapters/instagram-style/scripts/publish.star +++ b/adapters/instagram-style/scripts/publish.star @@ -119,6 +119,8 @@ def on_list_media(req): user_media = _apply_media_fields(req, user_media) page, next_cursor = _list_page(req, user_media) + if page == None: + return respond(400, {"error": {"message": "Invalid after cursor", "type": "OAuthException", "code": 100, "fbtrace_id": ""}}) result = {"data": page} if next_cursor != None and next_cursor != "": diff --git a/adapters/jira-style/scripts/lib.star b/adapters/jira-style/scripts/lib.star index 91347c55..411f4381 100644 --- a/adapters/jira-style/scripts/lib.star +++ b/adapters/jira-style/scripts/lib.star @@ -195,6 +195,8 @@ def _trim(s): return s[start:end] # _to_int converts a string to an int (returns 0 on failure). +_INT64_MAX = (1 << 63) - 1 + def _to_int(s): if s == "" or s == None: return 0 @@ -204,6 +206,8 @@ def _to_int(s): code = ord(ch) if code >= 48 and code <= 57: result = result * 10 + (code - 48) + if result > _INT64_MAX: + return 0 else: return 0 return result diff --git a/adapters/linkedin-style/scripts/comments.star b/adapters/linkedin-style/scripts/comments.star index 7f3d9565..2d9a8033 100644 --- a/adapters/linkedin-style/scripts/comments.star +++ b/adapters/linkedin-style/scripts/comments.star @@ -34,6 +34,8 @@ def on_list_comments(req): }) page, next_cursor = _list_page(req, elements) + if page == None: + return respond(400, {"status": 400, "message": "Invalid start parameter."}) start = _to_int(req["query"].get("start", "")) links = [] if next_cursor != None: diff --git a/adapters/llm-style/scripts/openai.star b/adapters/llm-style/scripts/openai.star index b7937c5d..0041f5dd 100644 --- a/adapters/llm-style/scripts/openai.star +++ b/adapters/llm-style/scripts/openai.star @@ -75,6 +75,8 @@ def on_list_models(req): return err page, has_more = _list_page(req, _MODELS) + if page == None: + return respond(400, {"error": {"message": "Invalid cursor value.", "type": "invalid_request_error", "param": "after", "code": None}}) return respond(200, { "object": "list", "data": page, diff --git a/adapters/microsoft-graph-style/scripts/sharepoint.star b/adapters/microsoft-graph-style/scripts/sharepoint.star index 2918a779..250d7b22 100644 --- a/adapters/microsoft-graph-style/scripts/sharepoint.star +++ b/adapters/microsoft-graph-style/scripts/sharepoint.star @@ -34,6 +34,8 @@ def on_list_sites(req): base_url = "https://graph.microsoft.com/v1.0/groups/" + group_id + "/sites" top = _to_int(req["query"].get("$top", "")) page, next_cursor = _list_page(sites, req["query"]) + if page == None: + return _err(400, "BadRequest", "Invalid skiptoken.") envelope = { "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups('" + group_id + "')/sites", diff --git a/adapters/opensea-style/scripts/assets.star b/adapters/opensea-style/scripts/assets.star index 59714121..e8935f0e 100644 --- a/adapters/opensea-style/scripts/assets.star +++ b/adapters/opensea-style/scripts/assets.star @@ -38,6 +38,8 @@ def on_list_assets(req): }) page, next_cursor = _list_page(req, result) + if page == None: + return respond(400, {"error": "Invalid cursor parameter."}) body = {"assets": page} if next_cursor != None: body["next"] = next_cursor diff --git a/adapters/opensea-style/scripts/events.star b/adapters/opensea-style/scripts/events.star index be2e3f96..f18be711 100644 --- a/adapters/opensea-style/scripts/events.star +++ b/adapters/opensea-style/scripts/events.star @@ -36,6 +36,8 @@ def on_list_events(req): }) page, next_cursor = _list_page(req, result) + if page == None: + return respond(400, {"error": "Invalid cursor parameter."}) body = {"asset_events": page} if next_cursor != None: body["next"] = next_cursor diff --git a/adapters/opensea-style/scripts/orders.star b/adapters/opensea-style/scripts/orders.star index e6089ac9..e6a159c1 100644 --- a/adapters/opensea-style/scripts/orders.star +++ b/adapters/opensea-style/scripts/orders.star @@ -37,6 +37,8 @@ def on_list_listings(req): result.append(_strip_id(listing)) page, next_cursor = _list_page(req, result) + if page == None: + return respond(400, {"error": "Invalid cursor parameter."}) body = {"orders": page} if next_cursor != None: body["next"] = next_cursor @@ -58,6 +60,8 @@ def on_list_offers(req): result.append(_strip_id(offer)) page, next_cursor = _list_page(req, result) + if page == None: + return respond(400, {"error": "Invalid cursor parameter."}) body = {"orders": page} if next_cursor != None: body["next"] = next_cursor diff --git a/adapters/powerplatform-style/scripts/connectors.star b/adapters/powerplatform-style/scripts/connectors.star index 21be6440..ec03bc7a 100644 --- a/adapters/powerplatform-style/scripts/connectors.star +++ b/adapters/powerplatform-style/scripts/connectors.star @@ -31,6 +31,8 @@ def on_list_connectors(req): ] page, next_link = _list_page(req, docs, "/v2/environments/" + req["params"]["env"] + "/connectors") + if page == None: + return respond(400, {"error": {"code": "BadRequest", "message": "Invalid skiptoken."}}) resp = {"value": page} if next_link != None: diff --git a/adapters/powerplatform-style/scripts/dataverse.star b/adapters/powerplatform-style/scripts/dataverse.star index ab242dfe..90d218cb 100644 --- a/adapters/powerplatform-style/scripts/dataverse.star +++ b/adapters/powerplatform-style/scripts/dataverse.star @@ -53,6 +53,8 @@ def on_list_accounts(req): if skip > 0: docs = docs[skip:] page, next_link = _list_page(req, docs, base_path) + if page == None: + return respond(400, {"error": {"code": "BadRequest", "message": "Invalid skiptoken."}}) q = req.get("query") sel = "" diff --git a/adapters/powerplatform-style/scripts/environments.star b/adapters/powerplatform-style/scripts/environments.star index cd5fbb83..58843920 100644 --- a/adapters/powerplatform-style/scripts/environments.star +++ b/adapters/powerplatform-style/scripts/environments.star @@ -8,6 +8,8 @@ def on_list_environments(req): return err page, next_link = _list_page(req, _ENVS, "/v2/environments") + if page == None: + return respond(400, {"error": {"code": "BadRequest", "message": "Invalid skiptoken."}}) resp = {"value": page} if next_link != None: diff --git a/adapters/powerplatform-style/scripts/flows.star b/adapters/powerplatform-style/scripts/flows.star index f09126b7..bec884fa 100644 --- a/adapters/powerplatform-style/scripts/flows.star +++ b/adapters/powerplatform-style/scripts/flows.star @@ -30,6 +30,8 @@ def on_list_flows(req): }) page, next_link = _list_page(req, items, "/v2/environments/" + env + "/flows") + if page == None: + return respond(400, {"error": {"code": "BadRequest", "message": "Invalid skiptoken."}}) resp = {"value": page} if next_link != None: diff --git a/adapters/printful-style/scripts/orders.star b/adapters/printful-style/scripts/orders.star index 7c326e92..bf0f5827 100644 --- a/adapters/printful-style/scripts/orders.star +++ b/adapters/printful-style/scripts/orders.star @@ -87,6 +87,8 @@ def on_list_orders(req): docs = c.list() docs = _apply_order_filters(req, docs) page, next_cursor = _list_page(req, docs) + if page == None: + return respond(400, {"error": {"message": "Invalid offset parameter", "code": 400}}) limit = _to_int(_get_query(req, "limit")) body = {"data": page} if limit > 0: diff --git a/adapters/printful-style/scripts/products.star b/adapters/printful-style/scripts/products.star index 72dcfe44..4dabfdcc 100644 --- a/adapters/printful-style/scripts/products.star +++ b/adapters/printful-style/scripts/products.star @@ -17,6 +17,8 @@ def on_list_products(req): c = store_collection("products") docs = c.list() page, next_cursor = _list_page(req, docs) + if page == None: + return respond(400, {"error": {"message": "Invalid offset parameter", "code": 400}}) limit = _to_int(_get_query(req, "limit")) body = {"data": page} if limit > 0: diff --git a/adapters/printify-style/scripts/catalog.star b/adapters/printify-style/scripts/catalog.star index 3ef02596..2bacb2f1 100644 --- a/adapters/printify-style/scripts/catalog.star +++ b/adapters/printify-style/scripts/catalog.star @@ -66,6 +66,8 @@ def on_list_blueprints(req): if err != None: return err page, next_page = _list_page(req, _BLUEPRINTS) + if page == None: + return respond(400, {"error": {"message": "Invalid page parameter.", "code": 400}}) return respond(200, {"data": page, "next_page": next_page}) # on_list_variants returns the variants for a given blueprint_id. @@ -79,4 +81,6 @@ def on_list_variants(req): if variants == None: return respond(404, {"status": 404, "message": "blueprint not found"}) page, next_page = _list_page(req, variants) + if page == None: + return respond(400, {"error": {"message": "Invalid page parameter.", "code": 400}}) return respond(200, {"data": page, "next_page": next_page}) diff --git a/adapters/printify-style/scripts/lib.star b/adapters/printify-style/scripts/lib.star index 6aa69d7a..68ac0f29 100644 --- a/adapters/printify-style/scripts/lib.star +++ b/adapters/printify-style/scripts/lib.star @@ -31,6 +31,11 @@ def _require_auth(req): # _to_int parses a decimal string to int. Returns 0 for None, empty string, # or any non-numeric input (never crashes on None). +# _INT64_MAX bounds parsed ints: Starlark ints are arbitrary precision but +# the response path converts to int64, and a client-sent 25-digit "id" +# would overflow it. +_INT64_MAX = (1 << 63) - 1 + def _to_int(s): if s == None or s == "": return 0 @@ -39,6 +44,8 @@ def _to_int(s): ch = s[i] if ch >= "0" and ch <= "9": n = n * 10 + (ord(ch) - ord("0")) + if n > _INT64_MAX: + return 0 else: return 0 return n diff --git a/adapters/printify-style/scripts/orders.star b/adapters/printify-style/scripts/orders.star index 01e1ca46..a1c2b0b9 100644 --- a/adapters/printify-style/scripts/orders.star +++ b/adapters/printify-style/scripts/orders.star @@ -20,6 +20,8 @@ def on_list_orders(req): docs = c.list() total = len(docs) page, next_page = _list_page(req, docs) + if page == None: + return respond(400, {"error": {"message": "Invalid page parameter.", "code": 400}}) offset = _page_offset(req) page_len = len(page) return respond(200, { diff --git a/adapters/printify-style/scripts/products.star b/adapters/printify-style/scripts/products.star index fd21a511..4e49009b 100644 --- a/adapters/printify-style/scripts/products.star +++ b/adapters/printify-style/scripts/products.star @@ -31,6 +31,8 @@ def on_list_products(req): total = len(shop_docs) page, next_page = _list_page(req, shop_docs) + if page == None: + return respond(400, {"error": {"message": "Invalid page parameter.", "code": 400}}) offset = _page_offset(req) page_len = len(page) return respond(200, { diff --git a/adapters/psd2-style/scripts/accounts.star b/adapters/psd2-style/scripts/accounts.star index 04cb13ec..0227c4df 100644 --- a/adapters/psd2-style/scripts/accounts.star +++ b/adapters/psd2-style/scripts/accounts.star @@ -67,6 +67,8 @@ def on_list_accounts(req): # Apply Berlin Group NextGenPSD2 pagination (page/size) to the account list. page, next_cursor = _list_page(req, result) + if page == None: + return _psd2_err(400, "INVALID", "FORMAT_ERROR", "Invalid page cursor.") self_href = "https://api.stunt.test/v1/accounts" size_hint = str(_to_int(_get_query(req).get("size", ""))) diff --git a/adapters/resend-style/scripts/emails.star b/adapters/resend-style/scripts/emails.star index dbb726f8..51555c29 100644 --- a/adapters/resend-style/scripts/emails.star +++ b/adapters/resend-style/scripts/emails.star @@ -94,6 +94,8 @@ def on_list_emails(req): docs.append(_email_view(_advance_email(d))) page, next_cursor = _list_page(req, docs) + if page == None: + return respond(400, {"message": "Invalid cursor."}) body = { "object": "list", "has_more": next_cursor != None and next_cursor != "", diff --git a/adapters/sendgrid-style/scripts/mail.star b/adapters/sendgrid-style/scripts/mail.star index 0d215804..b64caeab 100644 --- a/adapters/sendgrid-style/scripts/mail.star +++ b/adapters/sendgrid-style/scripts/mail.star @@ -139,6 +139,8 @@ def on_list_messages(req): }) page, next_cursor = _list_page(req, result) + if page == None: + return respond(400, {"errors": [{"message": "Invalid cursor parameter.", "field": None, "help": None}]}) body = {"messages": page} if next_cursor != None and next_cursor != "": body["next_offset"] = next_cursor diff --git a/adapters/shopify-style/scripts/customers.star b/adapters/shopify-style/scripts/customers.star index 1adb8e50..08e62ef5 100644 --- a/adapters/shopify-style/scripts/customers.star +++ b/adapters/shopify-style/scripts/customers.star @@ -35,6 +35,8 @@ def on_list_customers(req): result.append(_customer_view(c)) page, next_cursor = _list_page(req, result) + if page == None: + return _shopify_err(400, "Invalid page_info parameter. Ensure it is a valid cursor generated by the API.") headers = None link = _next_link(req, next_cursor, _to_int(_get_query(req, "limit"))) if link != None: diff --git a/adapters/shopify-style/scripts/orders.star b/adapters/shopify-style/scripts/orders.star index 8a2c3b6a..f7f51c22 100644 --- a/adapters/shopify-style/scripts/orders.star +++ b/adapters/shopify-style/scripts/orders.star @@ -46,6 +46,8 @@ def on_list_orders(req): result = _apply_order_filters(req, result) page, next_cursor = _list_page(req, result) + if page == None: + return _shopify_err(400, "Invalid page_info parameter. Ensure it is a valid cursor generated by the API.") headers = None link = _next_link(req, next_cursor, _to_int(_get_query(req, "limit"))) if link != None: diff --git a/adapters/shopify-style/scripts/products.star b/adapters/shopify-style/scripts/products.star index 17d138af..d3d33fcb 100644 --- a/adapters/shopify-style/scripts/products.star +++ b/adapters/shopify-style/scripts/products.star @@ -29,6 +29,8 @@ def on_list_products(req): result.append(_product_view(p)) page, next_cursor = _list_page(req, result) + if page == None: + return _shopify_err(400, "Invalid page_info parameter. Ensure it is a valid cursor generated by the API.") headers = None link = _next_link(req, next_cursor, _to_int(_get_query(req, "limit"))) if link != None: diff --git a/adapters/shopify-style/scripts/webhooks.star b/adapters/shopify-style/scripts/webhooks.star index 57478710..07e5e5b8 100644 --- a/adapters/shopify-style/scripts/webhooks.star +++ b/adapters/shopify-style/scripts/webhooks.star @@ -32,6 +32,8 @@ def on_list_webhooks(req): result.append(_webhook_view(h)) page, next_cursor = _list_page(req, result) + if page == None: + return _shopify_err(400, "Invalid page_info parameter. Ensure it is a valid cursor generated by the API.") headers = None link = _next_link(req, next_cursor, _to_int(_get_query(req, "limit"))) if link != None: diff --git a/adapters/slack-style/scripts/conversations.star b/adapters/slack-style/scripts/conversations.star index 5e7d5eee..7356efde 100644 --- a/adapters/slack-style/scripts/conversations.star +++ b/adapters/slack-style/scripts/conversations.star @@ -87,6 +87,8 @@ def on_list_conversations(req): result.append(ch) page, next_cursor = _list_page(req, result) + if page == None: + return _err("invalid_cursor") body = {"channels": page} if next_cursor != None: body["response_metadata"] = {"next_cursor": next_cursor} @@ -121,6 +123,8 @@ def on_conversation_history(req): }) page, next_cursor = _list_page(req, result) + if page == None: + return _err("invalid_cursor") body = {"messages": page} if next_cursor != None: body["response_metadata"] = {"next_cursor": next_cursor} diff --git a/adapters/smartbill-style/scripts/lib.star b/adapters/smartbill-style/scripts/lib.star index 95d3b9a4..74a55c76 100644 --- a/adapters/smartbill-style/scripts/lib.star +++ b/adapters/smartbill-style/scripts/lib.star @@ -78,7 +78,9 @@ def require_auth(req): auth = "" if not auth.startswith("Basic "): return None, api_error(401, "The credentials are missing or invalid.") - # crypto.base64_decode raises on non-alphabet input; validate first. + # Shape-validate before decode (base64_decode is total and returns + # None on malformed input; the shape check keeps None out of the + # user:pass split). enc = auth[6:] body_chars = enc.replace("=", "") ok = len(body_chars) > 0 and len(enc) % 4 == 0 and "=" not in body_chars diff --git a/adapters/square-style/scripts/locations.star b/adapters/square-style/scripts/locations.star index c174690a..2ecab360 100644 --- a/adapters/square-style/scripts/locations.star +++ b/adapters/square-style/scripts/locations.star @@ -57,6 +57,8 @@ def on_list_locations(req): ] page, next_cursor = _list_page(req, locations) + if page == None: + return _sq_err(400, "INVALID_REQUEST_ERROR", "INVALID_CURSOR", "The cursor is invalid.") return respond(200, { "locations": page, "cursor": _sq_cursor(next_cursor), diff --git a/adapters/square-style/scripts/payments.star b/adapters/square-style/scripts/payments.star index bb3a83d8..216bd466 100644 --- a/adapters/square-style/scripts/payments.star +++ b/adapters/square-style/scripts/payments.star @@ -129,6 +129,8 @@ def on_list_payments(req): items = query_select(items, f if len(f) > 0 else None, "created_at", order_dir, None, None, None) page, next_cursor = _list_page(req, items) + if page == None: + return _sq_err(400, "INVALID_REQUEST_ERROR", "INVALID_CURSOR", "The cursor is invalid.") return respond(200, { "payments": page, "cursor": _sq_cursor(next_cursor), diff --git a/adapters/square-style/scripts/refunds.star b/adapters/square-style/scripts/refunds.star index eddde7ed..664e842f 100644 --- a/adapters/square-style/scripts/refunds.star +++ b/adapters/square-style/scripts/refunds.star @@ -136,6 +136,8 @@ def on_list_payment_refunds(req): items = query_select(items, f if len(f) > 0 else None, "created_at", order_dir, None, None, None) page, next_cursor = _list_page(req, items) + if page == None: + return _sq_err(400, "INVALID_REQUEST_ERROR", "INVALID_CURSOR", "The cursor is invalid.") return respond(200, { "refunds": page, "cursor": _sq_cursor(next_cursor), diff --git a/adapters/twilio-style/scripts/messages.star b/adapters/twilio-style/scripts/messages.star index 7559a62c..5bc7509b 100644 --- a/adapters/twilio-style/scripts/messages.star +++ b/adapters/twilio-style/scripts/messages.star @@ -135,6 +135,8 @@ def on_list_messages(req): # Apply paging after filtering. Twilio lists are driven by PageSize # (page size) + PageToken (opaque cursor from a prior next_page_uri). page, next_cursor = _list_page(req, result) + if page == None: + return respond(400, {"code": 400, "message": "Invalid PageToken", "more_info": "", "status": 400}) page_size = _to_int(req["query"].get("PageSize", "")) if page_size <= 0: diff --git a/adapters/twitter-style/scripts/timeline.star b/adapters/twitter-style/scripts/timeline.star index a06f822b..b7e4c9b4 100644 --- a/adapters/twitter-style/scripts/timeline.star +++ b/adapters/twitter-style/scripts/timeline.star @@ -14,6 +14,8 @@ def on_timeline(req): tweets = _reverse(docs) tweets = _apply_timeline_filters(req, tweets) page, next_cursor = _list_page(req, tweets) + if page == None: + return respond(400, {"title": "Invalid Request", "detail": "Invalid pagination_token.", "type": "about:blank"}) meta = {"result_count": len(page)} if next_cursor != None: meta["next_token"] = next_cursor diff --git a/adapters/twitter-style/scripts/tweets.star b/adapters/twitter-style/scripts/tweets.star index f5afaddd..9bf293e9 100644 --- a/adapters/twitter-style/scripts/tweets.star +++ b/adapters/twitter-style/scripts/tweets.star @@ -67,6 +67,8 @@ def on_list(req): docs = c.list() tweets = _reverse(docs) page, next_cursor = _list_page(req, tweets) + if page == None: + return respond(400, {"title": "Invalid Request", "detail": "Invalid pagination_token.", "type": "about:blank"}) meta = {"result_count": len(page)} if next_cursor != None: meta["next_token"] = next_cursor diff --git a/adapters/walletconnect-style/scripts/relay.star b/adapters/walletconnect-style/scripts/relay.star index a994e3c7..a655245c 100644 --- a/adapters/walletconnect-style/scripts/relay.star +++ b/adapters/walletconnect-style/scripts/relay.star @@ -105,6 +105,8 @@ def on_list_sessions(req): for s in sc.list(): result.append(_session_view(s)) page, _next = _list_page(req, result) + if page == None: + return respond(400, {"error": "invalid_cursor", "message": "Invalid cursor token"}) return respond(200, page) # on_approve_session acknowledges (approves) a session, simulating the diff --git a/adapters/whatsapp-style/scripts/templates.star b/adapters/whatsapp-style/scripts/templates.star index bc5313e8..8632af98 100644 --- a/adapters/whatsapp-style/scripts/templates.star +++ b/adapters/whatsapp-style/scripts/templates.star @@ -28,6 +28,8 @@ def on_list_templates(req): result.append(_template_view(t)) page, next_cursor = _list_page(req, result) + if page == None: + return _wa_err(400, "Invalid cursor parameter.", "OAuthException", 100) resp = {"data": page} if next_cursor != None and next_cursor != "": diff --git a/adapters/xero-style/scripts/accounts.star b/adapters/xero-style/scripts/accounts.star index 7ddfdc0d..d2b4436c 100644 --- a/adapters/xero-style/scripts/accounts.star +++ b/adapters/xero-style/scripts/accounts.star @@ -30,4 +30,6 @@ def on_list_accounts(req): accounts = _apply_list_filters(req, accounts) accounts, next_page = _list_page(req, accounts) + if accounts == None: + return _xero_err(400, "ValidationException", 10, "Invalid page parameter.") return _envelope("Accounts", accounts, next_page) diff --git a/adapters/xero-style/scripts/bank.star b/adapters/xero-style/scripts/bank.star index 77fd391f..22a20165 100644 --- a/adapters/xero-style/scripts/bank.star +++ b/adapters/xero-style/scripts/bank.star @@ -33,4 +33,6 @@ def on_list_bank_transactions(req): docs = _apply_list_filters(req, docs) docs, next_page = _list_page(req, docs) + if docs == None: + return _xero_err(400, "ValidationException", 10, "Invalid page parameter.") return _envelope("BankTransactions", docs, next_page) diff --git a/adapters/xero-style/scripts/connections.star b/adapters/xero-style/scripts/connections.star index c51c424b..1fa0c1b9 100644 --- a/adapters/xero-style/scripts/connections.star +++ b/adapters/xero-style/scripts/connections.star @@ -28,6 +28,8 @@ def on_list_connections(req): ] docs, next_page = _list_page(req, docs) + if docs == None: + return _xero_err(400, "ValidationException", 10, "Invalid page parameter.") body = {"connections": docs} if next_page != None: body["nextPage"] = next_page diff --git a/adapters/xero-style/scripts/contacts.star b/adapters/xero-style/scripts/contacts.star index ffd6a05d..217aee42 100644 --- a/adapters/xero-style/scripts/contacts.star +++ b/adapters/xero-style/scripts/contacts.star @@ -30,6 +30,8 @@ def on_list_contacts(req): contacts = _apply_contact_filters(req, contacts) contacts, next_page = _list_page(req, contacts) + if contacts == None: + return _xero_err(400, "ValidationException", 10, "Invalid page parameter.") return _envelope("Contacts", contacts, next_page) # _apply_contact_filters maps the real Xero GET /Contacts query params to diff --git a/adapters/xero-style/scripts/invoices.star b/adapters/xero-style/scripts/invoices.star index 12955894..6210278a 100644 --- a/adapters/xero-style/scripts/invoices.star +++ b/adapters/xero-style/scripts/invoices.star @@ -28,6 +28,8 @@ def on_list_invoices(req): invoices = _apply_invoice_filters(req, invoices) invoices, next_page = _list_page(req, invoices) + if invoices == None: + return _xero_err(400, "ValidationException", 10, "Invalid page parameter.") return _envelope("Invoices", invoices, next_page) # _apply_invoice_filters maps the real Xero GET /Invoices query params to diff --git a/adapters/xero-style/scripts/items.star b/adapters/xero-style/scripts/items.star index af933279..8a423aa5 100644 --- a/adapters/xero-style/scripts/items.star +++ b/adapters/xero-style/scripts/items.star @@ -33,4 +33,6 @@ def on_list_items(req): docs = _apply_list_filters(req, docs) docs, next_page = _list_page(req, docs) + if docs == None: + return _xero_err(400, "ValidationException", 10, "Invalid page parameter.") return _envelope("Items", docs, next_page) diff --git a/adapters/xero-style/scripts/tracking.star b/adapters/xero-style/scripts/tracking.star index ef1ee5a4..d4600bc3 100644 --- a/adapters/xero-style/scripts/tracking.star +++ b/adapters/xero-style/scripts/tracking.star @@ -26,4 +26,6 @@ def on_list_tracking(req): docs = _apply_list_filters(req, docs) docs, next_page = _list_page(req, docs) + if docs == None: + return _xero_err(400, "ValidationException", 10, "Invalid page parameter.") return _envelope("TrackingCategories", docs, next_page) diff --git a/adapters/youtube-style/scripts/playlists.star b/adapters/youtube-style/scripts/playlists.star index 95143126..2b02b9ce 100644 --- a/adapters/youtube-style/scripts/playlists.star +++ b/adapters/youtube-style/scripts/playlists.star @@ -73,6 +73,8 @@ def on_list_playlists(req): items = _apply_playlist_query(req, items) page, next_token = _list_page(req, items) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) result = {"items": page} if next_token != None: result["nextPageToken"] = next_token diff --git a/adapters/youtube-style/scripts/videos.star b/adapters/youtube-style/scripts/videos.star index 4ae83497..2fbdec4e 100644 --- a/adapters/youtube-style/scripts/videos.star +++ b/adapters/youtube-style/scripts/videos.star @@ -84,6 +84,8 @@ def on_list_videos(req): items = _apply_video_query(req, items) page, next_token = _list_page(req, items) + if page == None: + return respond(400, {"error": {"code": 400, "message": "Invalid pageToken", "status": "INVALID_ARGUMENT"}}) result = {"items": page} if next_token != None: result["nextPageToken"] = next_token diff --git a/adapters/zendesk-style/scripts/tickets.star b/adapters/zendesk-style/scripts/tickets.star index 8d21b159..3f664178 100644 --- a/adapters/zendesk-style/scripts/tickets.star +++ b/adapters/zendesk-style/scripts/tickets.star @@ -27,6 +27,8 @@ def on_list(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, docs) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") tickets = [] for d in paged: @@ -258,6 +260,8 @@ def on_list_comments(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, comments) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") resp = { "comments": paged, @@ -320,6 +324,8 @@ def on_search(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, results) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") resp = { "results": paged, @@ -356,6 +362,8 @@ def on_list_requests(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, requests) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") resp = { "requests": paged, @@ -374,6 +382,8 @@ def on_list_suspended(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, []) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") resp = { "suspended_tickets": paged, diff --git a/adapters/zendesk-style/scripts/users.star b/adapters/zendesk-style/scripts/users.star index 07a1848a..59c49210 100644 --- a/adapters/zendesk-style/scripts/users.star +++ b/adapters/zendesk-style/scripts/users.star @@ -32,6 +32,8 @@ def on_list_users(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, users) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") resp = { "users": paged, @@ -66,6 +68,8 @@ def on_list_organizations(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, orgs) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") resp = { "organizations": paged, @@ -99,6 +103,8 @@ def on_list_groups(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, groups) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") resp = { "groups": paged, @@ -123,6 +129,8 @@ def on_list_views(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, views) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") resp = { "views": paged, @@ -151,6 +159,8 @@ def on_list_triggers(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, triggers) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") resp = { "triggers": paged, diff --git a/adapters/zendesk-style/scripts/webhooks.star b/adapters/zendesk-style/scripts/webhooks.star index 24ef0a80..cabb74ff 100644 --- a/adapters/zendesk-style/scripts/webhooks.star +++ b/adapters/zendesk-style/scripts/webhooks.star @@ -35,6 +35,8 @@ def on_list_webhooks(req): page_size = _to_int(_get_query(req, "per_page", "100")) paged, next_cursor = _list_page(req, webhooks) + if paged == None: + return _zd_error(400, "InvalidQuery", "Invalid page cursor.") resp = { "webhooks": paged, diff --git a/adapters/zuora-style/scripts/accounts.star b/adapters/zuora-style/scripts/accounts.star index 05fcee8a..1ed2aac4 100644 --- a/adapters/zuora-style/scripts/accounts.star +++ b/adapters/zuora-style/scripts/accounts.star @@ -36,6 +36,8 @@ def on_list_accounts(req): accounts = _apply_zuora_filters(req, accounts) accounts, next_cursor = _list_page(req, accounts) + if accounts == None: + return _zuora_err(400, "INVALID_CURSOR", "The cursor parameter is invalid.") accounts = _apply_zuora_fields(req, accounts) resp = { diff --git a/adapters/zuora-style/scripts/billing.star b/adapters/zuora-style/scripts/billing.star index 97652f96..e8e71909 100644 --- a/adapters/zuora-style/scripts/billing.star +++ b/adapters/zuora-style/scripts/billing.star @@ -91,6 +91,8 @@ def on_list_payments(req): payments = _apply_zuora_filters(req, payments) payments, next_cursor = _list_page(req, payments) + if payments == None: + return _zuora_err(400, "INVALID_CURSOR", "The cursor parameter is invalid.") payments = _apply_zuora_fields(req, payments) resp = { diff --git a/adapters/zuora-style/scripts/usage.star b/adapters/zuora-style/scripts/usage.star index 56edac9e..f4a03145 100644 --- a/adapters/zuora-style/scripts/usage.star +++ b/adapters/zuora-style/scripts/usage.star @@ -76,6 +76,8 @@ def on_list_usage(req): # same convention as the accounts and payments list endpoints. usage = _apply_zuora_filters(req, usage) usage, next_cursor = _list_page(req, usage) + if usage == None: + return _zuora_err(400, "INVALID_CURSOR", "The cursor parameter is invalid.") usage = _apply_zuora_fields(req, usage) resp = { diff --git a/internal/adapter/runtime/fuzz_multipart_test.go b/internal/adapter/runtime/fuzz_multipart_test.go new file mode 100644 index 00000000..05269842 --- /dev/null +++ b/internal/adapter/runtime/fuzz_multipart_test.go @@ -0,0 +1,58 @@ +package runtime + +import ( + "sync" + "testing" + + sk "go.starlark.net/starlark" +) + +// The parse_multipart builtin is shared by every handler that decodes +// multipart/form-data, so it must be TOTAL over adversarial input: its +// contract is the in-band (parts, err) pair, and an eval-time raise would +// surface as a 500. One builtins dict per process is enough — it is +// stateless per call. +var ( + fuzzBuiltinsOnce sync.Once + fuzzParseMulti sk.Value +) + +func fuzzMultipartFn() sk.Value { + fuzzBuiltinsOnce.Do(func() { + b := BuildAllBuiltins(BuiltinOptions{}) + fn, ok := b["parse_multipart"] + if !ok { + panic("parse_multipart builtin not registered") + } + fuzzParseMulti = fn + }) + return fuzzParseMulti +} + +// FuzzParseMultipart throws arbitrary Content-Type/body pairs at the +// multipart decoder. Invariants: no panic, no eval-time raise, and the +// result is the documented 2-tuple. +func FuzzParseMultipart(f *testing.F) { + f.Add("multipart/form-data; boundary=----x", + "------x\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nv\r\n------x--\r\n") + f.Add("multipart/form-data", "no boundary param") + f.Add("multipart/form-data; boundary=", "garbage") + f.Add("", "") + f.Add("multipart/mixed; boundary=b", "--b\r\n\r\nx\r\n--b--") + f.Add("application/json", "{}") + f.Add("multipart/form-data; boundary=🦀", "--🦀\r\nContent-Disposition: form-data; name=\"f\"; filename=\"x\"\r\n\r\nbytes\r\n--🦀--") + f.Add("multipart/form-data; boundary=b", + "--b\r\nContent-Disposition: form-data\r\n\r\nno name\r\n--b--") + f.Add("multipart/form-data; boundary=b", "--b") + f.Add("multipart/form-data; boundary=b", "--b--") + f.Fuzz(func(t *testing.T, contentType, body string) { + res, err := sk.Call(new(sk.Thread), fuzzMultipartFn(), sk.Tuple{sk.String(contentType), sk.String(body)}, nil) + if err != nil { + t.Fatalf("parse_multipart raised on ct=%q body=%q: %v (contract is in-band (parts, err))", contentType, body, err) + } + tup, ok := res.(sk.Tuple) + if !ok || tup.Len() != 2 { + t.Fatalf("parse_multipart returned %s, want 2-tuple", res.Type()) + } + }) +} diff --git a/internal/adapter/runtime/paginate_test.go b/internal/adapter/runtime/paginate_test.go index 0f175ac0..629a61c1 100644 --- a/internal/adapter/runtime/paginate_test.go +++ b/internal/adapter/runtime/paginate_test.go @@ -147,14 +147,36 @@ func TestPaginateKwargsAndErrors(t *testing.T) { if _, err := sk.Call(new(sk.Thread), fn, sk.Tuple{items, sk.Float(2.0), sk.None}, nil); err == nil { t.Fatal("float limit: want error, got nil") } - // Garbage cursor token errors. - if _, err := sk.Call(new(sk.Thread), fn, sk.Tuple{items, sk.MakeInt64(2), sk.String("abc")}, nil); err == nil { - t.Fatal("garbage cursor: want error, got nil") + // Garbage cursor token is TOTAL: (None, None) so the handler can + // answer its provider's 400 instead of an unhandled 500. + res, err := sk.Call(new(sk.Thread), fn, sk.Tuple{items, sk.MakeInt64(2), sk.String("abc")}, nil) + if err != nil { + t.Fatalf("garbage cursor: error %v, want (None, None)", err) + } + tup, ok := res.(sk.Tuple) + if !ok || tup.Len() != 2 || tup.Index(0) != sk.None || tup.Index(1) != sk.None { + t.Fatalf("garbage cursor = %v, want (None, None)", res.String()) } // Non-iterable items errors. if _, err := sk.Call(new(sk.Thread), fn, sk.Tuple{sk.MakeInt64(42), sk.MakeInt64(2), sk.None}, nil); err == nil { t.Fatal("int items: want error, got nil") } + + // A huge limit (clamped to MaxInt64) with a VALID cursor must not + // overflow start+limit into a negative slice bound — found by review: + // this exact combination panicked. + big := sk.MakeInt64(9223372036854775807) + res, err = sk.Call(new(sk.Thread), fn, sk.Tuple{items, big, sk.String("1")}, nil) + if err != nil { + t.Fatalf("huge limit + valid cursor: %v", err) + } + tup, ok = res.(sk.Tuple) + if !ok || tup.Len() != 2 { + t.Fatalf("huge limit + valid cursor = %v, want tuple", res) + } + if got := listToInts(t, tup.Index(0)); !equal(got, []int{2, 3}) { + t.Fatalf("huge limit + valid cursor page = %v, want [2 3] (no effective limit)", got) + } } func equal(a, b []int) bool { diff --git a/internal/adapter/runtime/query.go b/internal/adapter/runtime/query.go index b24541e3..f96bd658 100644 --- a/internal/adapter/runtime/query.go +++ b/internal/adapter/runtime/query.go @@ -2,6 +2,7 @@ package runtime import ( "fmt" + "math" "math/big" "sort" "strconv" @@ -144,7 +145,7 @@ func buildQueryBuiltins() sk.StringDict { } n, ok := off.Int64() if !ok { - return nil, fmt.Errorf("query_select: offset out of int range") + n = math.MaxInt64 } start = int(n) if start < 0 { @@ -162,14 +163,16 @@ func buildQueryBuiltins() sk.StringDict { } n, ok := lim.Int64() if !ok { - return nil, fmt.Errorf("query_select: limit out of int range") + n = math.MaxInt64 } if n < 0 { n = 0 } - end = start + int(n) - if end > len(items) { - end = len(items) + // Clamp against remaining BEFORE adding — start+limit + // wraps negative when a huge limit saturated MaxInt64. + end = len(items) + if n <= int64(len(items)-start) { + end = start + int(n) } } items = items[start:end] diff --git a/internal/adapter/runtime/runtime.go b/internal/adapter/runtime/runtime.go index 46dd781d..f4c6ac87 100644 --- a/internal/adapter/runtime/runtime.go +++ b/internal/adapter/runtime/runtime.go @@ -54,6 +54,7 @@ import ( "encoding/json" "fmt" "io" + "math" "mime" "mime/multipart" "strconv" @@ -168,7 +169,10 @@ func buildListBuiltins() sk.StringDict { iter.Done() total := len(all) - // limit: None or <= 0 disables paging. + // limit: None or <= 0 disables paging. An out-of-int64 + // value (client-sent 25-digit limit) is clamped to the + // maximum — semantically "no limit", which is what a huge + // limit asks for — rather than raising. limit := -1 if limitVal != sk.None { li, ok := limitVal.(sk.Int) @@ -177,12 +181,17 @@ func buildListBuiltins() sk.StringDict { } n, ok := li.Int64() if !ok { - return nil, fmt.Errorf("paginate: limit out of int range") + n = math.MaxInt64 } limit = int(n) } // cursor: opaque offset token (string) or None/"" for the start. + // A syntactically invalid token is TOTAL — (None, None) — the + // same contract as json_safe_decode: cursors are client + // input, handlers cannot try/except a raise, and the right + // answer is the adapter's own 400, not a 500. Type errors + // (programmer mistakes) still raise. start := 0 if cursorVal != sk.None { s, ok := cursorVal.(sk.String) @@ -190,9 +199,19 @@ func buildListBuiltins() sk.StringDict { return nil, fmt.Errorf("paginate: cursor must be a string or None, got %s", cursorVal.Type()) } if string(s) != "" { + // Tokens are produced by strconv.Itoa — plain + // digits. Reject anything else (ParseInt would + // accept "+5"/"0x1f"-adjacent forms). + digits := true + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + digits = false + break + } + } off, err := strconv.ParseInt(string(s), 10, 64) - if err != nil || off < 0 { - return nil, fmt.Errorf("paginate: invalid cursor token %q", string(s)) + if !digits || err != nil || off < 0 { + return sk.Tuple{sk.None, sk.None}, nil } start = int(off) } @@ -204,9 +223,12 @@ func buildListBuiltins() sk.StringDict { if limit <= 0 { return sk.Tuple{sk.NewList(all), sk.None}, nil } - end := start + limit - if end > total { - end = total + // Clamp against REMAINING before adding: start+limit can + // wrap negative when a huge limit was clamped to MaxInt64 + // (a valid cursor + limit=1e23 used to panic the slice). + end := total + if limit <= total-start { + end = start + limit } var next sk.Value = sk.None if end < total { diff --git a/internal/cli/llm.go b/internal/cli/llm.go index be279bd6..46157445 100644 --- a/internal/cli/llm.go +++ b/internal/cli/llm.go @@ -99,7 +99,7 @@ Builtins: c = store_collection("items"); c.insert(d); c.get(id); c.list(); c.update(id,d); c.delete(id) store_kv_set(ns,k,v); store_kv_get(ns,k); n=store_kv_incr(ns,k); store_kv_delete(ns,k) b = store_blob("up"); b.put(name,bytes,ctype); b.get(name); b.stat(name); b.list(); b.append(...); b.delete(name) - page, next = paginate(items, limit, cursor) # cursor-based list paging; limit None/<=0 = all; next is None when done + page, next = paginate(items, limit, cursor) # cursor-based list paging; limit None/<=0 = all; next is None when done; invalid cursor -> (None,None) (answer the provider's 400) parts, err = parse_multipart(content_type, raw_body) # multipart/form-data; each part {name, filename, content_type, data} # filter/sort/slice/project a list in one call; filter = [[field, op, value], ...] (AND), ops: # = != > >= < <= contains startswith endswith in like(% _); dotted field paths OK ("a.b") diff --git a/internal/engine/adapter_dispatch.go b/internal/engine/adapter_dispatch.go index 6e6b831e..3daa7a2c 100644 --- a/internal/engine/adapter_dispatch.go +++ b/internal/engine/adapter_dispatch.go @@ -283,11 +283,16 @@ func matchRoute(pattern, path string) (map[string]string, bool) { } // matchSegment matches one path segment against a pattern segment, capturing -// any param into params. +// any param into params. An empty param name ("{}") never matches — a +// manifest typo must not capture under "". func matchSegment(pat, pathSeg string, params map[string]string) bool { // Whole-segment {name}: capture the entire segment. if len(pat) >= 2 && pat[0] == '{' && pat[len(pat)-1] == '}' { - params[pat[1:len(pat)-1]] = pathSeg + name := pat[1 : len(pat)-1] + if name == "" { + return false + } + params[name] = pathSeg return true } // No placeholder: literal match. @@ -304,6 +309,9 @@ func matchSegment(pat, pathSeg string, params map[string]string) bool { prefix := pat[:open] name := pat[open+1 : closeIdx] suffix := pat[closeIdx+1:] + if name == "" { + return false + } if !strings.HasPrefix(pathSeg, prefix) || !strings.HasSuffix(pathSeg, suffix) || len(pathSeg) < len(prefix)+len(suffix) { return false } @@ -430,11 +438,19 @@ func splitFormKey(k string) ([]string, bool) { return segs, true } +// maxFormIndex bounds a numeric bracket index: beyond it the pair is +// skipped rather than materializing a huge sparse slice (found by +// fuzzing — a[222222220][b]=v took 57s in the parser alone). +const maxFormIndex = 10000 + // assignFormValue walks segs, materializing nested dicts and lists, and sets // the final segment to val. "" means "append to a list"; a numeric segment // indexes one (gaps become nil). Conflicting shapes at a path are skipped // (first writer wins) rather than crashing the handler. func assignFormValue(cur map[string]any, segs []string, val string) { + if len(segs) == 0 { + return // total: an empty path assigns nothing + } head := segs[0] if len(segs) == 1 { // A terminal scalar never clobbers an existing structure at the same @@ -455,11 +471,24 @@ func assignFormValue(cur map[string]any, segs []string, val string) { cur[head] = l case isNumericSegment(tail[0]): idx, _ := strconv.Atoi(tail[0]) + if idx < 0 || idx >= maxFormIndex { + return // pathological index (a[222222220]…): skipping beats + // materializing a hundred-million-element slice + } l, _ := cur[head].([]any) for len(l) <= idx { l = append(l, nil) } - if em, ok := l[idx].(map[string]any); ok { + if len(tail) == 1 { + // Terminal index (a[0]=v — the Rails array-literal form): + // write the scalar at the index, never clobbering a + // structure already there. + switch l[idx].(type) { + case map[string]any, []any: + default: + l[idx] = val + } + } else if em, ok := l[idx].(map[string]any); ok { assignFormValue(em, tail[1:], val) } else if l[idx] == nil { sub := map[string]any{} diff --git a/internal/engine/adapter_dispatch_parseform_test.go b/internal/engine/adapter_dispatch_parseform_test.go index 9fe80a4b..d1b3ac22 100644 --- a/internal/engine/adapter_dispatch_parseform_test.go +++ b/internal/engine/adapter_dispatch_parseform_test.go @@ -24,6 +24,17 @@ func TestParseFormBodyBracketNotation(t *testing.T) { // map-iteration order is unspecified — asserted order-insensitively // in TestParseFormBodyBareAppendDictsUnordered below. {"numeric indexed merge", "a[0][b]=1&a[0][c]=2&a[1][b]=3", `{"a":[{"b":"1","c":"2"},{"b":"3"}]}`}, + // Terminal numeric index — the Rails array-literal form. Found by + // fuzzing: an empty tail segs panicked the recursion (index out + // of range). + {"terminal numeric index", "a[0]=v", `{"a":["v"]}`}, + {"terminal numeric indexes", "a[0]=1&a[1]=2", `{"a":["1","2"]}`}, + {"terminal numeric, numeric key, empty value", "0[0]=", `{"0":[""]}`}, + {"terminal scalar never clobbers structure", "a[0][b]=1&a[0]=2", `{"a":[{"b":"1"}]}`}, + // Found by fuzzing: a huge bracket index must be skipped, not + // materialized as a hundred-million-element sparse slice (57s + // in the parser alone before the cap). + {"pathological index skipped", "a[222222220][b]=v&ok=1", `{"ok":"1"}`}, {"flat + brackets coexist", "mode=payment&line_items[0][qty]=2", `{"mode":"payment","line_items":[{"qty":"2"}]}`}, {"stripe-shaped", "line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=1000&line_items[0][quantity]=1&success_url=https%3A%2F%2Fx.test%2Fs", `{"line_items":[{"price_data":{"currency":"usd","unit_amount":"1000"},"quantity":"1"}],"success_url":"https://x.test/s"}`}, diff --git a/internal/engine/adapter_fuzz_safety_test.go b/internal/engine/adapter_fuzz_safety_test.go new file mode 100644 index 00000000..76d2239b --- /dev/null +++ b/internal/engine/adapter_fuzz_safety_test.go @@ -0,0 +1,225 @@ +package engine + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/manifest" +) + +// TestAdapterInputSafety drives adversarial — but fully deterministic — +// requests at EVERY reference adapter's handler-backed routes and asserts +// the one invariant a mock must uphold: client input NEVER produces a +// 5xx. Starlark has no try/except, so any builtin error on attacker-shaped +// input surfaces as a 500 ("handler error"); real APIs answer bad input +// with 4xx. Each route is hit with its declared method, path params filled +// from a garbage pool, a garbage body (JSON nulls, malformed JSON, batch +// arrays, bracket-form), garbage auth, and a garbage query string. +// +// This is the seed corpus; FuzzAdapterRequests extends it with coverage- +// guided mutation over a curated adapter set. +func TestAdapterInputSafety(t *testing.T) { + adaptersDir := repoAdaptersDir(t) + if adaptersDir == "" { + t.Skip("adapters/ directory not found — skipping reference-adapter safety sweep") + } + + entries, err := os.ReadDir(adaptersDir) + if err != nil { + t.Skipf("cannot read adapters dir %s: %v", adaptersDir, err) + } + var dirs []string + for _, e := range entries { + if e.IsDir() && strings.HasSuffix(e.Name(), "-style") { + dirs = append(dirs, e.Name()) + } + } + sort.Strings(dirs) + if len(dirs) == 0 { + t.Skip("no *-style adapter directories found") + } + + // Bound live engines: each subtest boots its own engine + SQLite. + sem := make(chan struct{}, 6) + for _, name := range dirs { + t.Run(name, func(t *testing.T) { + t.Parallel() + sem <- struct{}{} + defer func() { <-sem }() + runAdapterSafetySweep(t, filepath.Join(adaptersDir, name)) + }) + } +} + +// garbageParams fills {param} segments with values handlers must survive: +// negative numbers, huge numbers, unicode, empty-ish shapes, percent +// residues, assignment syntax. Slash-free so routes still MATCH (a slash +// would just 404 and test nothing). +var garbageParams = []string{ + "1", "0", "-1", "null", "abc", "🦀", "0x1F", + "9999999999999999999999", "x=y", "%20", "undefined", + "dddddddddddddddddddddddddddddddd", "{}", "[]", "id", +} + +// garbageBodies cycles per endpoint: JSON null in required spots, nested +// nulls, a JSON-RPC batch array (the engine wraps it as _batch), +// malformed JSON (handler sees an empty body dict), bracket-form +// urlencoded, wrong-typed fields, unicode keys. +var garbageBodies = []struct { + ct string + body string +}{ + {"application/json", `{"x": null}`}, + {"application/json", `{"a": {"b": null}, "id": null}`}, + {"application/json", `[1,2,3]`}, + {"application/json", `{"a":`}, + {"application/x-www-form-urlencoded", `a=1&b[c][]=2&d[e]=3&f=`}, + {"application/json", `{"id": 123, "amount": "1e3", "n": -0.5, "limit": -1}`}, + {"application/json", `{"🦀": "🦀"}`}, +} + +var garbageAuth = []string{ + "Bearer garbage-token", + "Basic garbage", + "", +} + +// garbageQuery poisons every plausible cursor/limit/query param name at +// once (URL-encoded; case-sensitive names included): adapters feed their +// own param name into paginate or a decoder, and a tampered cursor must +// answer 4xx, never a builtin raise. +const garbageQuery = "?cursor=%21%21%21bogus&pageToken=%21%21%21bogus" + + "&PageToken=%21%21%21bogus&nextPageToken=%21%21%21bogus" + + "&starting_after=%21%21%21bogus&after=%21%21%21bogus&next=%21%21%21bogus" + + "&offset=%21%21%21bogus&marker=%21%21%21bogus&Marker=%21%21%21bogus" + + "&position=%21%21%21bogus&continuation=%21%21%21bogus&bookmark=%21%21%21bogus" + + "&pagination_token=%21%21%21bogus&page=%21%21%21bogus&page_info=%21%21%21bogus" + + "&sysparm_offset=%21%21%21bogus&%24skip=%21%21%21bogus&%24skipToken=%21%21%21bogus" + + "&continuation-token=%21%21%21bogus&start=%21%21%21bogus" + + "&limit=99999999999999999999999&maxResults=99999999999999999999999" + + "&maxresults=99999999999999999999999&per_page=99999999999999999999999" + + "&pageSize=99999999999999999999999&max_results=99999999999999999999999" + + "&top=99999999999999999999999&%24top=99999999999999999999999" + + "&max-keys=99999999999999999999999&q=%F0%9F%A6%80" + +func runAdapterSafetySweep(t *testing.T, adapterDir string) { + t.Helper() + + tmp := t.TempDir() + m := &manifest.Manifest{ + Path: filepath.Join(tmp, "stunt.yaml"), + Version: 1, + Network: manifest.Network{BasePort: 0}, + Services: map[string]manifest.Service{ + "svc": {Adapter: adapterDir}, + }, + } + e, err := newEngine(m, t.TempDir()) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + defer e.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + addrs, stop, err := e.ServeForTest(ctx) + if err != nil { + t.Fatalf("ServeForTest: %v", err) + } + defer stop() + base := addrs["svc"] + + st, ok := e.states["svc"] + if !ok { + t.Fatalf("no service state for svc (adapter load error?)") + } + + client := &http.Client{Timeout: 15 * time.Second} + i := 0 + for _, ep := range st.adapter.Endpoints { + if ep.Handler == "" { + continue // rules-only endpoint: static responses, no handler to crash + } + method := ep.Method + if method == "" { + method = "GET" + } + path := fillRouteParams(ep.Route, i) + garbageQuery + + // Body-bearing verbs get EVERY garbage body (not a rotating + // one); read verbs get one probe. Auth rotates per endpoint. + bodiesForRoute := []struct{ ct, body string }{} + if method == "POST" || method == "PUT" || method == "PATCH" { + bodiesForRoute = append(bodiesForRoute, garbageBodies...) + } + if len(bodiesForRoute) == 0 { + bodiesForRoute = append(bodiesForRoute, struct{ ct, body string }{"", ""}) + } + + for bi, gb := range bodiesForRoute { + var body *bytes.Reader + if gb.body != "" { + body = bytes.NewReader([]byte(gb.body)) + } else { + body = bytes.NewReader(nil) + } + req, err := http.NewRequest(method, base+path, body) + if err != nil { + // A garbage param can produce an unparseable URL; that + // exercises the CLIENT, not the server. Skip. + break + } + if auth := garbageAuth[(i+bi)%len(garbageAuth)]; auth != "" { + req.Header.Set("Authorization", auth) + } + if gb.body != "" { + req.Header.Set("Content-Type", gb.ct) + } + + resp, err := client.Do(req) + if err != nil { + t.Errorf("%s %s: request failed: %v", method, ep.Route, err) + continue + } + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + resp.Body.Close() + if resp.StatusCode >= 500 { + t.Errorf("%s %s (filled %q, body #%d) -> %d: client input must never 5xx; body: %.400s", + method, ep.Route, path, bi, resp.StatusCode, respBody) + } + } + i++ + } +} + +// fillRouteParams replaces every {name} in a route template with a +// deterministic garbage value (cycling by position + seed so different +// params on the same route get different poison). +func fillRouteParams(route string, seed int) string { + var b strings.Builder + n := 0 + for { + open := strings.IndexByte(route, '{') + if open < 0 { + b.WriteString(route) + return b.String() + } + end := strings.IndexByte(route[open:], '}') + if end < 0 { + b.WriteString(route) + return b.String() + } + b.WriteString(route[:open]) + b.WriteString(garbageParams[(seed+n)%len(garbageParams)]) + route = route[open+end+1:] + n++ + } +} diff --git a/internal/engine/adapter_fuzz_targets_test.go b/internal/engine/adapter_fuzz_targets_test.go new file mode 100644 index 00000000..21a2b3bd --- /dev/null +++ b/internal/engine/adapter_fuzz_targets_test.go @@ -0,0 +1,227 @@ +package engine + +import ( + "bytes" + "context" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "stuntapi.com/stunt/internal/manifest" +) + +// fuzzAdapters is the curated deep-fuzz set — one process boots one engine +// per adapter (fuzz workers are separate processes), so the set trades +// breadth for a rich mutation substrate: the largest REST surface +// (stripe-style), SQL text (cloudflare-style D1), SOQL (salesforce-style), +// OData (powerplatform-style), RFC 7807 (emailoctopus-style), JSON-RPC +// batches (eth-jsonrpc-style), bracket-form bodies (shopify-style). +var fuzzAdapters = []string{ + "stripe-style", + "cloudflare-style", + "salesforce-style", + "powerplatform-style", + "emailoctopus-style", + "eth-jsonrpc-style", + "shopify-style", +} + +type fuzzServer struct { + base string + client *http.Client +} + +var ( + fuzzSrvMu sync.Mutex + fuzzSrvs = map[string]*fuzzServer{} + fuzzSrvDir string +) + +// fuzzServerFor lazily boots the adapter's engine once per process (per +// fuzz worker). State accumulates garbage across inputs — that is the +// point; the dirs live under os.TempDir for the process lifetime because +// t.TempDir would be reaped after every input. +func fuzzServerFor(t *testing.T, name string) *fuzzServer { + t.Helper() + fuzzSrvMu.Lock() + defer fuzzSrvMu.Unlock() + if srv, ok := fuzzSrvs[name]; ok { + return srv + } + + if fuzzSrvDir == "" { + var err error + fuzzSrvDir, err = os.MkdirTemp("", "stunt-fuzz-*") + if err != nil { + t.Fatalf("tmp dir: %v", err) + } + } + adapterDir, err := filepath.Abs(filepath.Join("..", "..", "adapters", name)) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(adapterDir); err != nil { + t.Skipf("adapter %s not present", name) + } + + stateDir, err := os.MkdirTemp(fuzzSrvDir, "state-*") + if err != nil { + t.Fatal(err) + } + m := &manifest.Manifest{ + Path: filepath.Join(stateDir, "stunt.yaml"), + Version: 1, + Network: manifest.Network{BasePort: 0}, + Services: map[string]manifest.Service{ + "svc": {Adapter: adapterDir}, + }, + } + e, err := newEngine(m, stateDir) + if err != nil { + t.Fatalf("engine.New(%s): %v", name, err) + } + addrs, stop, err := e.ServeForTest(context.Background()) + if err != nil { + t.Fatalf("ServeForTest(%s): %v", name, err) + } + // Under `go test -fuzz` the workers are separate processes and each + // input is its own run — the engine must outlive t, so nothing is + // stopped and the dir stays. In plain `go test` (seed runs) it would + // just leak: close everything. + if fl := flag.Lookup("fuzz"); fl == nil || fl.Value.String() == "" { + t.Cleanup(func() { + stop() + e.Close() + _ = os.RemoveAll(stateDir) + fuzzSrvMu.Lock() + delete(fuzzSrvs, name) + fuzzSrvMu.Unlock() + }) + } else { + // Engine + listener live for the process; nothing to stop. + _ = stop + } + + srv := &fuzzServer{base: addrs["svc"], client: &http.Client{Timeout: 15 * time.Second}} + fuzzSrvs[name] = srv + return srv +} + +// fuzzSafePath percent-encodes any byte outside RFC 3986's path pchar set +// (keeping '/' structure) so every mutated path is still a valid request — +// the fuzzer exercises the router and handlers, not the HTTP client. +func fuzzSafePath(p string) string { + return fuzzSafeComponent(p, false) +} + +// fuzzSafeQuery sanitizes a query string the same way, additionally +// keeping the '?'-introducing '&'/'=' structure so mutations produce real +// params (without this the query half of the URL — cursors, limits, +// filters — is unreachable by the fuzzer). +func fuzzSafeQuery(q string) string { + return fuzzSafeComponent(q, true) +} + +func fuzzSafeComponent(s string, query bool) string { + if !query { + if s == "" || s[0] != '/' { + s = "/" + s + } + } + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || + c == '/' || c == '-' || c == '.' || c == '_' || c == '~' || + c == ':' || c == '@' || c == '!' || c == '$' || c == '&' || c == '\'' || + c == '(' || c == ')' || c == '*' || c == '+' || c == ',' || c == ';' || c == '=' { + b.WriteByte(c) + } else { + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} + +// FuzzAdapterRequests drives coverage-guided mutated requests through the +// full HTTP dispatch path (routing, body classification, Starlark handler, +// response marshal) of the curated adapter set. Invariant: client input +// never yields a 5xx — the same contract as TestAdapterInputSafety, but +// with the fuzzer inventing the inputs. +func FuzzAdapterRequests(f *testing.F) { + for _, s := range []struct { + idx int + method string + path string + query string + body string + }{ + {0, "GET", "/v1/charges", "", ""}, + {0, "POST", "/v1/charges", "", `{"amount": 1000, "currency": "usd"}`}, + {0, "POST", "/v1/payment_intents", "", "amount=1000¤cy=usd"}, + {1, "GET", "/zones", "cursor=!!!bogus", ""}, + {1, "POST", "/client/v4/accounts/acc/d1/database/db/query", "", `{"sql": "SELECT * FROM t"}`}, + {2, "GET", "/services/data/v60.0/query", "limit=99999999999999999999999&offset=25", ""}, + {2, "POST", "/services/oauth2/token", "", "grant_type=password&username=u&password=p"}, + {3, "GET", "/accounts", "%24skip=!!!bogus", ""}, + {4, "GET", "/lists", "limit=-1", ""}, + {4, "POST", "/lists", "", `{"name": "x"}`}, + {5, "POST", "/", "", `{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}`}, + {5, "POST", "/", "", `[{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}]`}, + {6, "POST", "/admin/api/2024-10/orders.json", "page_info=!!!bogus&limit=250", `{"order":{"line_items":[{"title":"x"}]}}`}, + {6, "GET", "/admin/api/2024-10/orders.json", "since_id=abc", ""}, + } { + f.Add(s.idx, s.method, s.path, s.query, []byte(s.body)) + } + f.Fuzz(func(t *testing.T, adapterIdx int, method, path, query string, body []byte) { + name := fuzzAdapters[abs(adapterIdx)%len(fuzzAdapters)] + srv := fuzzServerFor(t, name) + + url := srv.base + fuzzSafePath(path) + if query != "" { + url += "?" + fuzzSafeQuery(query) + } + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + return // unparseable method line — the HTTP client rejects it + } + if len(body) > 0 { + if body[0] == '{' || body[0] == '[' { + req.Header.Set("Content-Type", "application/json") + } else { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + } + resp, err := srv.client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + resp.Body.Close() + if resp.StatusCode >= 500 { + t.Fatalf("%s %s?%s %s (adapter %s) -> %d: client input must never 5xx; body: %.400s", + method, path, query, truncate(body, 200), name, resp.StatusCode, respBody) + } + }) +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} diff --git a/internal/engine/fuzz_parse_test.go b/internal/engine/fuzz_parse_test.go new file mode 100644 index 00000000..58ef4b3b --- /dev/null +++ b/internal/engine/fuzz_parse_test.go @@ -0,0 +1,88 @@ +package engine + +import ( + "encoding/json" + "strings" + "testing" +) + +// FuzzMatchRoute throws arbitrary pattern/path pairs at the router core — +// whole-segment params, embedded-prefix params (OData `prefix{p}suffix`), +// dot-separated versions, and whatever mutation the fuzzer invents. +// Invariants: no panic; a match's captured params never contain "/" (they +// are segment-scoped) and never use an empty key. +func FuzzMatchRoute(f *testing.F) { + for _, s := range [][2]string{ + {"/v1/charges/{id}", "/v1/charges/ch_123"}, + {"/accounts({id})", "/accounts(001xx)"}, + {"/v1.0/{container_id}", "/v1.0/c_1"}, + {"/v1.0/{a}/{b}/x", "/v1.0/1/2/x"}, + {"/v1/{id}", "/v1/"}, + {"/v1/{id}", "/v1/a/b"}, + {"/prefix{p}suffix", "/prefixmiddlesuffix"}, + {"/prefix{p}suffix", "/prefixsuffix"}, + {"/x/{a}", "//"}, + {"/a/b", "/a/b/"}, + {"/v21.0/{media_id}/comments", "/v21.0/m_1/comments"}, + {"{only}", "anything"}, + {"", ""}, + {"/", "/"}, + } { + f.Add(s[0], s[1]) + } + f.Fuzz(func(t *testing.T, pattern, path string) { + params, ok := matchRoute(pattern, path) + if !ok { + return + } + for k, v := range params { + if k == "" { + t.Fatalf("matchRoute(%q, %q) captured an empty param name: %v", pattern, path, params) + } + if strings.Contains(v, "/") { + t.Fatalf("matchRoute(%q, %q): param %q captured across segments: %q", pattern, path, k, v) + } + } + }) +} + +// FuzzParseFormBody drives the bracket-notation form parser (Rails/PHP +// SDK bodies: a[b]=v, a[]=v, a[0][b]=v) with arbitrary raw payloads. +// Invariants: no panic; the result is a non-nil map that stays +// JSON-marshalable — it flows into the Starlark request dict and back out +// through respond(), so a non-marshalable value would be a downstream 500. +func FuzzParseFormBody(f *testing.F) { + for _, s := range []string{ + "a=1", + "a[b]=v", + "a[]=1&a[]=2", + "a[0][b]=v", + "line_items[0][price_data][currency]=usd&line_items[0][quantity]=2", + "a[]=1&a[b]=2", + "a=1&a[b]=2", + "a[b]=1&a=2", + "=v", + "a=", + "&&&", + "a[b][c][d][e][f]=1", + "a%zz=badpercent", + "a=%C3%28", + "key=value+with+plus", + "a[]=b[c]=d", + } { + f.Add(s) + } + f.Fuzz(func(t *testing.T, raw string) { + // A body with no decodable pairs legitimately yields nil (the + // handler then sees an empty body); the invariant is that any + // map it DOES return stays JSON-marshalable — it flows into the + // Starlark request dict and back out through respond(). + m := parseFormBody(raw) + if m == nil { + return + } + if _, err := json.Marshal(m); err != nil { + t.Fatalf("parseFormBody(%q) produced non-marshalable state: %v", raw, err) + } + }) +} diff --git a/internal/engine/testdata/fuzz/FuzzAdapterRequests/039f89f23c6019af b/internal/engine/testdata/fuzz/FuzzAdapterRequests/039f89f23c6019af new file mode 100644 index 00000000..af121494 --- /dev/null +++ b/internal/engine/testdata/fuzz/FuzzAdapterRequests/039f89f23c6019af @@ -0,0 +1,6 @@ +go test fuzz v1 +int(96) +string("POST") +string("/") +string("") +[]byte("[{\"0000000\":\"000\",\"method\":0}]") diff --git a/internal/engine/testdata/fuzz/FuzzMatchRoute/39a4153466439c51 b/internal/engine/testdata/fuzz/FuzzMatchRoute/39a4153466439c51 new file mode 100644 index 00000000..39c2fda1 --- /dev/null +++ b/internal/engine/testdata/fuzz/FuzzMatchRoute/39a4153466439c51 @@ -0,0 +1,3 @@ +go test fuzz v1 +string("{}0") +string("0") diff --git a/internal/engine/testdata/fuzz/FuzzParseFormBody/f2550e626028267e b/internal/engine/testdata/fuzz/FuzzParseFormBody/f2550e626028267e new file mode 100644 index 00000000..cc736f0b --- /dev/null +++ b/internal/engine/testdata/fuzz/FuzzParseFormBody/f2550e626028267e @@ -0,0 +1,2 @@ +go test fuzz v1 +string("a[222222220][b]=v") diff --git a/internal/engine/testdata/fuzz/FuzzParseFormBody/f37633f36e8c0521 b/internal/engine/testdata/fuzz/FuzzParseFormBody/f37633f36e8c0521 new file mode 100644 index 00000000..88d44d99 --- /dev/null +++ b/internal/engine/testdata/fuzz/FuzzParseFormBody/f37633f36e8c0521 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("0[0]") diff --git a/internal/primitives/events/fuzz_validate_header_test.go b/internal/primitives/events/fuzz_validate_header_test.go new file mode 100644 index 00000000..e99fbb91 --- /dev/null +++ b/internal/primitives/events/fuzz_validate_header_test.go @@ -0,0 +1,34 @@ +package events + +import ( + "strings" + "testing" +) + +// FuzzValidateHeader drives the outgoing-webhook header validator with +// arbitrary keys and values. Invariants: no panic; anything accepted must +// be safe to hand to http.Header.Set — no CR or LF anywhere, or the +// delivery becomes a header-smuggling vector. +func FuzzValidateHeader(f *testing.F) { + for _, s := range [][2]string{ + {"X-Test", "v"}, + {"X-Bad", "v\r\nX-Inject: yes"}, + {"X-Bad\r\nInjected: yes", "v"}, + {"Host", "example.com"}, + {"Content-Length", "5"}, + {"", ""}, + {"x-signature-ed25519", "3×q=base64=="}, + {"🦀", "🦀"}, + {"X-Ok", "line1\rline2"}, + {"X-Ok", "line1\nline2"}, + } { + f.Add(s[0], s[1]) + } + f.Fuzz(func(t *testing.T, key, val string) { + if err := validateHeader(key, val); err == nil { + if strings.ContainsAny(key, "\r\n") || strings.ContainsAny(val, "\r\n") { + t.Fatalf("validateHeader accepted CRLF: key=%q val=%q", key, val) + } + } + }) +} diff --git a/internal/starlark/crypto_asym_test.go b/internal/starlark/crypto_asym_test.go index 5398217a..785d1d45 100644 --- a/internal/starlark/crypto_asym_test.go +++ b/internal/starlark/crypto_asym_test.go @@ -301,7 +301,18 @@ func TestBase64URLDecodeRoundTrip(t *testing.T) { t.Errorf("decode(%q) = %q, want %q", in, dec, claims) } } - if err := callAsymErr("base64url_decode", sk.Tuple{sk.String("!!!not-base64!!!")}); err == nil { - t.Error("garbage input: want error, got nil") + // Malformed input is TOTAL: None, no error — the argument is + // usually client input (JWT segments, cursors) and a raise would be + // an unhandled 500. (Note "garbage" is VALID unpadded base64url — + // 7 chars — so it is only garbage to the std alphabet.) + for _, in := range []string{"!!!not-base64!!!", "a", "!!!!"} { + if got := callAsym(t, "base64url_decode", sk.Tuple{sk.String(in)}); got != sk.None { + t.Errorf("base64url_decode(%q) = %v, want None", in, got) + } + } + for _, in := range []string{"!!!not-base64!!!", "a", "garbage", "=="} { + if got := callAsym(t, "base64_decode", sk.Tuple{sk.String(in)}); got != sk.None { + t.Errorf("base64_decode(%q) = %v, want None", in, got) + } } } diff --git a/internal/starlark/cryptomod.go b/internal/starlark/cryptomod.go index ed09158e..d43a566b 100644 --- a/internal/starlark/cryptomod.go +++ b/internal/starlark/cryptomod.go @@ -131,9 +131,13 @@ func base64Decode(_ *sk.Thread, b *sk.Builtin, args sk.Tuple, kwargs []sk.Tuple) if err := sk.UnpackArgs(b.Name(), args, kwargs, "data", &s); err != nil { return nil, err } + // Total on malformed input (None, not a raise): the argument is + // usually client input (auth material, cursors, ids), handlers have + // no try/except, and a raise is an unhandled 500. Same contract as + // json_safe_decode. out, err := base64.StdEncoding.DecodeString(s) if err != nil { - return nil, fmt.Errorf("crypto.base64_decode: %w", err) + return sk.None, nil } return sk.String(string(out)), nil } @@ -153,9 +157,10 @@ func base64urlDecode(_ *sk.Thread, b *sk.Builtin, args sk.Tuple, kwargs []sk.Tup if err := sk.UnpackArgs(b.Name(), args, kwargs, "data", &s); err != nil { return nil, err } + // Total on malformed input — see base64Decode. out, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")) if err != nil { - return nil, fmt.Errorf("crypto.base64url_decode: %w", err) + return sk.None, nil } return sk.String(string(out)), nil } diff --git a/justfile b/justfile index f96c81d8..01afd925 100644 --- a/justfile +++ b/justfile @@ -85,6 +85,27 @@ cross-build: test: go test -race ./... +# Coverage-guided fuzzing — each target for the given time (default 30s; +# pass just fuzz 2m for longer rounds). The fuzz seed corpora also run as +# regular tests in `just test`, so discovered inputs stay pinned forever. +# Found failures are written to testdata/fuzz// — commit them. +fuzz t="30s": + #!/bin/sh + set -e + for spec in \ + "internal/engine FuzzMatchRoute" \ + "internal/engine FuzzParseFormBody" \ + "internal/engine FuzzAdapterRequests" \ + "internal/adapter/runtime FuzzParseMultipart" \ + "internal/primitives/events FuzzValidateHeader"; do + set -- $spec + echo "== $1 $2" + go test "$1" -run "^$2\$" -fuzz "^$2\$" -fuzztime={{t}} || { + echo "FAIL: $2 — failing input in $1/testdata/fuzz/$2/" + exit 1 + } + done + # `go vet` across all packages. vet: go vet ./...