Skip to content

fix(rootly,clerk,jira): reject path-traversal ids in URL segments - #7268

Closed
waleedlatif1 wants to merge 2 commits into
stagingfrom
fix/rootly-clerk-jira-path-safety
Closed

fix(rootly,clerk,jira): reject path-traversal ids in URL segments#7268
waleedlatif1 wants to merge 2 commits into
stagingfrom
fix/rootly-clerk-jira-path-safety

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

The defect

Rootly, Clerk and Jira tools interpolated LLM-writable identifiers (incident id, alert id, user id, organization id, session id, issue key, comment id, worklog id, attachment id, project id, …) straight into request path segments. Those params are visibility: 'user-or-llm', so prompt injection controls them.

A value like ../../users/victim escapes its API prefix once fetch normalizes the URL, re-aiming the request — carrying the workspace's credential — at a different resource on the same host, including on DELETE. assertRequestUrlMatchesTrust in tools/request-transport.ts only canonicalizes internal /api/ routes, so nothing downstream catches it.

Clerk is the worst case: its credential is a backend API key with full user-management authority, and the affected routes include delete/ban/lock.

encodeURIComponent does not close this:

encodeURIComponent('..')                                          // => '..'
new URL('https://api.clerk.com/v1/users/../ban').pathname         // => '/v1/ban'
new URL('https://api.clerk.com/v1/users/%2e%2e/ban').pathname     // => '/v1/ban'

. and .. are unreserved, so they survive encoding verbatim, and the WHATWG URL parser removes dot segments after decoding. Only rejecting the value works.

The fix

safeUrlPathSegment(value, paramName) from @/tools/url-path at 69 path-segment sites across 66 tool files:

service sites files
Rootly 23 23
Clerk 26 23
Jira 20 request builders + 20 transformResponse fallbacks 20

Jira tools build the URL twice — once in request.url when cloudId is already present, and again inside transformResponse after resolving the cloudId. Both were unguarded; both are guarded now.

Clerk's provider slug is guarded too: it is only prefixed with oauth_, so a / inside it still traverses (provider = "x/../../users/victim").

No param visibility, no subBlock id, and no behaviour for legitimate input changed. bun run tool-metadata:generate produces no diff.

Jira's cloudId — left unguarded on purpose

cloudId reaches a path segment on every Jira tool, but it is not LLM-writable:

  • declared visibility: 'hidden' on every tool
  • grep -c cloudId blocks/blocks/jira.ts is 0, and git log --all -S'cloudId' -- blocks/blocks/jira.ts returns no commits — no revision has ever been able to set it
  • its only real source is getJiraCloudIdresolveAtlassianCloudId, which returns a UUID from Atlassian's own accessible-resources endpoint

Guarding it would assert a threat model that does not exist. The test pins it instead, and says why.

Also checked and left alone: lib/internal/jira/operations.ts, the server side of jira_update / jira_write / jira_add_attachment, already runs validateJiraIssueKey (a validatePathSegment with allowDots: false) before every client.issuePath() interpolation.

Tests

path_safety.test.ts under each of the three tool folders, modelled on tools/vercel/edge_config_path_safety.test.ts:

  • enumerates the tool barrel, then per tool every parameter that reaches a path segment — so a new tool, or a new id param on an existing tool, is covered without registration
  • fuzzes one parameter at a time while its siblings hold a safe value. Fuzzing them all together let a still-guarded sibling throw first and mask an unguarded one — that is a real hole this design closes
  • resolves every URL with new URL(...), the same normalization fetch performs, never string matching
  • keeps the bare . and .. vectors, plus ' .. ', %2f spellings, backslashes, embedded traversal, and query/fragment injection
  • a LEGITIMATE_IDS list asserts real values pass through byte-for-byte: PROJ-123, MY_PROJECT-4567, user_2abcDEF, org_2abcDEF, sess_2abcDEF, Rootly UUIDs, and the ..foo / foo.. edge cases

1684 tests pass across the three folders.

Verified the tests can fail: reverted the guard on rootly_delete_incident.incidentId, clerk_delete_user.userId, and jira_delete_comment.commentId one at a time and watched exactly that pair go red (10 failures each — the traversal vectors, both bare-dot rejections, and query injection), then restored.

Gates

bun run lint, bun run check:audits (39 audits green, including check:tool-request-boundary and check:api-validation:strict), check-block-registry.ts origin/staging, bun run type-check clean on all touched files, tool-metadata:generate no-diff.

