fix(github): reject path-traversal values in interpolated URL segments - #7262
fix(github): reject path-traversal values in interpolated URL segments#7262waleedlatif1 wants to merge 6 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 SummaryThe PR centralizes GitHub URL-path interpolation behind traversal-safe helpers and distinguishes opaque identifiers, multi-segment repository paths, and encoded single-segment values.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/tools/url-path.ts | Defines the path-safety helpers, preserves repository-path whitespace, and rejects traversal or padded mutation identifiers as intended. |
| apps/sim/tools/github/path_safety.test.ts | Enumerates GitHub URL builders, accounts for skipped and pathless tools, and verifies traversal, whitespace, and strict mutation behavior. |
| apps/sim/lib/internal/github/operations.ts | Guards internal GitHub operation URLs and converts caller-supplied path validation failures into actionable client errors. |
| apps/sim/tools/url-path.test.ts | Covers whitespace preservation, strict identifier rejection, dot-segment handling, backslashes, and encoded labels. |
Reviews (6): Last reviewed commit: "fix(github): reject a backslash in safeE..." | Re-trigger Greptile
There was a problem hiding this comment.
3 issues found across 79 files
Confidence score: 3/5
apps/sim/lib/internal/github/operations.tslets rejected latest-commit paths escape as a plainError, causingexecuteGitHubToolto return 500 instead of a client-facing 400; convert validation failures toGitHubOperationErrorwith status 400.apps/sim/tools/github/update_file.tstrims leading or trailing whitespace from valid filename segments, which can update the wrong file or fail to find the target; preserve segment whitespace when constructingsafeUrlPath.apps/sim/tools/github/path_safety.test.tscan silently omit parameters when baseline construction fails, allowing aggregate discovery to report incomplete coverage; surface the tool and parameter or assert that nothing was skipped.
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/github/update_file.ts">
<violation number="1" location="apps/sim/tools/github/update_file.ts:65">
P2: When `path` contains a legal filename segment with leading or trailing whitespace, `safeUrlPath` trims it before building the request, so the update targets a different file or fails. Preserve segment whitespace while still rejecting raw dot segments.</violation>
</file>
<file name="apps/sim/lib/internal/github/operations.ts">
<violation number="1" location="apps/sim/lib/internal/github/operations.ts:356">
P2: When a latest-commit input contains a rejected path value, this guard throws a plain `Error`, and `executeGitHubTool` returns 500 for it. Convert path-validation failures to `GitHubOperationError` with status 400 so rejected user input is not reported as a server failure.</violation>
</file>
<file name="apps/sim/tools/github/path_safety.test.ts">
<violation number="1" location="apps/sim/tools/github/path_safety.test.ts:171">
P2: Do not silently skip baseline-construction failures during discovery. Surface the tool and parameter that failed, or otherwise assert that no parameters were skipped, so the aggregate count cannot hide missing traversal 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.
|
@greptile All six threads are addressed, replied to individually, and resolved — pushed in 7d4d2e1. Re-review please. Summary of what changed:
Every new pin was verified to fail against the pre-fix code before being committed. Gates: |
…nd Contacts ids LLM-writable ids were interpolated into request paths, so a value like `../../files/victim` re-aimed 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. The headline case is Supabase `encodeStoragePath`, which read as sanitisation and was a no-op for traversal: it split the object key on `/` and ran `encodeURIComponent` over each piece, but `.` and `..` are unreserved, so `../..` came back byte-for-byte unchanged and the URL parser removed the dot segments after decoding. Single-segment ids go through `safeUrlPathSegment`. The two genuinely hierarchical values — Supabase storage keys and Google Contacts `resourceName` — go through `safeUrlPath` from #7262, so the tree carries one multi-segment guard rather than a second near-duplicate. That also adopts `safeUrlPath`'s rejection of empty segments: a leading or doubled separator addresses a different object than the caller wrote, and the upload operation normalizes its own trailing separator, so no real key needs one. Supabase `table` is left alone — `validateDatabaseIdentifier` already refuses anything outside the SQL identifier alphabet, so it is not in this risk class. Each service gets a `path_safety.test.ts` over a shared harness that enumerates (tool, parameter) pairs from the barrel and fuzzes one parameter at a time with its siblings held safe. Every value that encoding cannot neutralize is asserted to throw and to name its parameter, because a shape-only assertion is blind to a dot segment in the final path position: `https://x/a/.` normalizes to `https://x/a/` with the segment count intact. Discovery also harvests the literals a URL builder compares against, so a parameter reachable only on one branch cannot hide, and reports any tool whose URL will not build at all instead of dropping it.
…nd Contacts ids LLM-writable ids were interpolated into request paths, so a value like `../../files/victim` re-aimed 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. The headline case is Supabase `encodeStoragePath`, which read as sanitisation and was a no-op for traversal: it split the object key on `/` and ran `encodeURIComponent` over each piece, but `.` and `..` are unreserved, so `../..` came back byte-for-byte unchanged and the URL parser removed the dot segments after decoding. Single-segment ids go through `safeUrlPathSegment`. The two genuinely hierarchical values — Supabase storage keys and Google Contacts `resourceName` — go through `safeUrlPath` from #7262, so the tree carries one multi-segment guard rather than a second near-duplicate. That also adopts `safeUrlPath`'s rejection of empty segments: a leading or doubled separator addresses a different object than the caller wrote, and the upload operation normalizes its own trailing separator, so no real key needs one. Supabase `table` is left alone — `validateDatabaseIdentifier` already refuses anything outside the SQL identifier alphabet, so it is not in this risk class. Each service gets a `path_safety.test.ts` over a shared harness that enumerates (tool, parameter) pairs from the barrel and fuzzes one parameter at a time with its siblings held safe. Every value that encoding cannot neutralize is asserted to throw and to name its parameter, because a shape-only assertion is blind to a dot segment in the final path position: `https://x/a/.` normalizes to `https://x/a/` with the segment count intact. Discovery also harvests the literals a URL builder compares against, so a parameter reachable only on one branch cannot hide, and reports any tool whose URL will not build at all instead of dropping it.
…nd Contacts ids LLM-writable ids were interpolated into request paths, so a value like `../../files/victim` re-aimed 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. The headline case is Supabase `encodeStoragePath`, which read as sanitisation and was a no-op for traversal: it split the object key on `/` and ran `encodeURIComponent` over each piece, but `.` and `..` are unreserved, so `../..` came back byte-for-byte unchanged and the URL parser removed the dot segments after decoding. Single-segment ids go through `safeUrlPathSegment`. The two genuinely hierarchical values — Supabase storage keys and Google Contacts `resourceName` — go through `safeUrlPath` from #7262, so the tree carries one multi-segment guard rather than a second near-duplicate. That also adopts `safeUrlPath`'s rejection of empty segments: a leading or doubled separator addresses a different object than the caller wrote, and the upload operation normalizes its own trailing separator, so no real key needs one. Supabase `table` is left alone — `validateDatabaseIdentifier` already refuses anything outside the SQL identifier alphabet, so it is not in this risk class. Each service gets a `path_safety.test.ts` over a shared harness that enumerates (tool, parameter) pairs from the barrel and fuzzes one parameter at a time with its siblings held safe. Every value that encoding cannot neutralize is asserted to throw and to name its parameter, because a shape-only assertion is blind to a dot segment in the final path position: `https://x/a/.` normalizes to `https://x/a/` with the segment count intact. Discovery also harvests the literals a URL builder compares against, so a parameter reachable only on one branch cannot hide, and reports any tool whose URL will not build at all instead of dropping it.
|
Carrying over a review finding from #7269, which is rebased onto this branch. cubic raised it against
My assessment: not a security issue, but a reasonable consistency question. The backslash is inert here. I checked rather than assuming: The value survives as one segment. The WHATWG parser removes a dot segment only when the segment is exactly The asymmetry with Where cubic has a fair point: the two helpers read inconsistently side by side, and "rejects backslashes" is easier to reason about than "encodes them, and here is why that is safe." If you prefer uniform rejection for reviewability, the only cost is the |
|
Two more review findings from cubic against #7269 (rebased onto this branch), both in this PR's files. Routing them here rather than fixing them on the downstream PR. 1.
|
The scoped workflow-runs path I added reintroduced, in one parameter, the exact
over-broad query this PR set out to fix.
`encodeURIComponent('..')` returns `'..'` verbatim — a dot segment is made
entirely of unreserved characters — and the WHATWG parser that `fetch` uses then
removes it and pops a path segment. So `workflow_id: '..'` resolved back to
`/repos/{owner}/{repo}/actions/runs` and silently listed every run in the
repository, with nothing to say the scope had been dropped. `'.'` produced a
bogus path the same way.
Only rejection closes this; no encoding scheme neutralizes a dot segment. A dot
segment *inside* a longer value is already inert and stays accepted, because its
separators survive as `%2F` and the parser does not decode those before removing
dot segments — `.github/workflows/../ci.yml` is preserved intact.
This matches `safeEncodedUrlPathSegment` in #7262 exactly, so the rebase is a
clean swap for that helper.
|
Third finding from cubic against #7269, again in this PR's code. This one I think is a genuine bug that defeats a guard this PR just added, so worth prioritising.
|
safeUrlPath rejected a path component made only of spaces. That check had no
security value and a real cost: git tracks both a file and a directory whose
entire name is spaces, so a valid GitHub file could not be read, updated, or
deleted.
A whitespace-only segment is not a dot segment, and the parser never removes it:
new URL('https://x/a/%20%20%20/b').pathname => /a/%20%20%20/b (kept)
new URL('https://x/a/../b').pathname => /b (removed)
Only a truly empty component (a `//`, where the caller wrote no name at all) is
rejected now. Dot-segment and backslash rejection are unchanged.
safeUrlPathSegment still rejects an all-whitespace value. That asymmetry is
correct: it trims opaque ids first, so one made only of spaces has named
nothing.
The TSDoc records why the check is absent, citing the git paths and the parser
behaviour, so it is not restored on aesthetic grounds.
|
Correction pushed in
Git permits it — a file and a directory whose entire name is spaces: And rejecting it bought nothing. A whitespace-only segment is not a dot segment, and the parser never removes it: So the guard had no security value and a real cost: Now only a truly empty component is rejected — a
This is the same error as the trimming bug one level down: reasoning about what a path segment ought to look like instead of what the filesystem and the URL parser actually do. Worth stating plainly since this helper is now shared. New pins: Dependency note for reviewers: #7269 branches off this PR and will need a rebase once this lands — it carries the older Gates re-run green: |
|
@greptile review |
|
@cubic review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
|
Fourth finding from cubic against #7269, again in this PR's code — and this one is a consequence of the whitespace fix itself, so worth deciding deliberately.
|
… query A present value of the wrong kind fell through to the omission branch, so `workflow_id: true` silently dropped the scope and listed every run in the repository — the same over-broad query as the dot segment, reached by a different route. A value the caller did supply must never be read as one they did not. A non-string, non-number value now fails by name; `undefined` and `null` still mean "list the whole repository", which is the documented behaviour of omitting the parameter. This mirrors `toGuardedString` in #7262, so the rebase onto the shared helper stays a clean swap.
|
Fifth finding routed from #7269 — and this one is the data-loss class that #7269 just had to fix twice, so flagging it with the pattern rather than just the instance.
|
|
Three more from cubic against #7269, all in this PR's files — plus one that applies to your guards but was found on mine. 1.
|
|
Worked through all four. One I'm pushing back on, one I'm correcting the scope of, one is pre-existing, one isn't a defect. No code changes — this branch stays at 5/5. Error-echo: does not apply to these guardsI verified this rather than taking it on faith, and I agree it does not apply here. The right question isn't "does the guard echo the value?" but "can the echoed value carry attacker-controlled text?" Every echoing site in
Every other throw names only Note I proved it rather than reasoning about it: a fuzz over 18 injection payloads ( The distinction matters in the other direction too: 1.
|
|
Tenth finding routed from #7269 — cubic,
Valid, and the fix already exists in this PR — it just is not used at that call site. function encodeSegment(segment: string, paramName: string): string {
try {
return encodeURIComponent(segment)
} catch {
throw new Error(`${paramName} contains an unpaired UTF-16 surrogate and cannot be encoded`)
}
}I verified the difference on the downstream branch with a lone high surrogate: So the guards that go through For symmetry with what I found downstream: Running list routed from #7269 (all verified there, none fixed downstream since they are this PR's files):
Offer stands: happy to take any subset as a follow-up PR against this branch if that is easier than folding them in. |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
…nd Contacts ids LLM-writable ids were interpolated into request paths, so a value like `../../files/victim` re-aimed 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. The headline case is Supabase `encodeStoragePath`, which read as sanitisation and was a no-op for traversal: it split the object key on `/` and ran `encodeURIComponent` over each piece, but `.` and `..` are unreserved, so `../..` came back byte-for-byte unchanged and the URL parser removed the dot segments after decoding. Single-segment ids go through `safeUrlPathSegment`. The two genuinely hierarchical values — Supabase storage keys and Google Contacts `resourceName` — go through `safeUrlPath` from #7262, so the tree carries one multi-segment guard rather than a second near-duplicate. That also adopts `safeUrlPath`'s rejection of empty segments: a leading or doubled separator addresses a different object than the caller wrote, and the upload operation normalizes its own trailing separator, so no real key needs one. Supabase `table` is left alone — `validateDatabaseIdentifier` already refuses anything outside the SQL identifier alphabet, so it is not in this risk class. Each service gets a `path_safety.test.ts` over a shared harness that enumerates (tool, parameter) pairs from the barrel and fuzzes one parameter at a time with its siblings held safe. Every value that encoding cannot neutralize is asserted to throw and to name its parameter, because a shape-only assertion is blind to a dot segment in the final path position: `https://x/a/.` normalizes to `https://x/a/` with the segment count intact. Discovery also harvests the literals a URL builder compares against, so a parameter reachable only on one branch cannot hide, and reports any tool whose URL will not build at all instead of dropping it.
#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.
…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.
The TSDoc stated the boundary and the harm on a write, but not why reads are deliberately excluded. Without that, the asymmetry reads as an unfinished pass and the next contributor "completes" it, breaking a paste flow that works today for no safety gain. Records both halves of the reasoning: the harm is asymmetric (a write mutates a resource the caller never named, unrecoverably and invisibly, since every traversal assertion still passes; a read returns data from the resource they almost certainly meant), and the cost of refusing runs the other way (a padded id on a read is overwhelmingly a stray newline in a paste). States the principle underneath both guards: refuse where being wrong is unrecoverable, tolerate where being wrong is merely unhelpful. Comment-only; no behaviour change.
…nd Contacts ids LLM-writable ids were interpolated into request paths, so a value like `../../files/victim` re-aimed 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. The headline case is Supabase `encodeStoragePath`, which read as sanitisation and was a no-op for traversal: it split the object key on `/` and ran `encodeURIComponent` over each piece, but `.` and `..` are unreserved, so `../..` came back byte-for-byte unchanged and the URL parser removed the dot segments after decoding. Single-segment ids go through `safeUrlPathSegment`. The two genuinely hierarchical values — Supabase storage keys and Google Contacts `resourceName` — go through `safeUrlPath` from #7262, so the tree carries one multi-segment guard rather than a second near-duplicate. That also adopts `safeUrlPath`'s rejection of empty segments: a leading or doubled separator addresses a different object than the caller wrote, and the upload operation normalizes its own trailing separator, so no real key needs one. Supabase `table` is left alone — `validateDatabaseIdentifier` already refuses anything outside the SQL identifier alphabet, so it is not in this risk class. Each service gets a `path_safety.test.ts` over a shared harness that enumerates (tool, parameter) pairs from the barrel and fuzzes one parameter at a time with its siblings held safe. Every value that encoding cannot neutralize is asserted to throw and to name its parameter, because a shape-only assertion is blind to a dot segment in the final path position: `https://x/a/.` normalizes to `https://x/a/` with the segment count intact. Discovery also harvests the literals a URL builder compares against, so a parameter reachable only on one branch cannot hide, and reports any tool whose URL will not build at all instead of dropping it.
#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.
…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.
#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.
|
Adopted Your rule is sharper than the one I had been applying. I used "refuse padding wherever this change newly trims an identifier" — which is why #7269 had
Applied to a read, my version was refusing a padded id whose only consequence would have been returning the resource the caller almost certainly meant — while breaking a working paste-with-a-stray-newline flow to do it. That is cost with no safety gain, and the explicit "do not complete the asymmetry" note is well placed, because completing it is exactly what I had done. So #7269 now reverts those six to Two smaller notes from the adoption:
Nothing outstanding from me on this PR. The routed list is fully addressed as far as I can see: the strict guards cover the destructive routes, the whitespace-only component is permitted, and |
|
@greptile review |
|
@cubic 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 81 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
It was the only one of the three guards that encoded a backslash to %5C
instead of refusing it.
Encoding is safe on the wire — a raw backslash IS a path separator to the
WHATWG parser for a special scheme, so https://x/a/b/..\..\etc resolves to
/etc, but the encoded form does not move:
new URL('https://x/a/b/..%5C..%5Cetc').pathname => /a/b/..%5C..%5Cetc
So this is not a live traversal hole. It is refused anyway for the reason the
module already gives for safeUrlPath: a value carrying a backslash is a
Windows-shaped path the caller did not mean to address literally, and letting
one through leaves a segment that reads as traversal to anything downstream
that normalizes it. Neither caller — a GitHub label name, a git ref — can
legitimately contain one, so the consistency costs nothing.
Pinned, including an assertion that all three guards agree.
|
@greptile review |
|
@cubic review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
…nd Contacts ids LLM-writable ids were interpolated into request paths, so a value like `../../files/victim` re-aimed 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. The headline case is Supabase `encodeStoragePath`, which read as sanitisation and was a no-op for traversal: it split the object key on `/` and ran `encodeURIComponent` over each piece, but `.` and `..` are unreserved, so `../..` came back byte-for-byte unchanged and the URL parser removed the dot segments after decoding. Single-segment ids go through `safeUrlPathSegment`. The two genuinely hierarchical values — Supabase storage keys and Google Contacts `resourceName` — go through `safeUrlPath` from #7262, so the tree carries one multi-segment guard rather than a second near-duplicate. That also adopts `safeUrlPath`'s rejection of empty segments: a leading or doubled separator addresses a different object than the caller wrote, and the upload operation normalizes its own trailing separator, so no real key needs one. Supabase `table` is left alone — `validateDatabaseIdentifier` already refuses anything outside the SQL identifier alphabet, so it is not in this risk class. Each service gets a `path_safety.test.ts` over a shared harness that enumerates (tool, parameter) pairs from the barrel and fuzzes one parameter at a time with its siblings held safe. Every value that encoding cannot neutralize is asserted to throw and to name its parameter, because a shape-only assertion is blind to a dot segment in the final path position: `https://x/a/.` normalizes to `https://x/a/` with the segment count intact. Discovery also harvests the literals a URL builder compares against, so a parameter reachable only on one branch cannot hide, and reports any tool whose URL will not build at all instead of dropping it.
#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.
…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.
#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.
|
Confirming No effect on #7269 — it has zero uses of One thing worth flagging back, because it is the mirror image of a decision on this PR. #7269 resolves it by trimming the whole value while preserving whitespace inside it — edge padding on the whole value is a paste artifact and never part of the key, whereas Not suggesting a change here: for GitHub |
|
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 |
The defect
GitHub tools interpolate LLM-writable values —
owner,repo,issue_number,pullNumber,path,branch,ref,sha, labelname,gist_id,run_id,comment_id, … — straight into the request path. These arevisibility: 'user-or-llm', so prompt injection controls them.A value of
..re-aims an authenticated request, carrying the workspace's GitHub token, at a different resource:That includes DELETE routes:
delete_file,delete_release,delete_branch,delete_comment,delete_milestone,delete_gist.assertRequestUrlMatchesTrustintools/request-transport.tsonly canonicalizes internal/api/routes, so nothing downstream catches it.Why
encodeURIComponentis not sufficientThis is the question reviewers will ask, so, concretely:
.and..are unreserved, soencodeURIComponent('..')returns'..'verbatim. And the WHATWG URL parser thatfetchuses removes dot segments after decoding, so the percent-encoded spellings are popped too. No encoding scheme neutralizes a dot segment — only rejecting the value does.The fix
Every interpolation site now goes through
apps/sim/tools/url-path.ts. GitHub has three genuinely different parameter shapes, so this PR adds two helpers alongside the existingsafeUrlPathSegment:safeUrlPathSegment(existing)owner,repo,issue_number,release_idsafeUrlPath(new)/is real path structurepath,branch,ref,base,headsafeEncodedUrlPathSegment(new)/name(area/apimust stay%2F)Applying the single-segment guard to
pathwould rejectdocs/README.md; promoting labelnameto a multi-segment path would emit a real/and address a different endpoint.safeUrlPathenforces the traversal rule per segment and restores:after encoding, socomparekeeps addressing a cross-fork ref asoctocat:feature/my-branchexactly as before. Each split is justified in TSDoc.Counts: 183 single-segment sites, 12 multi-segment, 2 encoded-single.
Sites deliberately left alone: the GraphQL tools (
create_project,list_projects,graphql, review threads,status_check_rollup) post to a fixedhttps://api.github.com/graphql, and thesearch_*tools build their URL withURLSearchParams— no value reaches a path segment in either. Human-readablecontentstrings andhtml_urldisplay fields were left raw so no output text changes.Two adjacent hardenings in the same files:
get_tree/get_file_contentinterpolated?ref=${params.ref}into the query without encoding (query injection, not traversal), nowencodeURIComponent, matchingget_readme.The test
apps/sim/tools/github/path_safety.test.tsenumerates tools from the barrel and probes every declared parameter to discover which ones reach the path, so a newly added tool with an unguarded parameter fails CI rather than needing to be remembered. Every assertion resolves the built URL withnew URL(...)— the same normalizationfetchperforms — rather than string-matching the template, because string matching is exactly what let this through. The vector list keeps the bare.and..entries, andLEGITIMATE_IDS/LEGITIMATE_PATHSprove real values (octocat,my-repo, a 40-char sha,v1.2.3,feature/my-branch,docs/README.md,heads/release/2.0) still pass through unchanged.Verified test-first: the suite was written before any guard and went red against the unmodified tools; reverting one guard on
delete_fileafterwards turns it red again.No param
visibility, subBlock id, or tool behaviour for legitimate input changed —tool-metadata:checkandcheck-block-registryconfirm.One existing expectation in
job_logs.test.tswas tightened: it asserted thatowner: '../../orgs/secret'was escaped through as..%2F..%2Forgs%2Fsecret. That is safe, but the guard now rejects a separator inowneroutright, so the test asserts the rejection instead.Padded identifiers on state-changing requests
owner,repoand the numeric ids were interpolated raw before this branch, so routing them through a trimming guard would have converted a 404 no-op into a real mutation:No traversal assertion catches that, which is why it needed a sweep rather than inspection.
strictUrlPathSegment/strictEncodedUrlPathSegmentrefuse a padded value instead of trimming it, and are applied to every parameter this branch newly trims on a non-GET route — 37 tools, 101 parameter sites, pluspullRequestUrlinlib/internal/github/operations.ts.The rule is that a hardening change must never turn a failing request into a succeeding one. So:
gist_id, because they already trimmed it before this branch — preserving that is the same rule, not an exception.safeUrlPathparameters (path,branch,ref,base,head) never trimmed, so they are unaffected.On the duplicate helper:
strictUrlPathSegmentis defined locally inurl-path.tsrather than imported from #7269, which introduces a helper of the same name. That is deliberate. #7269 is rebased onto this branch, so this one merges first, and waiting for the shared helper would mean shipping the regression and fixing it afterwards. When the two meet, this collapses to a one-line import change.Scope
This is one service of a larger sweep — the same pattern was found at 199 interpolation sites across the codebase, and other services ship separately.
Gates:
bun run lint,bun run check:audits(39/39),check-block-registry, andvitest run tools/github lib/internal/github tools/url-path.test.ts(11929 passed) are all green.