fix(tools): reject path traversal in Drive, BigQuery, Box, Supabase and Contacts ids - #7269
fix(tools): reject path traversal in Drive, BigQuery, Box, Supabase and Contacts ids#7269waleedlatif1 wants to merge 30 commits into
Conversation
GitHub tools interpolate LLM-writable values (owner, repo, issue_number, pullNumber, path, branch, ref, label name, gist_id, ...) straight into the request path. A value of `..` re-aims an authenticated request — carrying the workspace's GitHub token — at a different resource, including on DELETE routes such as delete_file, delete_release and delete_branch. Guards every such site with the helpers in tools/url-path.ts, and adds two new helpers there for the parameter shapes GitHub actually has: - safeUrlPath, for values that legitimately carry `/` as structure (path, branch, ref, base, head) - safeEncodedUrlPathSegment, for a value the provider reads as ONE path parameter that may still contain `/` (a namespaced label such as `area/api`) Adds tools/github/path_safety.test.ts, which enumerates tools from the barrel and probes every parameter that reaches the path, so a new unguarded parameter fails CI.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Greptile SummaryThis PR rejects path traversal in provider identifiers and multi-segment resource paths before authenticated requests are built.
Confidence Score: 5/5The PR appears safe to merge because the previously reported harness masking and type-safety issues are fixed and no blocking failure remains. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/tools/tests/path-safety.ts | Replaces the masking-prone traversal harness with independently driven tool-parameter pairs, typed structural narrowing, branch probing, and pinned coverage inventories. |
| apps/sim/tools/github/path_safety.test.ts | Adds direct GitHub path-guard assertions and coverage for guarded identifiers without leaving an actionable follow-up defect. |
| apps/sim/tools/url-path.ts | Defines the shared single- and multi-segment path validation behavior used by the affected provider tools. |
| apps/sim/tools/supabase/utils.ts | Routes storage buckets, object keys, and function names through the appropriate shared path guards. |
| apps/sim/lib/internal/supabase/operations.ts | Guards internal storage upload paths and reports rejected caller inputs as client errors before issuing provider requests. |
| apps/sim/lib/internal/github/operations.ts | Guards internal GitHub URL components and maps path validation failures to client errors. |
| apps/sim/tools/google_bigquery/path_safety.test.ts | Verifies traversal rejection and strict whitespace behavior for BigQuery path identifiers, including state-changing operations. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[User or model supplied identifier] --> B{Path value type}
B -->|Single segment| C[safe or strict segment guard]
B -->|Multi segment| D[safe path guard]
C --> E[Encoded provider URL]
D --> E
E --> F[Authenticated provider request]
C -->|Rejected| G[Named client error]
D -->|Rejected| G
Reviews (22): Last reviewed commit: "fix(supabase): trim a pasted storage key..." | Re-trigger Greptile
There was a problem hiding this comment.
2 issues found across 50 files
Confidence score: 3/5
apps/sim/tools/multi-segment-url-path.tstrims each Supabase object-key segment before encoding, so keys with surrounding whitespace—or whitespace-only segments—can resolve to a different object or fail; preserve the raw segments when constructing the request path.apps/sim/tools/__tests__/path-safety.tsdoes not isolate each path parameter under test, allowing an earlier guarded identifier to fail first and mask traversal through a later unguarded parameter; pin the other parameters to safe values in independent cases.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/tools/multi-segment-url-path.ts">
<violation number="1" location="apps/sim/tools/multi-segment-url-path.ts:72">
P2: Supabase object keys can legitimately contain surrounding whitespace, but this trims each segment before encoding, so the request addresses a different object and whitespace-only segments are lost. Preserve each raw non-dot segment and reject only exact `.` or `..` before encoding.</violation>
</file>
<file name="apps/sim/tools/__tests__/path-safety.ts">
<violation number="1" location="apps/sim/tools/__tests__/path-safety.ts:73">
P2: Exercise each path parameter independently while pinning the other string parameters to safe values. Otherwise an earlier guarded identifier can throw first, allowing a later unguarded path parameter to escape traversal-test coverage.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Review found a data-integrity bug in the new helper: safeUrlPath trimmed each segment, but a leading or trailing space is a legal filename character that git stores verbatim, so `docs/ draft.md` was silently rewritten to `docs/draft.md` and read, updated, or deleted a different file than the caller named. Splits the behaviour by purpose instead of dropping trimming outright: - safeUrlPathSegment keeps trimming. Its inputs are opaque copy-pasted ids and ~690 call sites depend on it. - safeUrlPath no longer trims anywhere. Whitespace is preserved byte-for-byte and percent-encoded. A whitespace-only segment is still rejected, as are dot segments and backslashes. Not trimming does not weaken the dot check: the URL parser removes %2e%2e but leaves %20..%20 inert. Also from review: - Path-guard failures in lib/internal/github/operations.ts now raise GitHubOperationError(400) instead of a plain Error, which executeGitHubTool mapped to 500 for what is caller-supplied input. - The traversal suite drops ToolConfig<any, any> and its `as any` cast for a structural interface plus a type guard. - The suite no longer swallows discovery failures. Every skip is recorded and asserted against an explicit expectation, which immediately surfaced that github_job_logs had fallen out of coverage entirely: a string filler in the sibling job_id parameter aborted the build before owner/repo could be probed. Non-target number parameters now get a number, and the 12 genuinely pathless tools are listed rather than inferred.
bea017e to
9183f02
Compare
9183f02 to
29cdc74
Compare
29cdc74 to
6a506bf
Compare
|
@greptile @cubic-dev-ai review Re-requesting review against the current head What changed since
Red-first verification went 322 → 652 failures with all guards reverted; 13,423 pass with them in place. Two points most likely to draw fire, with the reasoning up front:
Note this PR depends on #7262 and must merge after it. |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
|
@greptile review |
|
@cubic-dev-ai review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 130 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
|
@greptile review |
|
@cubic-dev-ai review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
3 issues found and verified against the latest diff
Confidence score: 3/5
apps/sim/tools/google_bigquery/create_table.tsandapps/sim/tools/google_bigquery/query.tscan send different project IDs in the URL and JSON body whenprojectIdincludes surrounding whitespace, potentially targeting inconsistent BigQuery resources—normalizeprojectIdonce and reuse it everywhere.apps/sim/tools/google_bigquery/path_safety.test.tstreats dotted dataset/table identifiers as valid single-segment IDs, weakening the path-safety test coverage for these inputs—separate legitimate ID fixtures from dotted resource references and add focused cases.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/tools/google_bigquery/create_table.ts">
<violation number="1" location="apps/sim/tools/google_bigquery/create_table.ts:70">
P2: When `projectId` has surrounding whitespace, the new URL normalizes it but `tableReference.projectId` still sends the raw value. Normalize the project ID once and use the same value in both the path and JSON body, otherwise BigQuery receives inconsistent identifiers and the create request can fail.</violation>
</file>
<file name="apps/sim/tools/google_bigquery/path_safety.test.ts">
<violation number="1" location="apps/sim/tools/google_bigquery/path_safety.test.ts:34">
P3: LEGITIMATE_IDS is applied verbatim to every single-segment BigQuery id (projectId, datasetId, tableId, jobId), but the dotted values `my_dataset.my_table`/`my-project.my_dataset.my_table` are not legitimate values for any one of those parameters — BigQuery resource IDs never contain dots, since `project.dataset.table` dots are the separators between distinct path parameters on the REST API. Pinning them as legitimate forces the guard to accept interior dots in single-segment ids and would spuriously fail (and block) a future, still-correct tightening that rejects them. Move the dotted qualified names out of the shared pool and use per-parameter legitimate sets.</violation>
</file>
<file name="apps/sim/tools/google_bigquery/query.ts">
<violation number="1" location="apps/sim/tools/google_bigquery/query.ts:69">
P2: When `projectId` has surrounding whitespace and `defaultDatasetId` is supplied, this call trims the URL project ID while the request body keeps the raw value. Normalize `projectId` once and use the same normalized value in both the URL and `defaultDataset.projectId`.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
|
@greptile review |
|
@cubic-dev-ai review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
3 issues found and verified against the latest diff
Confidence score: 3/5
apps/sim/tools/google_bigquery/create_table.tscan send different project IDs in the URL andtableReferencewhen input has surrounding whitespace, risking a failed or inconsistent table creation request — normalizeprojectIdonce and reuse it.apps/sim/tools/google_bigquery/query.tscan mismatch the URL project ID andbody.defaultDataset.projectId, so queries usingdefaultDatasetIdmay be rejected by BigQuery — use one normalized project ID throughout.apps/sim/tools/google_bigquery/create_dataset.tstrims only the URL project ID while retaining the raw value indatasetReference.projectId, which can makedatasets.insertfail for padded input — normalize the value before building both request parts.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/tools/google_bigquery/query.ts">
<violation number="1" location="apps/sim/tools/google_bigquery/query.ts:69">
P2: When `projectId` contains surrounding whitespace and `defaultDatasetId` is provided, this URL uses the trimmed project ID while `body.defaultDataset.projectId` remains raw, so BigQuery receives inconsistent project references and rejects the query. Normalize `projectId` once and reuse that value for both the URL and request body.</violation>
</file>
<file name="apps/sim/tools/google_bigquery/create_table.ts">
<violation number="1" location="apps/sim/tools/google_bigquery/create_table.ts:70">
P2: When `projectId` has surrounding whitespace, this guard trims it in the URL but the request body still sends the padded value. Normalize `projectId` once and use the same value for both the URL and `tableReference`, or reject padded IDs before building the request.</violation>
</file>
<file name="apps/sim/tools/google_bigquery/create_dataset.ts">
<violation number="1" location="apps/sim/tools/google_bigquery/create_dataset.ts:63">
P2: When `projectId` has surrounding whitespace, this guard trims it for the URL while `body` still sends the raw value. Normalize once and use the same project ID in `datasetReference.projectId` so `datasets.insert` does not receive mismatched identifiers.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
safeUrlPathSegment trims before encoding, so guarding the path introduced a divergence the previous encodeURIComponent(params.projectId) did not have: the URL addressed the trimmed project while the body still carried the padded string. datasetId and tableId were already trimmed in these bodies, so projectId was the one identifier out of step. BigQuery resolves defaultDataset and tableReference from the body, so a mismatch either 404s or names a project the path does not. Normalizes projectId in create_dataset, create_table and query, and pins URL/body agreement with a test verified red against the un-normalized body.
A fully-qualified project.dataset.table is BigQuery's SQL syntax and appears in the query string, never in a path segment. Listing one as a legitimate id for every path parameter asserted support that does not exist and would fight any future per-identifier format validation. The property those values were really covering — a dot inside a segment is preserved rather than treated as traversal — belongs to the guard, and is already pinned on it directly in tools/url-path.test.ts.
The bulk substitution that added safeUrlPathSegment also rewrote the pre-fix template quoted inside this file's header comment, leaving it claiming the id was interpolated with no treatment at all into a template that now shows the guard. In a security test file a stale claim like that misleads a reader about whether the guard is present.
…e trim safeUrlPathSegment deliberately accepts a finite number or a bigint, because an LLM tool call can serialize a numeric-looking id as a JSON number. The .trim() this replaces did not, so a numeric projectId built the path fine and then threw a raw TypeError while building the body — the request died after passing its own guard. Adds canonicalBigQueryId, which round-trips through safeUrlPathSegment and undoes only the percent-encoding, so the body reuses the path guard's accepted input kinds, trimming and dot-segment rejection instead of restating them. Applied to all six body identifiers, including the five .trim() sites that predate this branch and shared the same fragility. Pinned with a numeric-projectId test verified red against the bare trim.
cubic found the docstring overclaimed. Discovery pinned one sibling to one branch literal at a time, so a parameter reachable only when two siblings hold specific values — action === 'unblock' && kind === 'folder' — was never probed and silently untested, while the comment said every branch is probed. Adds pair probing over distinct parameters, capped so a tool with many parameters and many literals cannot blow up combinatorially. The bound is stated rather than glossed: depth stops at two, so three simultaneous conditions would still be missed. No service here needs even one literal to reach any parameter, so the covered count is unchanged at 75. Verified the machinery is live rather than dead code with a synthetic two-condition builder: singles-only discovery misses its id, pair probing finds it.
…g it Guarding these paths introduced a data-loss hazard that the guard itself hid. projectId was interpolated as encodeURIComponent(params.projectId) before this branch — never trimmed — so ' my-project ' became %20%20my-project%20%20, which names no GCP project and failed cleanly: before: /bigquery/v2/projects/%20%20my-project%20%20/datasets/prod_dataset after: /bigquery/v2/projects/my-project/datasets/prod_dataset safeUrlPathSegment trims, so on delete_dataset and delete_table that turns a request which did nothing into one that irreversibly destroys a real dataset or table, from a value the caller never wrote. The rule applied is narrow and testable: this change must not turn a failing request into a succeeding one. Every identifier it newly began trimming now refuses surrounding whitespace — projectId in all eleven tools, plus datasetId and tableId where those were previously untrimmed. Identifiers already trimmed before this branch keep safeUrlPathSegment, since trimming them is not a change made here and refusing them would break callers whose stored value works today. Rejection is not argued from consistency with the other guarded sites; that averages over very different blast radii. It stands on two facts specific to these values: no legitimate BigQuery identifier carries surrounding whitespace, so nothing real is refused, and the previous behaviour was already a clean failure, so refusing preserves it while naming the offending parameter. Pinned by a REJECTS-style set that upgrades the generic per-pair whitespace assertion to demand a throw, plus explicit delete-tool tests. Verified non-vacuous: reverting either guard to a plain trim fails both.
cubic found that adding throwing guards to encodeStoragePath and encodeStorageSegment changed how caller mistakes are attributed. Nothing in those helpers threw before this branch, so the new rejections fall through to the generic handler in executeSupabaseStorageUpload and surface as HTTP 500 — blaming the server for a caller's '..' — while validateSupabaseProjectId one line above already reports a bad project id as 400. Maps guard rejections to 400 at both call sites, keeping the guard's named message. storage-get-public-url surfaces the same rejection through its own result shape rather than throwing out of the operation. Pinned with tests over traversal, empty-segment and bad-bucket inputs asserting 400 and that no provider request is made. Verified non-vacuous: removing the mapping fails all four.
…lving it Sweeping the six services for the same class found in BigQuery turned up a second instance nobody had flagged. signRequestId was interpolated raw before this branch — not even a .trim() — so a padded id was percent-encoded to %20%20<uuid>%20%20, matched no sign request, and the call failed: before: /2.0/sign_requests/%20%20<uuid>%20%20/cancel -> 404, no-op after: /2.0/sign_requests/<uuid>/cancel -> cancels it box_sign_cancel_request is irreversible, so trimming would have converted a request that did nothing into one that cancels a real signature request. Extracts the rule shared with BigQuery into strictUrlPathSegment, now that two services need it, and applies it to all three box_sign tools. Box Sign ids are UUIDs, so no legitimate value carries whitespace. The rest of the sweep is clean and deliberately unchanged: google_drive and box already trimmed every path id before this branch, so nothing there is newly resolved; google_contacts and the Supabase storage key move the other way, since safeUrlPath no longer trims at all, which can only turn a previously working value into a clean failure. Pinned the same way as BigQuery and verified non-vacuous: reverting the guard fails both the generic per-pair assertion and the explicit cancel test.
cubic found the whitespace assertion tolerated a throw unconditionally, even in the branch whose docstring says padding must survive to the wire. A regression that made safeUrlPath trim or refuse padding would have left the suite green — the same assertion-that-cannot-fail class this file exists to prevent. The tolerance now applies only to ordinary ids, where refusing padding is an equally correct outcome. Verified: pointing the Supabase storage key at a guard that refuses padding now fails the assertion instead of passing.
cubic found the fourth instance of the same pattern: the renders-inert cases caught any throw and returned, so none of the assertions ran. The suite proved only that values which build stay inert — a guard that over-tightened and rejected one it should have encoded passed silently. The surrounding-whitespace case for ordinary ids had the same hole. A throw is now a failure unless the parameter is named in strictlyValidated, which is exactly Supabase table and functionName: validateDatabaseIdentifier and validateFunctionName predate these guards and legitimately refuse values the shared guards only render inert. Measured rather than assumed — those ten pairs are the only ones that reject any MUST_NOT_RESHAPE value. Verified non-vacuous by over-tightening strictUrlPathSegment to reject '#': three box_sign cases fail where they previously passed.
…he last swallow Three fixes from the latest review round. cubic found the whitespace guard copied the rejected value into its message. These parameters are user-or-llm and the error returns as a tool result the model reads, so quoting the input echoes attacker-chosen text — U+2028/U+2029 included — straight into the model's context. The parameter name is the actionable part; the value is dropped. The box_sign suite's head comment still said signRequestId goes through safeUrlPathSegment. It goes through strictUrlPathSegment, and that distinction is precisely what the whitespace pins exist for, since the plain guard trims. Found while auditing the remaining catch sites rather than waiting for it: the per-parameter discovery probe swallowed a throw with no assertion behind it, so a parameter that failed on every branch dropped out of coverage while its siblings kept the tool covered. Only the count floor would have noticed, and that degrades as tools are added. Discovery now reports a parameter that never produced a URL at all, distinguished from one that built fine without the sentinel in its path, and each suite pins that set empty.
Sixth instance of the vacuous-assertion pattern, found by auditing my own each() call sites rather than waiting for review. describe.each over an empty array emits no tests and no failure. KEY_PARAMS and FLAT_PARAMS are derived by filtering PATH_PARAMS on the 'path' name, so renaming that parameter would silently empty KEY_PARAMS and delete the entire legitimate object keys block — the assertions proving folder/sub/file.png survives byte-for-byte — while the floor on the total still passed. A floor on the sum cannot see a shift between the two groups. Verified non-vacuous: pointing the filter at a renamed parameter fails the new assertion where everything else still passed.
cubic found namesParam was a substring scan after stripping non-letters, so it accepted exactly the cases the assertion exists to reject: "Invalid input" satisfied paramName "id" (generic) "projectId cannot have leading …" satisfied paramName "id" (WRONG param) "tableId cannot be '.'" satisfied paramName "table" (WRONG param) "pathological failure" satisfied paramName "path" (substring) A guard naming the wrong identifier therefore satisfied every rejects-by-name assertion across all six suites. The message is now split into letter-only tokens and the parameter must equal a token or a run of adjacent tokens joined. The join keeps prose spellings valid — validateFunctionName reports functionName as "Invalid function name", which is a correct naming, not a near-miss. The run is capped at four tokens and abandoned once longer than the target. All 1523 existing assertions still pass, so no guard was relying on the loose match. namesParam is now exported with its own contract test, because a weakness in it is invisible from every suite it powers: reverting to the substring version fails four of the new cases and nothing else.
Eighth instance of the vacuous-assertion pattern, found by auditing my own
if-guarded expects.
The body check was wrapped in if (serialized?.includes('projectId')), so it
stopped verifying the moment a body dropped the field — the assertion guarded
itself out of existence. All three tools carry projectId in defaultDataset,
tableReference or datasetReference, so requiring it is correct.
Verified: removing projectId from query.ts's body now fails two assertions
where it previously passed silently.
#7262 landed 515b951, narrowing safeUrlPath's empty-segment check from !segment.trim() to !segment — the fix this suite asked for after flagging the over-rejection. Rebased onto it. That changes behaviour under the Supabase storage key, so the suite is updated rather than left asserting the old error text: a/ /b -> a/%20/b (now permitted) a//b -> rejects: empty path segment (unchanged) The distinction is the point and both halves are now pinned. A component that is a single space is a legal, nameable key component; a genuinely empty one addresses a different object than the caller wrote. Collapsing them again in either direction is a silent correctness change — one makes a real key unreachable, the other retargets the request.
…e exact cubic found toolsWithoutPathParams re-ran the whole barrel sweep although every suite already calls discoverPathParams once. Discovery builds a URL for every tool, branch assignment and declared parameter, so that doubled the most expensive part of each suite for a list already in hand. The inventory now comes back from the same sweep as withoutPathParams and the standalone helper is gone. Also records, in both the shared harness header and the Supabase suite, why the assertions pin exact encoded output and exact error text. Those guards live in url-path.ts, owned by the PR this branch is rebased onto, so their behaviour changes land underneath this suite without touching a line of it. That has happened twice — segment trimming dropped, then the empty-segment check narrowing from !segment.trim() to !segment — and only the exact assertions caught either. A suite asserting just toThrow() would have gone green through both, and the second is a silent correctness change in either direction.
…l copy #7262 landed d2c74d7, defining strictUrlPathSegment and strictEncodedUrlPathSegment in url-path.ts to refuse padded identifiers on state-changing requests. That is the rule this branch introduced locally while the two PRs were in flight, so the duplication collapses now that the rebase brings it in: tools/strict-url-path.ts is deleted and its four consumers import from @/tools/url-path. Their assertUnpadded is slightly better than the local version — an all-whitespace value falls through to safeUrlPathSegment and reports "is required" rather than a padding error, which names the real problem. strictCanonicalBigQueryId now derives from their guard too, so the body value and the path value share one rule rather than two. The error text changed from "cannot have" to "must not have leading or trailing whitespace", and four assertions failed on the rebase because they pin the exact text. They are updated to the new wording rather than loosened — that precision is the property that caught this and two earlier upstream changes.
cubic found expect(serialized).not.toContain(' my-project ') could not fail:
it was written when the fixture supplied a padded projectId, and once the strict
guard made padding throw I unpadded the fixture and left the assertion behind.
Changing a fixture silently defanged an assertion written for the old one.
Padded refusal is covered where it belongs — NEWLY_TRIMMED_BY_THIS_CHANGE and
the destructive-tool describe — so nothing is lost by dropping it.
The expected body value is now derived from the URL rather than hard-coded, so
the test is about agreement: if either side starts naming a different project it
fails, whereas two independent literals both pass a change made to both.
Verified by pointing the body at 'other-project' — fails now, would have passed
before.
#7262 landed 0c5108e documenting why its strict guards stop at writes, and explicitly says not to complete the asymmetry by routing GETs through them. Six of my fourteen strict call sites were GETs, so they contradicted the contract of the helper they import. Their rule is sharper than mine. I applied refuse-padding uniformly wherever the change newly trimmed an identifier; the reason that rule exists is asymmetric harm. On a write, being wrong mutates or destroys a resource the caller never named — unrecoverable, and invisible in review since every traversal assertion still passes. On a read, being wrong returns data from the resource they almost certainly did mean, having typed the padded name themselves, while refusing breaks a working paste-with-a-stray-newline flow for no safety gain. Reverts to safeUrlPathSegment on box_sign get_request and on BigQuery get_query_results, get_table, list_datasets, list_table_data and list_tables. The eight state-changing routes keep the strict guard. Test pins follow: the NEWLY_TRIMMED map lists writes only, and box_sign gates rejectsSurroundingWhitespace on the state-changing ids.
cubic read the newly-trimmed map as missing create_table's datasetId and tableId. It is not — both were already params.<name>.trim() before this branch, in the URL and the body respectively, so neither is newly trimmed and the map is correct. But the confusion is my fault. The TSDoc gave the exception as a short illustrative list that omitted create_table, so it read as exhaustive and implied a coverage gap. It now states the rule and enumerates every write tool in a table, with the command that verifies each line against origin/staging. The exemptions are also pinned as tests rather than left to the comment: the already-trimmed identifiers must still trim, so the deliberate boundary is visible to anyone who suspects a gap. Verified non-vacuous by wrongly making delete_table's datasetId strict, which fails three of them.
This is the one place in the branch where something that worked before would have stopped working. The replaced helper trimmed, so a saved workflow whose key field carried a pasted stray space resolved fine: old " avatars/photo.png " -> avatars/photo.png (found it) new -> %20%20avatars/photo.png%20%20 (404) The whole value is trimmed again, restoring that. Whitespace *inside* the key stays preserved, because "avatars/ photo.png" names a component that genuinely starts with a space — the correctness safeUrlPath exists to provide. Edge padding on the whole value is a paste artifact and never part of the key; the two cases are different in kind. Trimming rather than refusing rests on a fact worth stating: no destructive storage operation routes an object key through this helper. storage_delete sends keys in the body as prefixes, and storage_move and storage_copy use sourceKey/destinationKey; only the bucket reaches a path guard. The callers are storage_download, storage_get_public_url and storage_create_signed_url plus storage_upload and storage_create_signed_upload_url, so upload and download trim identically and a padded key is never stored padded — the symmetry that originally argued for preserving, satisfied without breaking pasted keys. Trimming first also exposes a padded dot segment instead of encoding it, so " .. " is now refused where it previously survived as %20%20..%20%20. Pins the regression, the interior-preservation, and the no-destructive-caller premise, since the last of those could change silently.
34ea706 to
5e855a6
Compare
|
@greptile review |
|
@cubic-dev-ai review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
|
Closing for now — not because of a defect. This batch grew to 17 PRs across ~700 changed call sites, and we would rather revisit it as smaller, independently testable pieces than merge this much at once. Nothing here is lost: the branch |
There was a problem hiding this comment.
1 existing issue remains and no new issues found across 135 files
Confidence score: 3/5
- In
apps/sim/tools/supabase/utils.ts, whitespace is normalized before addressing Supabase object keys, so legitimate keys with leading or trailing whitespace can silently resolve to a different object and conflict with the intended rejection rule; pass the raw path while rejecting only bare./..segments.
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Re-trigger cubic
The defect
Google Drive, BigQuery, Box, Box Sign, Supabase and Google Contacts tools interpolated LLM-writable ids straight into URL path segments. These params are
visibility: 'user-or-llm', so prompt injection controls them. A value like../../files/victimre-aims an authenticated request — carrying the user's OAuth token or the workspace's Supabase service-role key — at a different resource on the same host, including on DELETE.encodeURIComponentis not a fix..and..are unreserved, so they survive encoding untouched, and the WHATWG URL parser removes dot segments after decoding:Only rejecting the value works.
Highlight: Supabase
encodeStoragePathlooked sanitised and was notapps/sim/tools/supabase/utils.tssplit the object key on/and ranencodeURIComponentover each segment. BecauseencodeURIComponent('..') === '..', a key of../..passed through byte-for-byte unchanged:Every Supabase storage tool and both internal storage operations (
lib/internal/supabase/operations.ts,operations/storage-get-public-url.ts) went through it, so/storage/v1/object/<bucket>/../../rest/v1/...escaped the storage prefix entirely.The fix
safeUrlPathSegment(DrivefileId/permissionId/commentId/revisionId, BoxfileId/folderId, Box SignsignRequestId, BigQueryprojectId/datasetId/tableId/jobId, SupabasefunctionNameand bucket names).safeUrlPath(Supabase storage keys, ContactsresourceName). An earlier revision added its own near-duplicate helper in a separate file; that has been deleted. There is exactly one multi-segment guard in the tree.Empty segments are now rejected where the old helper silently emitted them.
/storage/v1/object/avatars//folder/x.pngand.../avatars/folder/x.pngare different paths, so the old behaviour addressed a different object than the caller wrote. Nothing real needs one —executeStorageUploadOperationnormalizes its own trailing separator, and aresourceNameis alwayspeople/<id>.Storage keys: a pasted key still resolves
Guarding storage paths came within one commit of breaking a real flow, and this is the only place in the PR where that was true.
safeUrlPathtrims nowhere — correct for a git filename — but the helper it replaced did trim, so a saved workflow whose key field carried a pasted stray space resolved fine:Resolution: trim the whole value, preserve whitespace inside it. The two cases differ in kind — edge padding on the whole value is a paste artifact and never part of the key, while
avatars/ photo.pngnames a component that genuinely starts with a space. So pasted keys resolve exactly as before, and the correctnesssafeUrlPathexists to provide is kept.Why trim rather than refuse, when this PR refuses elsewhere? Because the refuse rule is for state-changing requests where being wrong is unrecoverable, and no destructive storage operation routes an object key through this helper:
storage_deleteprefixes) — only the bucket reaches a path guardstorage_move,storage_copysourceKey,destinationKey)storage_download,storage_get_public_url,storage_create_signed_urlstorage_upload,storage_create_signed_upload_urlUpload and download therefore trim identically, so a padded key is never stored padded and the pair cannot disagree about what a key is. That was the original argument for preserving, and it is satisfied here without breaking pasted keys. The no-destructive-caller premise is pinned in
path_safety.test.ts, because the reasoning depends on it and it could change silently.Trimming first also makes this stricter than before in one respect:
" .. "is now refused outright, where preserving let it survive as%20%20..%20%20.This is the highest-consequence change in the PR and it is on two DELETE tools. Read this one.
projectIdwas interpolated asencodeURIComponent(params.projectId)before this branch — never trimmed. A padded value became%20%20my-project%20%20, which names no GCP project (ids match[a-z][a-z0-9-]{5,29}, no whitespace), so the request failed cleanly and nothing happened.safeUrlPathSegmenttrims:On
google_bigquery_delete_datasetandgoogle_bigquery_delete_tablethat silently converts a request that did nothing into one that irreversibly destroys a real dataset or table, from a value the caller never wrote.datasetIdon those tools was already.trim()-ed pre-PR, soprojectIdwas the one identifier out of step — the guard hid its own hazard.Decision: refuse the padded value — on state-changing routes only.
An earlier revision applied the refusal to every tool that newly trimmed an identifier, including reads. #7262 then documented the sharper rule and this branch adopted it: the reason the rule exists is that the harm is asymmetric. On a write, being wrong mutates or destroys a resource the caller never named — unrecoverable, and invisible in review because every traversal assertion still passes. On a read, being wrong returns data from the resource the caller almost certainly did mean, having typed the padded name themselves, while refusing breaks a working paste-with-a-stray-newline flow for no safety gain.
delete_dataset,delete_table,create_dataset,create_table,query,insert_rows; Box Signcancel_request,resend_requestget_query_results,get_table,list_datasets,list_table_data,list_tables; Box Signget_requestThe rule is narrow and testable: this change must not turn a failing request into a succeeding one. Two facts specific to these values carry it, and neither is an appeal to consistency with the other guarded sites — that argument averages over very different blast radii and would excuse the deletion above:
Second instance, found by sweeping the other five services
The same class exists in Box Sign, and it was not in the original report — I found it by asking, for every service, which parameters does this PR newly trim, and which of those sit on an irreversible request?
signRequestIdwas interpolated raw before this branch — not even a.trim():box_sign_cancel_requestis irreversible, so the same fix applies. Box Sign ids are UUIDs, so no legitimate value carries whitespace. Both services use #7262'sstrictUrlPathSegment. This branch briefly carried its owntools/strict-url-path.tswhile the two PRs were in flight; once #7262 landed the same rule, that file was deleted and its consumers repointed, so there is one strict guard in the tree rather than two.The rest of the sweep is clean, and deliberately unchanged:
google_drive?.trim()box.trim()google_contactsresourceName.trim()→ nowsafeUrlPath(no trim)supabasestorage keyDeliberately not changed: identifiers already
.trim()-ed before this branch —datasetIdon the delete tools,tableIdondelete_table,jobIdonget_query_results. Trimming those is not a change this PR makes, and refusing them would break callers whose stored value works today. That is a real pre-existing hazard on a destructive tool and worth a follow-up, but bundling a behaviour break into a security fix is how the regression above got in.Pinned by
NEWLY_TRIMMED_BY_THIS_CHANGE, which upgrades the generic per-pair whitespace assertion from "same path or no path" to must throw, plus explicitdelete_*tests asserting the method really isDELETE, that a paddedprojectIdthrows, that the unpadded id still works, and thatdatasetIdis still trimmed. Verified non-vacuous: reverting either guard to a plain trim fails both the generic and the explicit assertions.Behaviour change: whitespace in a storage key is now preserved, not trimmed
This is the change most likely to be noticed, and it is deliberate.
safeUrlPathafter #7262's fix trims nowhere — not interior segment edges, not the whole key's leading/trailing edge. TheencodeStoragePathit replaces didencodeURIComponent(segment.trim())per segment, so it dropped whitespace at every edge:encodeStoragePathfolder/ file.pngfolder/file.pngfolder/ file.pngavatars/file.pngavatars/file.pngavatars/file.pngfolder/my file .pngDecision: a padded key 404s rather than being trimmed. In order of force:
storage_uploadbuilds its key through the sameencodeStoragePath. Trimming on read would make a padded key that was legitimately uploaded through this very tool permanently unreachable — you could create an object you can never fetch or delete. That alone settles it.supabase_storage_deletethat deletes the wrong file." a/b.png "and"a/b.png"are two keys. Trimming does not clean the input, it addresses something the caller did not name.pathisuser-or-llm, which reinforces it: a guard that quietly normalizes model output makes an injection attempt and an honest typo indistinguishable.Pinned by assertions in
supabase/path_safety.test.ts(round-trips %j byte-for-byte,addresses the padded object rather than the unpadded one,encodes the padding so it cannot restructure the URL) so a later change tourl-path.tscannot flip it silently. The previous "equivalence with the replaced helper" assertion is deleted — it is false in both the interior and edge cases now.A related consequence:
' .. 'is no longer rejected for multi-segment paramsThis reads alarming, so here is the verification rather than the assertion. A padded dot segment is not traversal:
Dot-segment removal matches only an exact
.or..(and their%2espellings).%20%20..%20%20is one ordinary segment that the parser never removes, so it names an object literally called" .. "— which is what the caller wrote. The bare..is still rejected, which is the case that actually matters, and that is asserted in the same test. Single-segment ids still trim-then-reject viasafeUrlPathSegment, because whitespace around an id is copy-paste noise rather than data — the harness models this aspreservesWhitespace.Noted, deliberately not fixed here
google_drive/get_content.ts:108interpolatesencodeURIComponent(exportFormat)into a query parameter, andexportFormatderives from the caller-suppliedparams.mimeType. A lone UTF-16 surrogate there throws a bareURIErrorrather than a named error. It is byte-identical inorigin/staging, untouched by this diff, and a query param rather than a path segment — outside this PR's contract. Flagged rather than folded in, for the same reason as the pre-existingdatasetIdtrim: quietly widening a security fix into unrelated behaviour is how the padded-projectIdregression got in. Unlike that case there is no risk in fixing it, so it is a cheap follow-up if wanted.datasetIdon the BigQuery delete tools was already.trim()-ed before this branch. Trimming it is therefore not a change made here, and refusing it would break callers whose stored value works today — but it is a real pre-existing hazard on a destructive tool.The guards this PR adds are not affected by the surrogate case: they delegate to
encodeSegment, so a lone surrogate producesprojectId contains an unpaired UTF-16 surrogate and cannot be encodedrather thanURIError: URI malformed. Verified directly.Rejected as not-a-risk
table(8 REST tools) — already guarded byvalidateDatabaseIdentifier. I had guarded it, then reverted when tests surfaced the existing validator.invoke_function— already hasvalidateFunctionName.app/api/tools/drive/{file,files}/route.ts— already validate withvalidateAlphanumericId. (The routes named in the brief,google_drive/{download,export}, do not exist.)create_folder/get_contentinner fetches — the id comes from Google's own API response (data.id,metadata.id), not the caller.Tests
75 (tool, parameter) pairs across six services, over a shared harness in
apps/sim/tools/__tests__/path-safety.ts. Four properties, each added because an earlier revision lacked it:One parameter at a time. The first harness filled every string param with the same hostile value and
catch { return }-ed, so one guarded param masked its siblings. Demonstrated, not assumed: withgoogle_drive_unshare'spermissionIdfully unguarded and its siblingfileIdstill guarded, the old harness reported 285/285 green.Rejection, not shape.
https://x/a/.normalizes tohttps://x/a/— same segment count, every other segment intact — so a shape-only assertion is blind to a dot segment in the final position, which is where the Drivedelete_*family andbox_sign_get_requestput their guarded id. Every value encoding cannot neutralize is asserted to throw and name its parameter.Every branch. Discovery harvests the literals a builder compares against from
String(tool.request.url), so a param reachable only on one branch cannot hide. Measured: 75 pairs before and after, no pair requiring a branch literal — the 29 conditionals here are all query-string only.No tool can silently leave the suite. Sibling params are filled from their declared
type(1fornumber,falseforboolean,[]forjson/array) so an early type check on a sibling cannot remove a tool, and each suite pins bothUNBUILDABLE(empty) and the exact set of tools contributing no path parameter.cubic caught that this pin was initially vacuous: the inventory was enumerated through
asPathTool, which requiresrequest.urlto be a function, so a tool declaringurlas a constant string (box_create_folder) or anInternalToolConfigwith norequest(box_upload_file) was invisible to the covered pairs and the pinned set —toEqual(['box_search'])passed precisely because they could not be seen. The real extent was 11 tools across four services, includingsupabase_storage_uploadandsupabase_storage_get_public_url, which genuinely do build storage paths viaencodeStoragePath. The inventory now walks every export carrying the service id prefix whatever shape its request takes. Verified non-vacuous: dropping the two Box tools back out now fails the assertion, where before it changed nothing.Internal tools have no
requestfor this suite to drive, so pinning them proves they are accounted for, not that they are traversal-safe; their real coverage is the directencodeStoragePath/encodeStorageSegmentunit tests on the exact helperlib/internal/supabase/operations.tscalls. The TSDoc says so rather than overstating the guarantee a second time.Balanced traversal. A value that pops exactly as many segments as it adds keeps the baseline segment count, so a count-only check cannot see it.
id/../../other/victimis in the throw-asserted set and verified red against an unguarded trailing parameter. The inert-value shape check also previously pinned only the prefix ahead of the guarded slot, which would let a value that expands its own slot (a/b/../c) through on a single-segment param; that case now pins the whole shape — count plus every slot but the guarded one.Plus: a structural
PathToolnarrowed byasPathTool(value: unknown)instead ofToolConfig<any, any>(zeroany), andgetErrorMessagefrom@sim/utils/errorsthroughout —check:auditsenforces that inside test files too.LEGITIMATE_IDSproves real values reach the wire unchanged: Drive ids with-/_, Box's root folder id0,people/c12345,folder/sub/file.png,folder/my file .png, andmy-project.my_dataset.my_table.Review findings fixed on this branch
Both bots ran against the head SHA and found real defects, all fixed and each verified red first:
ToolConfig<any, any>PathToolnarrowed fromunknownmulti-segment-url-path.tstrimmed each segmentsafeUrlPathSTATIC_URL_TOOLSpin was vacuousasPathTool; 11 invisible tools surfacedprojectIddiffered between URL and bodycreate_dataset,create_table,query; pinned by testurl-path.test.tsbox_signheader comment contradicted itselfperlsubstitution had rewritten the pre-fix template quoted inside the comment; reworded to past tense.trim()threw on a numeric project idsafeUrlPathSegmentaccepts a JSON number,.trim()does not, so the path built and the body threw a rawTypeError. Body identifiers now derive from the path guard itselfencodeStoragePaththrew before this branch, so the new rejections fell through to the generic handler and blamed the server for a caller's..; mapped at both call sites, with tests asserting no provider request is madeMUST_NOT_RESHAPEswallowed an over-tighteningcatch { return }meant none of the assertions ran, so a guard that rejected a value it should have encoded passed silently; a throw now fails unless the parameter is named instrictlyValidatedpreservesWhitespacebranch swallowed a rejectionuser-or-llmand the error returns as a tool result the model reads, so quoting the input copied attacker-chosen text (U+2028/U+2029 included) back into the prompt; value dropped, parameter name keptUNBUILDABLEnorSTATIC_URL_TOOLSnoticed; discovery now reports it and each suite pins the set emptyNine findings landed on files this PR does not touch —
tools/url-path.ts,tools/github/**andlib/internal/github/**, all added by #7262, which this branch is rebased onto sosafeUrlPathis importable (that is also why cubic reports 130 files). All are verified and routed to #7262 rather than fixed here, since fixing them would put changes to that PR's code in this PR's history and conflict on merge:safeEncodedUrlPathSegmentaccepts backslashes. I first argued this was inert and I answered the wrong question — my check was against the WHATWG URL parser, which does not decode%5Cbefore dot-segment removal. cubic's claim was about downstream normalizers, which my check could not catch: IIS-style folding turns\into/, so%5C..%5C..can become/../..at a proxy. I reversed my position and recommended the rejection on fix(github): reject path-traversal values in interpolated URL segments #7262.remove_label.tsbuilds its response from raw params. Confirmed by reading it: the DELETE path is guarded buthtml_urlandcontentare rebuilt from untrimmedparams, so a successful removal returns a dead link. Flagged there as a sweep, since anytransformResponserebuilding a URL from raw params has the same split.executeGitHubTooldoes notawaitthe comment operations. The most consequential:return promiseinsidetrycompletes the block before it settles, so the enclosingcatchnever sees a rejection. That makes fix(github): reject path-traversal values in interpolated URL segments #7262's ownbuildGuardedUrl400-mapping unreachable forgithub_comment, so a rejectedownersurfaces as a generic 500 instead of the documented client error — the exact misattribution its TSDoc says it prevents.safeUrlPathrejects a whitespace-only path component. Over-rejects: since fix(github): reject path-traversal values in interpolated URL segments #7262's own whitespace fix that helper treats whitespace as data, sodocs/ /notes.mdis a legal path it now refuses. Flagged with the caveat that the empty-segment rule must stay —a/ /banda//baddress different objects.remove_label.tstrims label names. The data-loss class again: a padded label previously 404'd, and trimming now removes the real one. A label may legitimately carry surrounding whitespace, so it is also wrong on plain correctness grounds.close_pr.tstrimsowner/repo. Third instance of the data-loss class, and cubic explicitly recommends the fix that landed here — a paddedownerwent from a 404 no-op to closing a PR in the real repository. fix(github): reject path-traversal values in interpolated URL segments #7262 has since fixed this itself ind2c74d7416, along withdelete_milestone,delete_branch,update_pranddelete_file— the fix landed there rather than as a follow-up, because this PR is rebased onto fix(github): reject path-traversal values in interpolated URL segments #7262, so "after fix(tools): reject path traversal in Drive, BigQuery, Box, Supabase and Contacts ids #7269" would have meant "after the regression shipped".get_branch_protection.tssplits a branch containing/across segments.feature/foobecomes/branches/feature/foo/protectionand 404s silently; wantssafeEncodedUrlPathSegment. Worth auditing everybranch/refsite, since the two helpers are easy to swap and nothing fails loudly.get_tree.tsskips the guard for a falsy0.params.path ? … : ''is a truthiness test doing an is-it-absent job, so a directory named0cannot be listed — and0is a value the guard's own type contract accepts.buildGuardedUrlturns into a model-facing tool result, so the same prompt-injection surface exists there.A recurring defect in my own tests, worth naming
Nine separate assertions on this PR could not fail. Review found six; three I found by auditing for the pattern myself:
STATIC_URL_TOOLSpin vacuous — 11 tools invisible to itpreservesWhitespacebranch swallowed a rejectionMUST_NOT_RESHAPEswallowed an over-tighteningdescribe.eachover a derived group that could silently emptynamesParamsubstring match accepted a message naming the wrong parameterif (serialized?.includes(…))guarded the body assertion out of existenceThe last one was the most instructive:
namesParampowered every "rejects … naming the parameter" assertion across all six suites, and its substring match accepted"projectId cannot …"as naming the parameterid. A guard naming the wrong identifier satisfied the check designed to catch exactly that. It now matches whole tokens (joining adjacent ones so"Invalid function name"still namesfunctionName), and has its own contract test, because a weakness there is invisible from every suite it serves.The ninth added a variant worth naming separately: it was not a tolerance at all but a literal that outlived its fixture.
expect(serialized).not.toContain(' my-project ')was meaningful while the test supplied a padded id; when the strict guard made padding throw, the fixture became unpadded and the assertion could no longer fail. Changing a fixture silently defangs assertions written for the old one — a shape that auditingcatchblocks and filters does not surface.The common cause was a blanket tolerance quietly turning "acceptable here" into "untested everywhere". The fix in each case was to name the exceptions —
strictlyValidated,rejectsSurroundingWhitespace,STATIC_URL_TOOLS,UNDISCOVERABLE— so an unexpected one fails rather than passes. Every assertion added after this was verified red before being kept.Red-first verification
Scoped reverts also verified individually:
google_drive_unshare.permissionId(sibling-masking) andgoogle_drive_delete.fileId(trailing-position, where all 9 rejection vectors fire and a shape check sees nothing). 14,292 tests pass with the guards in place.Gates
bun run lint,bun run check:audits(39/39),check-block-registry.ts, andtsc --noEmitall clean.