Rootly, Clerk and Jira tools interpolated LLM-writable identifiers straight
into request path segments. A value like `../../users/victim` escapes its API
prefix once `fetch` normalizes the URL, re-aiming the request — and the
workspace's credential — at a different resource on the same host, including
on DELETE. Clerk is the worst case: its credential is a backend API key with
full user-management authority.

`encodeURIComponent` does not close this. `.` and `..` are unreserved, so they
survive encoding verbatim and the WHATWG URL parser removes them as dot
segments afterwards. Only rejecting the value works, which is what
`safeUrlPathSegment` does.

Hardened 69 path-segment sites across 66 tool files, covering both the
`request.url` builder and the `transformResponse` fallback that re-issues the
call after resolving a Jira `cloudId`. Clerk's `provider` slug is guarded too:
it is only prefixed with `oauth_`, so a separator in it still traverses.

Jira's `cloudId` is left unguarded on purpose. It is `visibility: 'hidden'`,
no Jira block subBlock has ever written it, and its only real source is
`resolveAtlassianCloudId`, which returns a UUID from Atlassian's own
accessible-resources endpoint.

Each service gets a `path_safety.test.ts` that enumerates its tool barrel and,
per tool, every parameter that reaches a path segment — fuzzing one parameter
at a time so a still-guarded sibling cannot mask an unguarded one. URLs are
resolved with `new URL(...)`, the same normalization `fetch` performs, rather
than string-matched. A `LEGITIMATE_IDS` list proves real values (`PROJ-123`,
`user_2abcDEF`, `org_2abcDEF`, Rootly UUIDs) pass through byte-for-byte.
@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 29, 2026 4:26am

Request Review

@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR prevents model-writable Rootly, Clerk, and Jira identifiers from reshaping authenticated request paths by validating each interpolated URL segment.

  • Applies safeUrlPathSegment across the affected request builders, including Jira’s post-cloudId fallback requests.
  • Adds barrel-driven traversal tests for all three integrations, with one-parameter-at-a-time fuzzing and legitimate-ID coverage.
  • Replaces the test harness’s unrestricted any usage with unknown-based narrowing and typed local interfaces.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains, and the previously reported test-harness typing issue is fixed at the current head.

Important Files Changed

Filename Overview
apps/sim/tools/clerk/path_safety.test.ts Adds typed, barrel-driven coverage for Clerk path parameters and removes the previously reported unrestricted any usage.
apps/sim/tools/jira/path_safety.test.ts Covers both primary Jira request URLs and URLs rebuilt after cloud-ID resolution using the typed harness.
apps/sim/tools/rootly/path_safety.test.ts Adds typed traversal and legitimate-identifier coverage for Rootly’s dynamic request paths.
apps/sim/tools/clerk/get_user_oauth_token.ts Validates both the user ID and provider slug before interpolating them into Clerk’s OAuth-token endpoint.
apps/sim/tools/jira/delete_comment.ts Validates the issue key and comment ID in both direct and cloud-ID fallback request paths.
apps/sim/tools/rootly/delete_incident.ts Validates the incident ID before constructing the authenticated destructive request URL.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[User or model supplies identifier] --> B[safeUrlPathSegment validation]
    B -->|Valid segment| C[Build provider URL]
    B -->|Traversal or delimiter input| D[Reject request]
    C --> E[Authenticated Rootly, Clerk, or Jira request]
    F[Jira cloud ID resolution] --> G[Fallback request builder]
    G --> B
Loading

Reviews (2): Last reviewed commit: "test(rootly,clerk,jira): type the path-s..." | Re-trigger Greptile

Comment thread apps/sim/tools/clerk/path_safety.test.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 69 files

Confidence score: 4/5

  • apps/sim/tools/jira/path_safety.test.ts does not cover jira_bulk_read.projectId when it is interpolated by transformResponse, leaving this URL path-safety case unvalidated and allowing a regression to go undetected—include parameters used in generated URLs in the test coverage.
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/jira/path_safety.test.ts">

<violation number="1" location="apps/sim/tools/jira/path_safety.test.ts:139">
P2: PATH_PARAMS only enumerates parameters that land in a path segment of `tool.request.url`. `jira_bulk_read.projectId` is interpolated only into a URL built inside `transformResponse` (its `request.url` is the fixed accessible-resources endpoint), so it is filtered out of PATH_PARAMS and never fuzzed here — contradicting the comment above the suite that it covers "every declared parameter that reaches a path segment." If the `safeUrlPathSegment` guard on that transformResponse-built URL is removed or weakened, this suite will not detect the regression it was written to catch. Extend PATH_PARAMS (or split it) to also exercise the URLs constructed in `transformResponse` after cloudId resolution, matching the second URL construction the PR guards.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread apps/sim/tools/jira/path_safety.test.ts Outdated
…econd URL

