| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
Every Rippling and Okta tool that interpolates an LLM-writable ID into its
request path now routes it through `safeUrlPathSegment` instead of a bare
`encodeURIComponent`.
`encodeURIComponent` is not sufficient. `.` and `..` are unreserved
characters, so `encodeURIComponent('..')` returns `'..'` verbatim, and the
WHATWG URL parser that `fetch` uses removes dot segments *after* decoding —
so `%2e%2e` is popped too. Only rejecting the value works:
new URL('https://rest.ripplingapis.com/users/../workers/x').pathname
// => '/workers/x'
Because these IDs are `visibility: 'user-or-llm'`, prompt injection controls
them, and a popped segment re-aims an authenticated request — the workspace's
Rippling bearer token or the org's Okta SSWS token still attached — at a
different resource, including on DELETE and on the Okta lifecycle endpoints
that deactivate a user or clear their sessions.
105 call sites across 88 tools are hardened, plus the server-side
`okta_update_group` read-modify-write in `lib/internal/okta/operations`,
whose `groupId` builds the URL for both the read and the `PUT`.
Two new suites enumerate the tools from their barrels, so a newly added
unguarded path param fails CI rather than shipping. Every assertion resolves
the built URL with `new URL(...)` — the same normalization `fetch` performs —
rather than string-matching the template output, because string matching is
what let this through. A `LEGITIMATE_IDS` list proves real Rippling UUIDs,
custom-object API names, Okta `00u…`/`00g…`/`0oa…` IDs, and email logins pass
through unchanged; no param visibility, subBlock ID, or behaviour for
legitimate input changes.
This is one batch of a larger path-safety sweep across the integration
catalog.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Sorry, something went wrong.
Greptile SummaryThe PR prevents resource IDs from reshaping authenticated Okta and Rippling request paths by routing interpolated path segments through safeUrlPathSegment.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (4): Last reviewed commit: "test(okta): cite Okta's own /-in-login r..." | Re-trigger Greptile |
Sorry, something went wrong.
There was a problem hiding this comment.
1 issue found across 92 files
Confidence score: 4/5
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/okta/path_safety.test.ts">
<violation number="1" location="apps/sim/tools/okta/path_safety.test.ts:122">
P2: Exercise each dynamic path parameter independently with safe sibling values. The current `catch { return }` exits after the first guarded parameter throws, so an unguarded sibling can remain untested.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Sorry, something went wrong.
…a time
The suites enumerated tools and filled every string param with the same fuzz
value, then swallowed the throw:
try { path = buildPath(tool, value) } catch { return }
So the first guarded param aborted the case and every sibling silently
stopped being tested. The reported "a new unguarded param fails CI" property
did not hold for any tool that already had one guard.
That is the common shape here, not an edge case: 15 of these tools put two
IDs in one path (`groupId` + `userId`, `appId` + `groupId`, `userId` +
`factorId`, `customObjectApiName` + `fieldApiName`, …). Reverting
`okta_remove_user_from_group`'s `userId` guard produced ZERO failures under
the old suite, because `groupId` is interpolated first and threw.
Both suites now enumerate (tool, param) pairs — 61 Rippling, 43 Okta,
accounting for every in-tool call site — and fuzz exactly one param while
each sibling holds its own distinct sentinel. Distinct sentinels are what
make a two-ID path attributable; one shared value would let a guard on
either param look like coverage of both. Two new assertions pin the
property: the pair count, and an explicit check that each two-ID tool
contributes two pairs.
The same revert now fails 5 assertions, all scoped to
`okta_remove_user_from_group / userId`, with the sibling `groupId` pair
still green.
No production code changes. The `okta_update_group` block is unchanged — it
already asserted the URL actually requested.
Both harnesses carried `type AnyTool = ToolConfig<any, any>` and a matching `as any` on the `url(...)` call — inherited from the landed Vercel harness they were modelled on, and flagged P2 on sibling PRs. CLAUDE.md is explicit: no `any`. The obvious replacement does not compile. `Object.values` over a barrel yields a union across every member, and `ToolConfig` puts its param type in the contravariant position of `request.url`, so no specific member is assignable to a widened `ToolConfig<Record<string, unknown>, ToolResponse>`; a bare `.filter` with a type guard intersects rather than replaces and leaves the errors in place. The barrel is therefore seeded as `Object.values<unknown>(...)`, which makes the existing `isRipplingTool` / `isOktaTool` predicate the single narrowing point — the `unknown` + type-guard form CLAUDE.md prescribes — and keeps a cast out of every call site. `buildParams` returns `Record<string, unknown>`, so `url(...)` needs no cast at all. A TSDoc block on each seed records why. The `okta_update_group` block asserts a stubbed fetch rather than a `ToolConfig`, so it never needed the alias; its two `as never` casts are gone too, replaced by a declared `OktaUpdateGroupParams` and a typed mock signature, which is what makes `mock.calls[0][0]` resolve. Verified with a temporary tsconfig overriding `exclude`, then deleted: `apps/sim/tsconfig.json` excludes `**/*.test.ts`, so `bun run type-check` does not cover these files and CI would not have caught the errors this change fixes. Zero behavioural effect — 2723 tests still pass.
|
@greptile @cubic-dev-ai review Both prior reviews ran against 8ae14e5, the first of three commits. Head is now 25f1821. Every finding from that pass is addressed — please re-review the current head. What changed since you last looked:
Worth a specific look, since normal CI cannot see it: apps/sim/tsconfig.json excludes **/*.test.ts and vitest transpiles without typechecking, so neither bun run type-check nor CI type-checks these two files. I verified them with a temporary tsconfig overriding exclude, which is how the as never errors surfaced. The production changes are all covered normally. The 105 guards themselves are unchanged since the first review. |
Sorry, something went wrong.
@waleedlatif1 Incremental reviews are turned off for this repository. Comment @cubic review to run a full review. |
Sorry, something went wrong.
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Sorry, something went wrong.
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Sorry, something went wrong.
There was a problem hiding this comment.
1 issue found across 92 files
Confidence score: 5/5
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/rippling/path_safety.test.ts">
<violation number="1" location="apps/sim/tools/rippling/path_safety.test.ts:158">
P3: The `covers both IDs on every two-ID path` test only asserts full two-param coverage for `rippling_delete_custom_object_field` and `rippling_get_custom_object_record`, but its own doc comment names the full two-ID shape as `customObjectApiName` + `fieldApiName`, `codrId`, and `externalId`. The `codrId` (`delete_custom_object_record`) and `externalId` (`get_custom_object_record_by_external_id`) two-ID tools are not listed, so a regression that makes `PATH_PARAM_PAIRS` map only one param per tool on those paths would still pass this named guard (the `>= 60` floor is too loose to catch a few dropped pairs). Extend the list to every tool whose `request.url` interpolates two guarded params so the named regression test actually covers all two-ID paths.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Sorry, something went wrong.
cubic's P3 was right: the `covers both IDs on every two-ID path` guard listed only 2 of Rippling's 7 two-ID tools, so a regression that dropped `codrId` or `externalId` from `PATH_PARAM_PAIRS` would still pass it — the `>= 60` floor is far too loose to notice a handful of missing pairs. The Rippling list now names all 7. Okta's list was already complete at 8, but both files were relying on a hand-maintained literal that nothing forced to stay in sync with reality — exactly the defect one level up. So each suite also gains a `names every tool that interpolates two guarded params` assertion that derives the two-ID set from `PATH_PARAM_PAIRS` and compares it to the expected list with `toEqual`. That fails in both directions: a tool that stops contributing two pairs, and a newly added two-ID tool nobody listed. Verified both guards actually fail by simulating the drift cubic described — truncating `rippling_get_custom_object_record` to a single pair turns both assertions red. 2723 -> 2725 tests. No production code changes.
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Sorry, something went wrong.
Rippling: `purchase_order__c`, `amount__c`, and `procurement__c` replace three
invented API names. They come from Rippling's published OpenAPI document at
`developer.rippling.com/docs/rest-beta.yaml`, which I fetched and checked
(776,028 bytes, HTTP 200). The `__c` suffix is the part worth pinning — the
invented names did not have it, so the suite was not exercising the shape
Rippling actually documents. That same document contains no `pattern:`
constraint anywhere (verified: grep count 0), so Rippling publishes no
character set for IDs at all, and the TSDoc now says so.
Okta: the ID list is unchanged, and its TSDoc now states plainly that these
are realistic shapes rather than quoted documentation, because Okta's
published parameter descriptions type every ID as a bare `string`.
It also records a genuine open question rather than burying it. The previous
`encodeURIComponent` emitted `%2F` for a login containing `/`, and `%2F`
survives URL normalization intact — `/api/v1/users/a%2Fb` stays one segment.
`safeUrlPathSegment` refuses that value instead. So for the nine `{id}`
endpoints documented as accepting "An ID, login, or login shortname", a
`/`-bearing login that Okta may previously have resolved is now rejected.
Whether Okta ever resolved it could not be confirmed from any reachable
source.
Deliberately not worked around. `%2F` is not itself a traversal vector —
only literal `.`/`..` and their percent-encoded spellings are removed by the
parser, which I verified directly — so the separator check is defense in
depth, not the load-bearing half of the fix, and it lives in shared
`@/tools/url-path` consumed by every integration. Narrowing it is that
module's decision, not one to make from the Okta tools.
No production code changes.
…nown
The previous commit recorded the `/`-bearing-login case as an unresolved
open question, on the grounds that no reachable Okta source addressed it.
That was wrong, and the note is now a documented fact with a line reference.
Okta's OpenAPI description for `GET /api/v1/users/{id}` says:
"When fetching a user by `login` or `login shortname`, URL encode the
request parameter to ensure that special characters are escaped properly.
Logins with a `/` character can only be fetched by `id` due to URL issues
with escaping the `/` character."
Percent-encoding is exactly what the old `encodeURIComponent` did, and
exactly what Okta documents as not working. So refusing the value is not a
lost capability — the old code produced a request Okta cannot serve, and a
named error beats the 404 it earned.
The text is genuinely hard to reach, which is what made it look absent: the
developer portal renders those pages client-side and returns an empty shell
to a plain fetch, and the generated SDK markdown carries only the parameter
descriptions, not the operation description that holds this sentence. It
survives only in the OpenAPI document the official Go SDK is generated from
— `okta/okta-sdk-golang`,
`.generator/okta-management-APIs-oasv3-noEnums-inheritance.yaml` (HTTP 200,
2,258,877 bytes), line 22166, under the path declared at line 22154.
No production code changes.
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Sorry, something went wrong.
There was a problem hiding this comment.
1 issue found across 93 files
Confidence score: 4/5
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/rippling/path_safety.test.ts">
<violation number="1" location="apps/sim/tools/rippling/path_safety.test.ts:159">
P3: In `cannot reshape the path with %j`, the bare `catch { return }` treats *any* thrown error as a pass, not just the intended path-traversal rejection. A traversal value can legitimately be neutralized either by rejection (throw) or by producing an unchanged path, so some throw is expected — but as written, a tool whose URL builder throws for an unrelated reason (its own regression, a `TypeError`, a partially-restored guard) silently passes this traversal test instead of failing the gate. This weakens the very security regression check the PR adds. Narrow the catch so it only accepts rejection, e.g. rethrow on any error that does not match the traversal/guard rejection, or assert the rejection message in the catch.
The dedicated `'rejects a bare dot segment'`, `'rejects a bare dot-dot segment'`, and `'names the offending param'` tests already pin the throw for `..`/`.`, so those vectors are still gated — but every other `TRAVERSAL_IDS` value (`'user_abc?expand=secrets'`, `'user_abc#fragment'`, `'..%2f..%2fusers/victim-user'`, etc.) is only exercised by this swallowing path and would pass even if the builder crashed for an unrelated reason.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Sorry, something went wrong.
| try { | ||
| segments = segmentsOf(buildUrl(tool).pathname) | ||
| } catch { | ||
| return [] | ||
| } | ||
| return Object.entries(safeValues(tool)) | ||
| .filter(([, sentinel]) => segments.includes(sentinel)) | ||
| .map(([param, sentinel]) => ({ name: `${tool.id} / ${param}`, tool, param, sentinel })) | ||
| }) | ||
|
|
There was a problem hiding this comment.
P3: In cannot reshape the path with %j, the bare catch { return } treats any thrown error as a pass, not just the intended path-traversal rejection. A traversal value can legitimately be neutralized either by rejection (throw) or by producing an unchanged path, so some throw is expected — but as written, a tool whose URL builder throws for an unrelated reason (its own regression, a TypeError, a partially-restored guard) silently passes this traversal test instead of failing the gate. This weakens the very security regression check the PR adds. Narrow the catch so it only accepts rejection, e.g. rethrow on any error that does not match the traversal/guard rejection, or assert the rejection message in the catch.
The dedicated 'rejects a bare dot segment', 'rejects a bare dot-dot segment', and 'names the offending param' tests already pin the throw for ../., so those vectors are still gated — but every other TRAVERSAL_IDS value ('user_abc?expand=secrets', 'user_abc#fragment', '..%2f..%2fusers/victim-user', etc.) is only exercised by this swallowing path and would pass even if the builder crashed for an unrelated reason.
Prompt for AI agentsCheck if this issue is valid — if so, understand the root cause and fix it. At apps/sim/tools/rippling/path_safety.test.ts, line 159:
<comment>In `cannot reshape the path with %j`, the bare `catch { return }` treats *any* thrown error as a pass, not just the intended path-traversal rejection. A traversal value can legitimately be neutralized either by rejection (throw) or by producing an unchanged path, so some throw is expected — but as written, a tool whose URL builder throws for an unrelated reason (its own regression, a `TypeError`, a partially-restored guard) silently passes this traversal test instead of failing the gate. This weakens the very security regression check the PR adds. Narrow the catch so it only accepts rejection, e.g. rethrow on any error that does not match the traversal/guard rejection, or assert the rejection message in the catch.
The dedicated `'rejects a bare dot segment'`, `'rejects a bare dot-dot segment'`, and `'names the offending param'` tests already pin the throw for `..`/`.`, so those vectors are still gated — but every other `TRAVERSAL_IDS` value (`'user_abc?expand=secrets'`, `'user_abc#fragment'`, `'..%2f..%2fusers/victim-user'`, etc.) is only exercised by this swallowing path and would pass even if the builder crashed for an unrelated reason.</comment>
<file context>
@@ -0,0 +1,263 @@
+ .filter((tool) => typeof tool.request?.url === 'function')
+ .flatMap((tool) => {
+ let segments: string[]
+ try {
+ segments = segmentsOf(buildUrl(tool).pathname)
+ } catch {
</file context>
| try { | |
| segments = segmentsOf(buildUrl(tool).pathname) | |
| } catch { | |
| return [] | |
| } | |
| return Object.entries(safeValues(tool)) | |
| .filter(([, sentinel]) => segments.includes(sentinel)) | |
| .map(([param, sentinel]) => ({ name: `${tool.id} / ${param}`, tool, param, sentinel })) | |
| }) | |
| try { | |
| url = buildUrl(tool, { name: param, value }) | |
| } catch (error) { | |
| if (error instanceof Error && /path traversal|path separator/.test(error.message)) { | |
| return | |
| } | |
| throw error | |
| } |
Sorry, something went wrong.
|
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/rippling-okta-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. |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
The defect
Every Rippling and Okta tool that takes a resource ID interpolated it into the request path with a bare encodeURIComponent. Those IDs are visibility: 'user-or-llm', so prompt injection controls them.
Why encoding is not enough
. and .. are unreserved characters — encodeURIComponent('..') returns '..' verbatim. And the WHATWG URL parser that fetch uses removes dot segments after percent-decoding, so the encoded spellings are popped too:
A popped segment re-aims an authenticated request — the workspace's Rippling bearer token, or the org's Okta SSWS API token, still attached — at a different resource on the same fixed host. That includes DELETE routes (rippling_delete_*, okta_delete_user, okta_delete_group) and the Okta lifecycle endpoints that deactivate a user or clear their sessions. assertRequestUrlMatchesTrust only canonicalizes internal /api/ routes, so nothing downstream catches it.
Only rejecting the value works, which is what safeUrlPathSegment(value, paramName) from @/tools/url-path does.
The fix
Every param here addresses exactly one path segment, so the single-segment guard is correct throughout; url-path.ts exports no multi-segment helper and none is needed.
Not changed: no param visibility, no subBlock ID, no behaviour for legitimate input. Query-string values (Rippling expand, Okta sendEmail/filters) were already going through URLSearchParams or a boolean coercion and never reach a path segment — left alone.
Tests
apps/sim/tools/rippling/path_safety.test.ts and apps/sim/tools/okta/path_safety.test.ts, modelled on the existing tools/vercel/edge_config_path_safety.test.ts:
Verified the tests can fail: reverted the guard in rippling/get_user.ts, okta/delete_user.ts, and the update-group operation back to encodeURIComponent(...trim()) and watched 12 assertions go red across all three, then restored them. The full suite is 2257 green.
Gates
bun run lint, bun run check:audits (39/39), check-block-registry, tool-metadata:generate (no diff — no params changed), and type-check clean on the touched files.
One batch of a larger path-safety sweep across the integration catalog.
Follow-up: the harness template propagates two defects
Both review findings on this PR were inherited from the landed apps/sim/tools/vercel/edge_config_path_safety.test.ts, which is the template roughly ten open path-safety PRs were built from. apps/sim/tools/daytona/ has the same shape. Neither is touched here — out of scope — but both should be retrofitted so the pattern stops spreading:
Also worth a separate issue: apps/sim/tsconfig.json excludes **/*.test.ts and vitest transpiles without typechecking, so no test file in the repo is type-checked by either path. That is why the any and as never errors here reached review at all.
The /-in-login case is not a regression
safeUrlPathSegment rejects any value carrying a path separator. The previous encodeURIComponent emitted %2F instead, and %2F survives URL normalization intact (/api/v1/users/a%2Fb stays one segment), so the old code really did put a /-bearing login on the wire. That is not a capability being lost.
Okta's OpenAPI description for GET /api/v1/users/{id} says so directly:
Percent-encoding is exactly what the old code did and exactly what Okta documents as not working. The old path produced a request Okta cannot serve; a named error is strictly better than the 404 it earned.
Check it rather than taking it on trust — the text is deliberately hard to reach, which is what made it look absent on a first pass. The developer portal renders these pages client-side and returns an empty shell to a plain fetch, and the generated SDK markdown carries only parameter descriptions, not the operation description holding this sentence. It survives only in the OpenAPI document the official Go SDK is generated from:
https://raw.githubusercontent.com/okta/okta-sdk-golang/master/.generator/ okta-management-APIs-oasv3-noEnums-inheritance.yaml # HTTP 200, 2,258,877 bytes line 22166, under the path declared at line 22154 (/api/v1/users/{id})Separately verified from the same document: %2F is not itself a traversal vector — only literal ./.. and their percent-encoded spellings are removed by the parser — so the separator check is defense in depth, while the ./.. rejection is the load-bearing half of the fix.
Out of scope for this PR (filed separately)
A full validate-integration pass ran against both providers' official specs. Everything it found is pre-existing and unrelated to path safety, so none of it is fixed here: it would force a tool-metadata regen and block changes on a surgical security fix. Endpoint paths, methods, auth (SSWS), registry completeness, subBlock-id uniqueness, and tools.config.tool purity all verified clean on both integrations.