fix(cloudflare,discord): reject path traversal in interpolated resource IDs - #7259
fix(cloudflare,discord): reject path traversal in interpolated resource IDs#7259waleedlatif1 wants to merge 9 commits into
Conversation
…ce IDs Zone, account, ruleset, rule, record, tunnel, bucket, guild, channel, message, user, role, webhook, and invite IDs are `visibility: 'user-or-llm'`, so prompt injection controls them. Every one was interpolated straight into the request path, where a value like `../../accounts/victim` escapes the `/client/v4` or `/api/v10` prefix once `fetch` normalizes the URL — re-aiming an authenticated request, with the user's Cloudflare API token or the workspace's Discord bot token still attached, at a different resource. That includes DELETE zone, DELETE bucket, DELETE channel, DELETE role, and the ban routes. `encodeURIComponent` does not close this: `.` and `..` are unreserved, so they survive encoding untouched and the URL parser removes them as dot segments afterwards. The three call sites that already encoded (`bucketName`, `scriptName`, reaction `emoji`) were therefore just as exposed as the raw ones. Only rejecting the value works, so all 133 sites now route through `safeUrlPathSegment`. `get_zone_settings.ts` already rejected dot segments through its own local helper and is left as-is, since its error wording is asserted by an existing test. Nothing about legitimate input changes: no param visibility, no subBlock id, no tool metadata. Discord snowflakes — including ones arriving as JSON numbers or bigints — pass through as before, and one that `JSON.parse` already rounded past `MAX_SAFE_INTEGER` is now refused by name rather than silently addressing a neighbouring resource. Both new suites enumerate their tools from the service barrel, so a newly added tool with an unguarded path param fails CI without editing the test.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Greptile SummaryThe PR consistently validates Cloudflare and Discord resource identifiers before interpolating them into authenticated request paths.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/tools/url-path.ts | Central path-segment validation rejects traversal and separators while safely normalizing supported scalar identifiers. |
| apps/sim/tools/discord/utils.ts | Shared Discord helpers preserve supported @me routes and detect optional numeric or bigint identifiers without string-only preprocessing. |
| apps/sim/tools/discord/create_thread.ts | URL and body construction now consistently handle numeric message IDs through the same presence predicate. |
| apps/sim/tools/discord/remove_reaction.ts | Reaction routing safely handles numeric user IDs while preserving the documented @me endpoint. |
| apps/sim/tools/cloudflare/path_safety.test.ts | The generic Cloudflare harness exercises guarded path parameters independently across applicable tools and branches. |
| apps/sim/tools/discord/path_safety.test.ts | The Discord harness verifies traversal rejection, numeric snowflake support, and special @me path behavior. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Tool parameters] --> B{Special Discord @me slot?}
B -->|Yes and value is @me| C[Preserve @me]
B -->|No| D[safeUrlPathSegment]
D --> E{Valid single segment?}
E -->|No| F[Reject before request]
E -->|Yes| G[Interpolate encoded segment]
C --> G
G --> H[Authenticated provider request]
Reviews (7): Last reviewed commit: "fix(cloudflare): refuse a padded bucket ..." | Re-trigger Greptile
There was a problem hiding this comment.
3 issues found across 85 files
Confidence score: 2/5
apps/sim/tools/cloudflare/delete_r2_bucket.tscan delete a different bucket whenbucketNamehas surrounding whitespace, creating a concrete destructive-action risk; reject whitespace-bearing names before normalization.apps/sim/tools/discord/remove_reaction.tsthrows for numeric or bigintuserIdvalues before URL validation, preventing reaction removal for supported inputs; pass the raw value tosafeUrlPathSegment.apps/sim/tools/cloudflare/path_safety.test.tstreats any URL-build error as a passing traversal test, so regressions could go undetected; assert the expected rejection rather than catching all errors.
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/cloudflare/delete_r2_bucket.ts">
<violation number="1" location="apps/sim/tools/cloudflare/delete_r2_bucket.ts:48">
P1: When `bucketName` contains surrounding whitespace, `safeUrlPathSegment` trims it and this DELETE targets a different bucket than the caller supplied. Reject whitespace-bearing bucket names before normalization instead of silently remapping a destructive resource identifier.</violation>
</file>
<file name="apps/sim/tools/cloudflare/path_safety.test.ts">
<violation number="1" location="apps/sim/tools/cloudflare/path_safety.test.ts:116">
P2: The traversal tests (`cannot reshape the path`, `never smuggles a query parameter`, and the bare-dot rejection tests) all `catch { return }`, converting *any* URL-build error into a pass rather than only accepting the path-safety rejection. Because `buildParams` fills every string param with the same traversal value, a multi-segment tool (e.g. `update_ruleset_rule` interpolates `zoneId`, `rulesetId`, and `ruleId`) throws as soon as *one* param stays guarded, so a regression that unguards a sibling param passes silently for the dot/separator vectors. It also lets a tool whose `url()` fails for an unrelated reason skip the path assertions entirely. This weakens the CI guard the module comments advertise: only swallow the specific safeUrlPathSegment rejection error and fail on anything unexpected.</violation>
</file>
<file name="apps/sim/tools/discord/remove_reaction.ts">
<violation number="1" location="apps/sim/tools/discord/remove_reaction.ts:61">
P2: When `userId` is a number or bigint, `.trim()` throws before this line executes, so `remove_reaction` never reaches `safeUrlPathSegment`. Pass the raw value to the helper instead of trimming it first, and apply the same fix to `messageId` in `create_thread`.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…ole object
The previous suites filled every string param with the same fuzz value and
swallowed the throw:
try { path = buildPath(tool, value) } catch { return }
URL construction is eager, so the first guarded param to throw aborted the
whole vector and every sibling param went untested. That inverted the
property the suites were written to hold: once a tool had one guard, a
*newly unguarded* sibling could no longer fail CI. It is the worst possible
shape for these two services, where `channelId` + `messageId`,
`serverId` + `roleId`, and `zoneId` + `rulesetId` + `ruleId` share one path.
Both suites now enumerate (tool, param) pairs — discovered by probing one
param at a time, so a new tool or a new path param appears with no edit here
— and fuzz exactly one param while holding every sibling at a safe value.
133 pairs across 84 tools, up from 84 whole-tool cases.
The vectors are also split by the outcome they must produce, replacing the
tolerant try/catch: MUST_REJECT (dot segments and anything carrying a path
separator) asserts a throw naming the offending param, and MUST_NEUTRALIZE
(`?`/`#` inside a segment) asserts the segment shape is preserved. Nothing
is skipped silently any more.
Verified red-first against the tightened suite by reverting one guard on a
multi-param tool — `messageId` on discord_delete_message and `ruleId` on
cloudflare_delete_ruleset_rule — to an `encodeURIComponent`-only version,
leaving their siblings guarded. That is exactly the case the old shape could
not see: it produces 16 named failures now, while the old whole-object
assertion passed 3/3 green on the identical code.
No source change: the pair enumeration confirms every param that reaches a
path is already guarded.
…dable tools Three refinements to the path-safety suites. No source change: the fix itself was already complete, and all three confirm that rather than alter it. Branch coverage. A param that only appears on ONE branch of a conditional URL builder is invisible to a single all-params probe. Neither service switches on a string literal — the harness now harvests comparison literals from `String(tool.request.url)` so a future `action`-style builder is probed on every branch without editing this file, and it finds none today — but two Discord tools branch on param PRESENCE: `create_thread` picks a different endpoint when `messageId` is absent, and `remove_reaction` falls back to `/@me` when `userId` is. Discovery now probes each param with every optional sibling omitted in turn, which raises the case count 133 -> 137: 4 Discord branch shapes that were never exercised (the `/@me` form and the no-message thread form). The set of (tool, param) pairs is unchanged, so no unguarded param was hiding there; the new cases are previously untested path shapes for params already guarded. A ratchet assertion keeps them covered. No `any`. `ToolConfig<any, any>` and the `as any` calls are replaced by a structural `ServiceTool`/`PathTool` pair narrowed through `isPathTool` and `pathToolFor`, per CLAUDE.md. Unbuildable tools are named, not swallowed. `SKIPPED_TOOL_IDS` asserts the tools that build no URL from params against an explicit allowlist (`discord_send_message`, `cloudflare_create_zone`, `cloudflare_get_zone_settings`), and `UNBUILDABLE` collects any tool whose URL will not build from all-safe values and asserts empty — a failed probe of a guarded param is still expected and tolerated, but a tool that cannot be exercised at all now fails instead of vanishing from coverage. Verified non-vacuous by dropping an entry and watching it go red. Also adds the ` . ` vector, since a bare dot survives whitespace trimming. Rejection assertions are what carry this. Scoped-reverting the branch-only `userId` guard on discord_remove_reaction plus zoneId on cloudflare_delete_zone produces 19 named failures; a shape-only check would have caught 4. Of the 9 reject vectors, shape sees just 2: `encodeURIComponent` turns `/` into `%2F`, which the URL parser never decodes back into a separator, so every separator-bearing traversal preserves the path shape exactly — and a trailing bare `.` collapses to the parent collection while keeping the segment count.
`remove_reaction` and `create_thread` tested an optional param for presence with `params.x?.trim()`. That throws a bare `TypeError: params.userId?.trim is not a function` on a JSON number — naming neither the tool nor the parameter — and it throws BEFORE `safeUrlPathSegment`, so the number and bigint support that helper deliberately provides never applied to those two tools. An LLM tool call can and does deliver a snowflake as a JSON number, so this contradicted a claim made for this PR: that numeric snowflakes work. They worked everywhere the guard was reached directly (`get_member` builds fine from two numeric ids) and failed on exactly the two builders that pre-trimmed. Caught by greptile and cubic independently; both were right. Presence is now tested by `isProvidedParam` in a new `tools/discord/utils.ts` (two call sites, per the utils rule), which does not assume a string and hands the raw value to `safeUrlPathSegment` — the single place that owns kind checking and named errors. A blank or whitespace-only string still counts as absent, so the branch each builder selects is unchanged. The harness missed this because it only ever passed strings. Both suites now assert, for every one of the 137 (tool, param) cases, that a safe-range number and a bigint build the same path as their decimal string — which is what catches a pre-trim anywhere, not just at these two sites. Verified red-first: restoring either pre-trim fails exactly 4 of those assertions, naming `discord_remove_reaction / userId` and `discord_create_thread / messageId`.
|
@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.
4 issues found across 86 files
Confidence score: 2/5
apps/sim/tools/discord/create_thread.ts: numeric or bigintmessageIdvalues select the correct parent-message URL but then cause the body builder invoked byprepareToolRequestto throw on.trim(), preventing the request; make body construction useisProvidedParamor otherwise handle non-string IDs safely.apps/sim/tools/cloudflare/delete_r2_bucket.ts: whitespace-paddedbucketNamecan delete the normalized bucket while reporting the untrimmed name, creating misleading tool output; return the same normalized name used in the request.apps/sim/tools/discord/path_safety.test.ts: the MUST_NEUTRALIZE coverage does not assert that?remains within its segment, leaving a path-safety regression insufficiently detected; add an expliciturl.searchor equivalent segment-boundary assertion.
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/cloudflare/delete_r2_bucket.ts">
<violation number="1" location="apps/sim/tools/cloudflare/delete_r2_bucket.ts:48">
P2: When `bucketName` has surrounding whitespace, this URL deletes the trimmed bucket but `transformResponse` reports the untrimmed name. Return the same normalized bucket name that the request addressed.</violation>
</file>
<file name="apps/sim/tools/discord/create_thread.ts">
<violation number="1" location="apps/sim/tools/discord/create_thread.ts:64">
P2: When a numeric or bigint `messageId` is supplied, this guard builds the URL, but `prepareToolRequest` then calls `body`, where `params.messageId?.trim()` throws. Use `isProvidedParam(params.messageId)` in that body branch too.</violation>
<violation number="2" location="apps/sim/tools/discord/create_thread.ts:64">
P1: When `messageId` is a number or bigint, `isProvidedParam` selects the parent-message URL, but the body builder still calls `params.messageId?.trim()` and throws before the request is sent. Update the body builder to handle non-string IDs without calling `.trim()`.</violation>
</file>
<file name="apps/sim/tools/discord/path_safety.test.ts">
<violation number="1" location="apps/sim/tools/discord/path_safety.test.ts:315">
P3: The MUST_NEUTRALIZE test never verifies that a `?` stays inside its segment. It asserts origin, pathname prefix, `url.hash === ''`, segment count, and every non-PROBE segment, but it never checks `url.search` and explicitly skips the probe segment's content. A value like `123456789012345678?with_counts=false` interpolated raw (with `safeUrlPathSegment`'s `encodeURIComponent` removed but its rejection kept) produces a pathname of the same length and identical non-probe segments, so all assertions still pass even though the id has silently shifted into a query string. This is the failure the suite claims to be load-bearing, and it is the exact direction this PR moves (reject rather than encode). Assert `expect(url.search).toBe('')` (and, ideally, assert the probe segment equals `segment.replaceAll(PROBE, encodeURIComponent(value))`) so the vector is actually caught.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
The URL builder is not the only place a tool touches an id, and fixing only the URL moved the failure one step later rather than removing it. `create_thread` reads `messageId` again in its `body` to decide the thread type (a standalone thread must pin `type` to PUBLIC_THREAD; a message-backed one must not). That read was still `params.messageId?.trim()`, so a numeric messageId now passed the URL and threw a bare TypeError building the body. Caught by greptile on the previous head; it was right. `send_message` had the same class in its `operation.input` mapper. It is not a traversal sink — `executeDiscordSendMessage` validates `channelId` through `validateNumericId` before `lib/internal/discord/client.ts` interpolates it, and that validator explicitly accepts `string | number` — but the mapper's `params.channelId.trim()` threw on a number before the validator that was built to accept one ever ran. It now goes through `safeUrlPathSegment`, which is a no-op for a valid channel id (snowflakes are all digits, and digits are unreserved) while rejecting a precision-lost number by name. Both suites now also assert, for every one of the 137 (tool, param) cases, that `request.body` and `request.headers` build from a numeric id without a TypeError — checking specifically for TypeError so a builder's deliberate domain error does not produce a false failure. That is the assertion the suites lacked: they only ever exercised `request.url`, which is exactly why the `create_thread` body read survived them. Verified red-first — restoring that one `?.trim()` fails it, naming `discord_create_thread / messageId`.
|
@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.
1 issue found across 87 files
Confidence score: 3/5
- In
apps/sim/tools/cloudflare/delete_r2_bucket.ts, surrounding whitespace inbucketNamecan silently target a different bucket while reporting the raw input, creating a concrete risk of deleting the wrong resource. Reject whitespace-padded names before the irreversible request.
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/cloudflare/delete_r2_bucket.ts">
<violation number="1" location="apps/sim/tools/cloudflare/delete_r2_bucket.ts:48">
P1: When `bucketName` has surrounding whitespace, this call silently targets a different bucket and the success output names the raw input. Reject surrounding whitespace before this irreversible request, rather than allowing the helper to canonicalize it silently.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…n tests Three review findings, each verified before acting. **Guard the real sink.** `lib/internal/discord/client.ts` interpolated `channelId` raw into the messages URL. Not exploitable today — `executeDiscordSendMessage` runs `validateNumericId` ahead of both `sendDiscordMessage` call paths — but `sendDiscordMessage` is exported, so the guard lived only in callers rather than at the point of interpolation. It now applies at the sink, which is where the Application Operation Boundary rule puts it. No behaviour change for a valid channel id: snowflakes are all digits, and digits are unreserved, so the encode is the identity function. **Report the bucket that was actually deleted.** `delete_r2_bucket` echoes the requested name because Cloudflare returns an empty body, but it echoed the RAW param while the request addressed the trimmed one — so a padded input deleted `my-bucket` and reported `" my-bucket "`. The output now matches what the path addressed. This is the one place the trim was observable, and it was inconsistent rather than merely cosmetic. **Pin the query string and the probe slot in MUST_NEUTRALIZE.** The test asserted origin, prefix, hash, segment count and every NON-probe segment — and skipped the probe slot. Both gaps mattered, and the second is the more general one: - A raw interpolation of `id?x=y` kept the pathname segment count and every surrounding segment; only `search` showed the id had been torn in half (`?with_counts=false?with_counts=true`). Now asserted equal to the query the tool builds on its own, which is not simply `''` — several Discord tools carry a legitimate query. - Skipping the probe slot would let a balanced traversal such as `id/../../other/victim` pass with the guard removed, since only that slot differs. Every segment is now pinned to the trimmed, percent-encoded value. Verified red-first: reverting `get_server` to raw interpolation now fails 14 assertions including both MUST_NEUTRALIZE cases, which previously passed.
|
@greptile review |
|
@cubic-dev-ai review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
Discord publishes `@me` as a literal route segment standing in for the current
bot: `GET /users/@me` (resources/user), `DELETE .../reactions/{emoji}/@me`
(Delete Own Reaction), and `PATCH /guilds/{guild.id}/members/@me`. Before this
PR those slots were interpolated raw, so a user who typed `@me` into a user-ID
field got a working request.
`encodeURIComponent('@me')` is `%40me`, which Discord does not route, so the
guards silently turned those calls into 404s. That is a real backwards-
compatibility regression on documented routes, and it is the only such
regression this PR introduces — found by auditing the tools against Discord's
published reference rather than by a failing test, since a 404 is invisible to
a unit suite.
`discordUserPathSegment` passes `@me` through verbatim and delegates
everything else to `safeUrlPathSegment` unchanged. This widens the accepted
set by exactly one constant and weakens nothing: `@me` is neither a dot
segment nor does it contain `/` or `\`, so it cannot pop or add a path
segment. Applied only to the three slots where Discord documents the alias —
`get_user`, `remove_reaction`, and `update_member` — not to every user ID.
Tests pin the alias (including whitespace-padded), pin that a lookalike such
as `@everyone` is still encoded to `%40everyone`, and pin that `..` and
`@me/../../guilds/1` are still rejected in the same slot.
|
@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.
2 issues found across 88 files
Confidence score: 4/5
- In
apps/sim/tools/cloudflare/delete_r2_bucket.ts, numeric or bigintbucketNamevalues can remain non-string inoutput.name, creating an inconsistent result type; convert accepted non-string identifiers to strings. - In
apps/sim/tools/discord/send_message.ts, encoding the channel ID before validation can change its meaning and cause valid operations to target the wrong path; retain trimming and letsendDiscordMessagehandle path guarding.
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/cloudflare/delete_r2_bucket.ts">
<violation number="1" location="apps/sim/tools/cloudflare/delete_r2_bucket.ts:76">
P2: When `bucketName` arrives as a numeric or bigint runtime value, `safeUrlPathSegment` successfully addresses the bucket but this transform returns that non-string value as `output.name`. Convert accepted non-string identifiers to a string before returning the output.</violation>
</file>
<file name="apps/sim/tools/discord/send_message.ts">
<violation number="1" location="apps/sim/tools/discord/send_message.ts:51">
P2: Because `discord_send_message` does not build its URL here, this encoding changes the semantic channel ID before operation validation. Keep the existing trim and leave path guarding to `sendDiscordMessage`, which already guards the URL.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
There was a problem hiding this comment.
1 issue found and verified against the latest diff
Confidence score: 4/5
- In
apps/sim/tools/cloudflare/delete_r2_bucket.ts, numeric or bigintbucketNamevalues can leaveoutput.namenon-string even though the path segment uses their string form, causing inconsistent tool output; convert accepted values to a string before returningoutput.name.
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/cloudflare/delete_r2_bucket.ts">
<violation number="1" location="apps/sim/tools/cloudflare/delete_r2_bucket.ts:76">
P2: When `bucketName` arrives as a numeric or bigint tool value, `safeUrlPathSegment` addresses its string form but this branch returns the non-string value. Convert accepted values to a string so `output.name` matches its declared contract.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
`delete_r2_bucket` echoes the requested name because Cloudflare returns an empty body for this endpoint. The echo passed a non-string param straight through, so a bucket named `12345` supplied as JSON `12345` produced a NUMBER in `output.name`, contradicting the `type: 'string'` the tool declares. Reachable rather than theoretical: R2's documented rule is `^[a-z0-9][a-z0-9-]*[a-z0-9]`, so a digits-only bucket name is valid, and `safeUrlPathSegment` accepts a number — which is what made the path build succeed and pushed the inconsistency into the output instead of the request. Now `String(value).trim()`, matching the segment the request addressed for every accepted kind. Covered by `r2_output.test.ts`: padded and plain strings, a numeric name (asserting the returned type is `string`), and a missing name.
|
@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.
1 issue found across 89 files
Confidence score: 3/5
- In
apps/sim/tools/cloudflare/delete_r2_bucket.ts, trimming surrounding whitespace before the destructive DELETE can target a different existing bucket instead of rejecting the invalid name, creating a concrete data-loss risk—reject bucket names with surrounding whitespace before deletion.
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/cloudflare/delete_r2_bucket.ts">
<violation number="1" location="apps/sim/tools/cloudflare/delete_r2_bucket.ts:48">
P1: When `bucketName` has surrounding whitespace, this call trims it before the destructive DELETE and can delete a different existing bucket instead of rejecting the invalid name. Reject surrounding whitespace for this operation rather than silently normalizing the identifier.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Reversing my earlier position on this, because the narrower framing is right. I had defended trimming here on consistency: it is `safeUrlPathSegment`'s contract at all 137 sites, and `accountId` on this very line already trimmed before this PR. That argument is weaker than it looked. Only FIVE params in this PR are newly trimmed — the ones that previously went through a bare `encodeURIComponent` — and of those, `delete_r2_bucket / bucketName` is the only one attached to an irreversible request. The rest are two GETs, a PUT, and an emoji. So this is not one of 137 uniform sites; it is the single intersection of "newly trimmed" and "cannot be undone". R2 names are `^[a-z0-9][a-z0-9-]*[a-z0-9]`, so `" prod-data "` names no bucket that can exist. Before this PR that request failed. Trimming turns it into one that destroys `prod-data`. That inference is fine for a read and not worth making on the caller's behalf for a delete, and a stray newline out of a file read or a workflow variable is exactly how a padded name arrives. Rejecting costs nothing legitimate: no valid bucket name has surrounding whitespace to lose. `get_r2_bucket`, a read of the same resource, still trims, and that divergence is asserted rather than assumed. The suite carries the exception explicitly — `REJECTS_SURROUNDING_WHITESPACE` makes the generic per-pair trim assertion demand a throw for this pair instead of silently skipping it. Verified non-vacuous: removing the guard fails 6 assertions across both files.
|
@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 |
The defect
Every Cloudflare and Discord tool interpolated LLM-writable resource IDs directly into the request path. These params are
visibility: 'user-or-llm', so prompt injection controls them.A value like
../../accounts/victimescapes the/client/v4or/api/v10prefix oncefetchnormalizes the URL, re-aiming an authenticated request — with the user's Cloudflare API token or the workspace's Discord bot token still attached — at a different resource. This reachesDELETE /zones/{id},DELETE /r2/buckets/{name},DELETE /channels/{id},DELETE /guilds/{id}/roles/{id}, and the ban routes.encodeURIComponentdoes not close this..and..are unreserved characters, so they survive encoding untouched and the WHATWG URL parser removes them as dot segments afterwards:The five sites that already encoded (
bucketNamex2,scriptName, and the reactionemojix2) were exposed too, but narrowly — a correction to an earlier version of this description, which overstated them as "exactly as exposed". Measured:encodeURIComponent(5 sites)...../../accounts/victim..%2F..%2Faccounts%2Fvictim)abc/../../accounts/victimabc?x=1So the encoded sites could only ever pop one segment; they were not arbitrary-redirect capable. Still a real defect worth closing, but materially less severe than the raw ones.
The fix
All 134 path-interpolation call sites now route through
safeUrlPathSegment(value, paramName)from@/tools/url-path, which rejects rather than encodes. 73 sites in Cloudflare (48 files), 61 in Discord (39 files).apps/sim/tools/cloudflare/get_zone_settings.tsis deliberately left alone: its localencodePathSegmentalready rejects.and.., and its error wording is asserted by an existing test.Not a risk, left alone
discord/send_message.ts— builds no URL; dispatches through an internal operation.X-Audit-Log-Reasoninban_member/kick_member/unban_member— a header value, not a path segment.since,until,before,limit,with_counts) — built viaURLSearchParams; dot segments carry no meaning in a query.Behaviour preserved
No param
visibility, no subBlock id, and no tool metadata changed —tool-metadata:generateproduces zero drift andcheck-block-registry.tsreports no block definition changes. Discord snowflakes still work, including as JSON numbers or bigints. A snowflake thatJSON.parsealready rounded pastMAX_SAFE_INTEGERis refused by name instead of silently addressing a neighbouring resource.Backwards compatibility
Every guarded param was checked against the provider's own published rules (Discord's API reference; Cloudflare's docs plus its official OpenAPI v4 schema). No documented value for any Cloudflare or Discord path parameter is altered by trimming, and none is
.or..— Cloudflare's rulesets IDs are^[0-9a-f]{32}$, its 24 ruleset phases and 65 zone-setting IDs are[a-z0-9_]+, R2 bucket names are^[a-z0-9][a-z0-9-]*[a-z0-9](min length 3), Worker script names are^[a-z0-9_][a-z0-9-_]*$, and Discord snowflakes are decimal digit strings.Measured old-vs-new on a guarded param:
@me/users/@me/users/@meMAX_SAFE_INTEGERTypeErrorTypeErrorMAX_SAFE_INTEGERTypeErrorJSON.parsehas already rounded1234567890123456789to…800, so accepting it would address a different resource..,../../x,id?x=1The one regression this PR introduced, and fixed:
@meis a literal segment Discord publishes for the current bot (GET /users/@me,DELETE …/reactions/{emoji}/@me,PATCH /guilds/{guild.id}/members/@me). Encoding turned it into%40me, which Discord does not route — a silent 404 no unit test would catch.discordUserPathSegmentnow passes@methrough verbatim in exactly those three slots and delegates everything else unchanged;@everyoneand every other lookalike is still encoded.One deliberate exception to trimming
safeUrlPathSegmenttrims, and for 132 of the 137 sites that is not a change — those params already called.trim(). Exactly five are newly trimmed (they previously went through a bareencodeURIComponent), and only one of those sits on an irreversible request:bucketNamedelete_r2_bucketbucketNameget_r2_bucketscriptNameget_worker_script_settingsemojiadd_reactionemojiremove_reaction" prod-data "names no bucket that can exist, so before this PR that request simply failed. Trimming would turn it into one that destroysprod-data— a fine inference for a read, not one worth making for a delete, and a stray newline from a file read or workflow variable is exactly how it arrives.delete_r2_buckettherefore rejects a padded name instead of canonicalizing it;get_r2_bucketstill trims. The suite pins the divergence viaREJECTS_SURROUNDING_WHITESPACE, which makes the generic per-pair assertion demand a throw for that pair rather than skipping it.Tests
tools/cloudflare/path_safety.test.tsandtools/discord/path_safety.test.tsenumerate 137 (tool, param, branch) cases discovered from the service barrels, so a new tool or a new path param is covered with no edit to the test files. Three separate blind spots shaped them, and each is load-bearing:1. Fuzz one param at a time. URL construction is eager, so filling every param with the same vector means the first guard to throw aborts the case and every sibling goes untested — once a tool has one guard, a newly unguarded sibling can no longer fail CI. That is the worst possible blind spot here, where
channelId+messageIdandzoneId+rulesetId+ruleIdshare one path. Each param is now fuzzed with every sibling held safe.2. Assert rejection, not shape. A shape-only check is nearly blind. Of the 9 reject vectors it catches just 2:
encodeURIComponentturns/into%2F, which the parser never decodes back into a separator, so every separator-bearing traversal preserves the path shape exactly — and a trailing bare.collapses to the parent collection while keeping the segment count identical. Since the guarded id is the final segment ondelete_zone,delete_message,delete_channeland friends — all DELETEs — that is precisely where shape checking fails.MUST_REJECTasserts a throw.3. Probe every branch. A param appearing on only one branch of a conditional builder is invisible to a single all-params probe. The harness harvests comparison literals from
String(tool.request.url)so a futureaction-style builder is probed on every branch automatically (neither service switches on a literal today), and probes each param with every optional sibling omitted in turn — which caught thatcreate_threadandremove_reactionpick different endpoints whenmessageId/userIdare absent. That raised coverage 133 → 137 cases. The (tool, param) set was unchanged, so no unguarded param was hiding there; the 4 additions are previously untested path shapes (the/@meand no-message-thread forms), now ratcheted.Supporting hygiene: no
any— a structuralServiceTool/PathToolpair narrowed viaisPathTool/pathToolFor.SKIPPED_TOOL_IDSasserts the tools that build no URL from params against an explicit allowlist, andUNBUILDABLEasserts empty, so a tool that cannot be exercised is named rather than vanishing from coverage (verified non-vacuous).Verified red-first at every stage. The final scoped revert — the branch-only
userIdguard ondiscord_remove_reactionpluszoneIdoncloudflare_delete_zone, reverted toencodeURIComponent-only rather than removed, since that is the mistake likely to be reintroduced — produces 19 named failures; the equivalent shape-only assertion catches 4. Restoring returns all 3047 tests to green.Gates
bun run lint,bun run check:audits(39/39),tool-metadata:generate(no drift),check-block-registry.ts, andtype-checkclean for the changed files.