Two review findings, both real.

cubic (jira/path_safety.test.ts): `PATH_PARAMS` enumerates only what
`request.url` returns, but a Jira tool invoked without a `cloudId` sends its
configured request to the fixed accessible-resources endpoint and builds the
real per-site URL inside `transformResponse`. `jira_bulk_read.projectId`
reaches a path segment only that way, so discovery could not see it — the
guard was there, the test was blind to it.

Adds a second suite that runs `transformResponse` with the `cloudId` withheld
against a stubbed `fetch` and reads back the URLs the tool asked for.
Discovery is probe-driven like the first suite, so it stays total rather than
hard-coding the one known case: it finds 24 (tool, param) pairs, a superset of
the 23 `request.url` exposes, the extra being `jira_bulk_read :: projectId`.
Reverting that guard to the pre-PR `encodeURIComponent` now fails the suite.
No additional unguarded parameter surfaced.

Assertions there are shape-based — same segment count, same non-probe segments
— because a tool may legitimately issue a different number of requests for
different inputs, and segment count is what a popped dot segment changes.

Greptile (all three files): the harness used `ToolConfig<any, any>` and cast
generated params with `as any`, which CLAUDE.md forbids. Replaced with
`asPathBuildingTool`, an `unknown` -> narrow type guard returning the slice
the suite actually drives (`id`, `params`, `buildUrl`, and on Jira the optional
`transformResponse`). No `any` remains in any of the three files.

Rootly and Clerk headers now state why one suite is total for them — no tool in
either service issues a `fetch` of its own from `transformResponse` — and the
Jira header documents the residual limit: a URL a tool builds but never sends
is invisible to both mechanisms.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Both review threads addressed in 29f4547 and resolved.

cubic P2 — transformResponse-built URLs were invisible to discovery. Correct, and the same family of blindness as the bug this PR fixes. PATH_PARAMS only inspected request.url, so jira_bulk_read.projectId — which reaches a path segment only through the URL built inside transformResponse after cloudId resolution — was never fuzzed. Fixed generically: a second suite runs transformResponse with the cloudId withheld against a stubbed fetch and reads back the URLs requested, discovering parameters by probe exactly as the first suite does. It finds 24 (tool, param) pairs, a strict superset of the 23 request.url exposes. Reverting that guard to the pre-PR encodeURIComponent now fails. No additional unguarded parameter surfaced.

Greptile P2 — harness erased tool types. Correct; CLAUDE.md forbids any. ToolConfig<any, any> and the as any call-site cast are gone from all three files, replaced by asPathBuildingTool, an unknown -> narrow type guard returning the structural slice the suite drives.

On cloudId, restated for the record since it is the one thing deliberately left unguarded: it is visibility: 'hidden' on every Jira tool, grep -c cloudId blocks/blocks/jira.ts is 0 and git log --all -S'cloudId' -- blocks/blocks/jira.ts returns no commits (no revision has ever been able to set it), and its only real source is resolveAtlassianCloudId, which returns a UUID from Atlassian's own accessible-resources endpoint. Guarding it would assert a threat model that does not exist; the tests pin it and the header says why.

Gates re-run on the new commit: 2094 tests pass across the three folders, bun run lint, bun run check:audits (39 green), check-block-registry.ts origin/staging, type-check clean on all touched files, tool-metadata:generate no-diff.

@greptile

@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Thanks for the follow-up. The generic transformResponse discovery closes the Jira coverage gap, and replacing the erased any types with asPathBuildingTool addresses the harness concern cleanly. The rationale for leaving cloudId unguarded is also sufficiently documented and pinned by the tests. With the reported gates passing, I have no further concerns on these threads.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

Both threads are addressed and resolved as of 29f4547; your follow-up confirms no further concerns. Requesting a fresh full review so the summary reflects the current head rather than the original 8f981fa — the score on this PR is still the one from before the typed harness and the Jira transformResponse coverage landed.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

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 fix/rootly-clerk-jira-path-safety is preserved and this PR can be reopened. Review state, the reasoning on every thread, and the red-first verification all stay attached.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant