Skip to content

fix(incidentio,trigger_dev): reject path-traversal ids and free the reserved timeout param - #7265

Closed
waleedlatif1 wants to merge 4 commits into
stagingfrom
fix/incidentio-triggerdev-path-safety
Closed

fix(incidentio,trigger_dev): reject path-traversal ids and free the reserved timeout param#7265
waleedlatif1 wants to merge 4 commits into
stagingfrom
fix/incidentio-triggerdev-path-safety

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Defect 1 — path traversal (both services)

incidentio and trigger_dev tools interpolated visibility: 'user-or-llm' identifiers directly into request path segments. A value like ../../v2/incidents/victim re-aims an authenticated request — carrying the user's bearer token — at a different resource, including on the DELETE routes (schedules, workflows, custom fields, incident roles, env vars).

encodeURIComponent is not sufficient. . and .. are unreserved, so they survive encoding, and the WHATWG URL parser removes dot segments after decoding:

new URL('https://api.incident.io/v2/schedules/' + encodeURIComponent('..')).pathname
// => '/v2/'
new URL('https://api.incident.io/v2/alerts/' + encodeURIComponent('..') + '/actions/resolve').pathname
// => '/v2/actions/resolve'

Only rejecting the value works, so every path-interpolated id now goes through safeUrlPathSegment(value, paramName) from @/tools/url-path.

  • incident.io — 30 call sites across actions_*, alerts_*, alert_events_create, custom_fields_*, escalation_paths_*, escalations_*, follow_ups_*, incident_roles_*, incidents_*, incident_timestamps_show, on_call_now, schedules_*, teams_show, users_show, workflows_*.
  • Trigger.dev — 26 call sites across runs, schedules, queues, batches, deployments, tasks, waitpoint tokens, plus the shared buildTriggerDevEnvVarsUrl helper (projectRef, environment, name).

Not a risk — left alone

The list_* tools in trigger_dev interpolate a queryString into the URL, but it comes from URLSearchParams.toString() and sits after the ?, so it cannot reshape the path. Every incidentio list tool builds its URL with new URL() + searchParams. No changes there.

Defect 2 — reserved timeout param (Trigger.dev)

create_waitpoint_token declared a param named timeout. tools/request-transport.ts reads params.timeout as the outbound HTTP request deadline in milliseconds:

const rawTimeout = params.timeout
const timeout = rawTimeout != null ? Number(rawTimeout) : undefined

so the user's waitpoint lifetime silently shadowed the transport deadline.

Renamed the tool param timeoutwaitpointTimeout (following the functionTimeout precedent from AWS Lambda), because Copilot calls executeAppTool without ever running tools.config.params — a block-level workaround would not cover it. The value is mapped back onto the wire field timeout in request.body.

In blocks/blocks/trigger_dev.ts the mapping lives in tools.config.params (which runs after variable resolution, so <Block.output> references survive), never in tools.config.tool. The subBlock id timeout is unchanged, so saved workflow state stays valid; result.timeout is explicitly cleared so the merge in generic-handler.ts cannot leak it to the transport.

No param's visibility changed, no subBlock id changed, and no behaviour changed for legitimate input.

Tests

New tools/incidentio/path_safety.test.ts and tools/trigger_dev/path_safety.test.ts, modelled on tools/vercel/edge_config_path_safety.test.ts:

  • tools are enumerated from the barrel, so a new tool with an unguarded path param fails CI on arrival
  • every URL is resolved with new URL(...) — the same normalization fetch performs — never string-matched
  • the bare . and .. vectors are kept, since their absence is what makes an encodeURIComponent-only fix look correct
  • a LEGITIMATE_IDS list proves real values (run_abc123, batch_9xkq2m, ULID-shaped incident.io ids, my.task.identifier, ..foo, foo..) pass through unchanged

Verified red first: with the source fix reverted and only the new tests present, 413 assertions fail across the two files (traversal reshaping plus both timeout-shadowing cases). Restored, all 1523 tests in the two directories pass.

Gates

bun run tool-metadata:generate (committed), bun run lint, bun run check:audits (all-green, docs regenerated), bun run apps/sim/scripts/check-block-registry.ts origin/staging. Type-check is clean for these files; the only errors are the pre-existing unrelated @aws-sdk/client-lambda resolution failures.


Follow-up: the rename regressed the agent path (a533389)

Validation audit against the Trigger.dev management API turned up a defect the rename itself introduced. The block mapping only read the subBlock spelling, and it assigned unconditionally:

result.waitpointTimeout = scoped(params.timeout, ['trigger_dev_create_waitpoint_token'])

tools.config.params is not only the canvas mapper. providers/utils.ts installs the same function as the provider paramsTransform and spreads its result over the model's tool-call arguments (result = { ...result, ...transformed }), exactly as generic-handler.ts spreads it over the resolved subBlock inputs (finalInputs = { ...inputs, ...transformedParams }). Every key it assigns therefore wins — including when it assigns undefined.

On the agent path the model supplies the tool's own param name, waitpointTimeout, and no timeout subBlock value exists. So the assignment evaluated to undefined and erased what the model sent: body.timeout was never set and the waitpoint was created with no lifetime. Before the rename the model emitted timeout, which the mapping never touched — so this is a regression of the rename, not a pre-existing gap.

Fixed by reading both spellings through the same ?? chain that taskIdentifier, delay, tags and idempotencyKey already use two lines above, so a configured subBlock value still wins over the model argument.

Scope — this was the only unguarded rename of its kind. The other four timeout renames in the batch each guard the assignment on the source being present, so none of them can clobber a model argument:

Integration Guard
daytona if (rest.timeout !== undefined && rest.timeout !== '')
apify if (rest.timeout)
twilio if (operation === 'make_call' && timeout)
elasticsearch if (typeof params.timeout === 'string' && ...)
firecrawl if (params.mapTimeout != null && ...)

Four new tests in path_safety.test.ts cover the canvas path, the agent path, subBlock-wins-over-model precedence, and non-leakage into another operation. Verified red first: reverting only the one-line fix turns exactly one of them red; 1101 tests pass restored.

…eserved timeout param

Both integrations interpolated LLM-writable identifiers straight into
request paths. `encodeURIComponent` does not neutralize a dot segment —
`.` and `..` are unreserved, so they survive encoding and the WHATWG URL
parser removes them afterwards, popping a segment off a fixed host with
the caller's bearer token still attached:

  new URL('https://api.incident.io/v2/schedules/' + encodeURIComponent('..'))
    .pathname // => '/v2/'

Every path-interpolated id now goes through `safeUrlPathSegment`, which
rejects dot segments and separators instead of encoding them. 30
incident.io call sites and 26 Trigger.dev call sites are covered,
including the shared Trigger.dev env-var URL builder.

The Trigger.dev create-waitpoint-token tool also declared a param named
`timeout`, which `tools/request-transport.ts` reads as the outbound HTTP
request deadline in milliseconds — the user's waitpoint lifetime silently
shadowed the transport deadline. The tool param is renamed to
`waitpointTimeout` (following the `functionTimeout` precedent) and mapped
back onto the wire field in `tools.config.params`, which runs after
variable resolution. The `timeout` subBlock id is unchanged, so saved
workflow state stays valid.

New `path_safety.test.ts` suites enumerate both barrels, so a new tool
with an unguarded path param fails CI on arrival.
@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 5:07am

Request Review

@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR validates incident.io and Trigger.dev path parameters before URL interpolation and separates the Trigger.dev waitpoint lifetime from the transport request deadline.

  • Applies safeUrlPathSegment across path-based integration requests.
  • Renames the waitpoint tool argument to waitpointTimeout while preserving saved canvas state through block-level remapping.
  • Adds barrel-driven traversal and parameter-transform coverage and regenerates tool metadata and documentation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/blocks/blocks/trigger_dev.ts Preserves the legacy canvas field while mapping both canvas and agent inputs to waitpointTimeout and preventing transport-timeout leakage.
apps/sim/tools/trigger_dev/create_waitpoint_token.ts Renames the public tool parameter and maps it to the Trigger.dev API’s timeout request-body field.
apps/sim/tools/trigger_dev/utils.ts Validates environment-variable URL path segments before constructing Trigger.dev endpoints.
apps/sim/tools/trigger_dev/path_safety.test.ts Enumerates barrel-exported tools to verify traversal rejection, valid identifiers, and waitpoint parameter behavior.
apps/sim/tools/incidentio/path_safety.test.ts Verifies all barrel-exported incident.io path parameters reject traversal inputs while preserving legitimate identifiers.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Input[Tool input] --> Path{Path parameter?}
  Path -->|Yes| Validate[safeUrlPathSegment]
  Validate -->|Invalid dot segment or separator| Reject[Reject request]
  Validate -->|Valid identifier| Request[Build authenticated API request]
  Path -->|No| Waitpoint{Create waitpoint?}
  Waitpoint -->|Canvas timeout| Remap[Map timeout to waitpointTimeout]
  Waitpoint -->|Agent waitpointTimeout| Remap
  Remap --> Clear[Clear reserved transport timeout]
  Clear --> Body[Send body.timeout as waitpoint lifetime]
Loading

Reviews (2): Last reviewed commit: "fix(trigger_dev): keep the renamed waitp..." | Re-trigger Greptile

@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.

No issues found across 65 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

… at once

The suites inherited a coverage hole from the template they were modelled
on: `buildParams` filled every string param with the same fuzz value, and
the assertion swallowed the vector on any throw. So the first guarded
param on a tool aborted the whole case and its siblings were never
exercised — the "a new unguarded param fails CI" property did not hold
for any tool that already had one guard.

The unit under test is now a (tool, parameter) pair. Each param is fuzzed
alone with every sibling pinned to a safe value, and the pairs are
discovered by probing one marked param at a time rather than listed by
hand, so a new path param joins the matrix on arrival.

The Trigger.dev env-var tools are the case this was hiding:
`buildTriggerDevEnvVarsUrl` interpolates projectRef, environment, AND
name into one path, so guarding projectRef alone would have read as full
coverage for all three. All three are guarded, and an explicit assertion
now pins that so a dropped guard is legible in the failure output.

30 incident.io pairs and 43 Trigger.dev pairs, 1821 assertions.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Follow-up: tests now fuzz (tool, parameter) pairs, not tools

The suites carried the coverage hole described above. buildParams filled every string param with the same fuzz value and the assertion did try { ... } catch { return }, so the first guarded param on a tool aborted the entire vector and its siblings went unexercised. The "a new unguarded param fails CI" property did not hold for a tool that already had one guard.

Rewritten so the unit under test is a (tool, parameter) pair: each param is fuzzed alone with every sibling pinned to SIBLINGID, and pairs are discovered by probing one marked param at a time rather than listed by hand.

Matrix: 30 incident.io pairs + 43 Trigger.dev pairs. 1821 assertions pass (was 1523).

buildTriggerDevEnvVarsUrl — the case this was hiding

The helper interpolates three LLM-writable values into one path (projectRef, environment, name), shared by 6 tools. Under the old shape, guarding projectRef first meant environment and name were never actually exercised on any of them — those tools would have read as fully covered on the strength of one guard.

All three were already guarded in the original fix (I replaced the helper wholesale rather than one segment), so no unguarded param was hiding behind it. But that was luck, not something the old test could have told anyone. There is now an explicit assertion pinning all three so a dropped guard is legible in the failure output rather than silently absorbed.

Red-first re-verified, scoped

Reverted environment in the shared helper, leaving projectRef and name guarded:

   4 'trigger_dev_create_env_var / environment'
   5 'trigger_dev_delete_env_var / environment'
   5 'trigger_dev_get_env_var / environment'
   5 'trigger_dev_import_env_vars / environment'
   4 'trigger_dev_list_env_vars / environment'
   5 'trigger_dev_update_env_var / environment'
Tests  28 failed | 1068 passed

Failures land on exactly the / environment pairs across the 6 sharing tools, and on nothing else — the projectRef and name pairs of those same tools keep passing, which is the sibling isolation the old shape lacked. Under the old test this same revert would have produced zero failures, because projectRef throws first and the catch eats the case.

Reverted incidentio_teams_show's id guard alone:

   4 'incidentio_teams_show / id'
Tests  4 failed | 717 passed

Exactly one pair, no collateral. Both guards restored; all 1821 pass.

Did the tightened test surface a path param I missed?

No. The probe found 73 (tool, parameter) pairs and every one was already guarded — no new call site, and no new failure once the guards were back in. The three-param env-var helper was the only place a miss could plausibly have hidden, and it was already covered end-to-end.

waitpointTimeout and the result.timeout = undefined clearing are untouched. Gates re-run: lint, check:audits (exit 0, 39 audits), check-block-registry.ts origin/staging (subBlock ID stability now checked and passing). No source files changed in this commit, so tool-metadata:generate and the generated docs are unaffected.

… unbuildable tools

Two review points from sibling PRs.

The harness typed tools as `ToolConfig<any, any>` and cast the synthetic
param bag with `as any`. Replaced with a local `FuzzableTool` interface
naming the structural slice the harness actually needs, so the call to
`request.url` is type-checked rather than cast. No `any` remains.

Discovery also swallowed any error from building a tool's URL, so a tool
that could not be built from all-safe values would leave the matrix and
read as "nothing to guard" — the same class of bug as the swallowed
per-vector error this suite was just rewritten to fix. Failures are now
collected and asserted empty, naming the tool and the underlying error.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Rejection assertion: already present, and verified non-vacuous

The suite asserts throw, per (tool, parameter) pair, not just path shape:

it('rejects a bare dot-dot segment, naming the offending parameter', () => {
  expect(() => buildUrl(tool, param, '..')).toThrow(new RegExp(param))
})

it('rejects a bare dot segment, naming the offending parameter', () => {
  expect(() => buildUrl(tool, param, '.')).toThrow(new RegExp(param))
})

Confirmed the way you suggested. Reverting incidentio_teams_show's id guard — a route where the id is the last segment — the 4 failures are:

cannot reshape the path with ".."
cannot reshape the path with "  ..  "
rejects a bare dot-dot segment, naming the offending parameter
rejects a bare dot segment, naming the offending parameter

Note what is absent: cannot reshape the path with "." passes even with the guard removed, exactly as you describe — https://api.incident.io/v3/teams/. normalizes to /v3/teams/, same segment count, every other segment intact. The bare . is caught only by the explicit rejection assertion. The regex on param also pins that the error names the right parameter, so a guard wired to the wrong name cannot pass.

Two review items from the sibling PRs

any removed. The harness typed tools as ToolConfig<any, any> and cast the synthetic param bag with as any. Replaced with a local FuzzableTool interface naming the structural slice the harness needs:

interface FuzzableTool {
  id: string
  params?: Record<string, { type?: string }>
  request?: { url?: string | ((params: Record<string, unknown>) => string) }
}

The type guard narrows from unknown (.map((value): unknown => value) rather than a cast), so request.url is called type-checked. Both files type-check clean under the app tsconfig with the test exclusion lifted — no any, no as any, no double cast.

Discovery no longer swallows. It previously did catch { return false }, so a tool whose URL could not be built from all-safe values would quietly leave the matrix and read as "nothing to guard" — the same class of bug as the swallowed per-vector error. Errors are now collected into DISCOVERY_FAILURES and asserted empty, naming the tool and the underlying message.

Verified non-vacuous by making one tool's url throw:

× builds every candidate tool URL from safe values, so none is skipped silently
AssertionError: expected [ Array(1) ] to deeply equal []
+ [ "incidentio_users_show: synthetic build failure" ]

Gates

1823 assertions pass (was 1821; +2 discovery guards). lint clean, check:audits exit 0 (39 audits), check-block-registry.ts origin/staging all four checks pass. Only the two test files changed in these commits, so tool metadata and generated docs are untouched.

The `timeout` -> `waitpointTimeout` rename stops the param shadowing the
shared transport's own ms deadline, but the block mapping only read the
subBlock spelling, and it assigned unconditionally:

    result.waitpointTimeout = scoped(params.timeout, [...])

`tools.config.params` is not only the UI mapper. `providers/utils.ts`
installs the same function as the provider `paramsTransform` and spreads
its result over the model's tool-call arguments, exactly as the generic
block handler spreads it over the resolved subBlock inputs. Every key it
assigns therefore wins — including when it assigns `undefined`.

On the agent path the model supplies the tool's own param name,
`waitpointTimeout`, and no `timeout` subBlock value exists, so the
assignment evaluated to `undefined` and erased what the model sent.
`body.timeout` was never set and the waitpoint was created with no
lifetime. Before the rename the model emitted `timeout`, which the
mapping never touched, so this path regressed with the rename itself.

Read both spellings through the same `??` chain the neighbouring
`taskIdentifier`, `delay`, `tags` and `idempotencyKey` mappings already
use, so a configured subBlock value still wins over the model argument.

Scope: this was the only unguarded rename of its kind. daytona, apify,
twilio, elasticsearch and firecrawl each guard the assignment on the
source being present.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

@cubic review

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@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.

No issues found across 65 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

@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/incidentio-triggerdev-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.

@waleedlatif1
waleedlatif1 deleted the fix/incidentio-triggerdev-path-safety branch August 29, 2026 07:17
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