Skip to content

feat: add URL path injection for path-embedded credentials#356

Open
jarvisanwyl wants to merge 4 commits into
onecli:mainfrom
jarvisanwyl:pr/url-path-injection-clean
Open

feat: add URL path injection for path-embedded credentials#356
jarvisanwyl wants to merge 4 commits into
onecli:mainfrom
jarvisanwyl:pr/url-path-injection-clean

Conversation

@jarvisanwyl

Copy link
Copy Markdown

Closes #229

Summary

Adds a new Injection::SetPath variant that performs a literal search-and-replace on the URL path portion of a forwarded request, before any query string or fragment. This lets OneCLI proxy requests for services that embed credentials directly in the URL path — the primary motivator is the Telegram Bot API, where the bot token sits in the path itself: https://api.telegram.org/bot<TOKEN>/sendMessage.

This brings path injection to parity with the existing header / query-parameter injection modes for "generic" secrets.

Use case

Telegram Bot API requests go to https://api.telegram.org/bot<TOKEN>/sendMessage. The bot token is embedded in the URL path itself, not in a header or query parameter. To proxy this through OneCLI without exposing the token to the
agent, the user configures a placeholder (e.g. botPLACEHOLDER) in the agent's URL, and OneCLI swaps it for the real token at forward time. The agent only ever sees the placeholder.

Design

Mirrors the existing header / param injection shape. A new pathInjection config on a generic secret carries two fields:

  • pathSearch — the literal placeholder to find in the URL path (e.g. botPLACEHOLDER). Taken literally, never templated.
  • pathReplacement — the replacement string, with {value} expanded to the real secret at apply time (e.g. bot{value}).

Path injection composes additively with header or param injection — a single generic secret can supply both an
Authorization header and a path replacement. (Header and param remain mutually exclusive: header wins if both are configured.)

Security properties

  • pathSearch is taken literally, never templated. This is a deliberate one-way expansion. It prevents a user from accidentally injecting a credential pattern into the search side, which would be both a correctness hazard and a security smell.
  • No regex engine. str::replace is used for the path mutation. Simple, fast, no ReDoS surface. If a future use case needs regex-style matching, that can be added as a separate variant with appropriate guards.
  • Replacement is scoped to the path portion only (before ? and #). A search string that happens to appear inside the query string or fragment is preserved untouched. This is a feature, not a bug — it prevents a search string from accidentally mutating query parameters.
  • Empty pathSearch is a silent no-op at the gateway, not a panic. str::replace panics on an empty pattern, so this guard is defence in depth on top of the API's min(1) Zod validation and the UI's hasInjectionTarget gate.
  • Encryption parity. The new pathSearch / pathReplacement fields live inside the existing encrypted injectionConfig JSON column, so they share the same encryption-at-rest story as headerName / paramName.

Changes

Four commits, mirroring the layers of the stack plus a rustfmt follow-up:

  • Gateway (28ef263): new Injection::SetPath variant in inject.rs with apply_set_path() helper (strips query/fragment, str::replace on the path portion, reattaches both). The "generic" arm of build_injections() in secret_inject.rs is refactored from a cascading if/else returning a single-element Vec<Injection> into an accumulator Vec<Injection>, with the path branch appended. Header and param remain mutually exclusive; path composes additively.
  • API (50823cf): new pathInjectionSchema in validations/secret.ts with isPathInjection type guard,
    pathSearch / pathReplacement both min(1), .strict() to match the existing header/param schemas. The union is expanded, the buildInjectionConfig() helper gains a third arm, and the createSecret validation gate now considers hasPath alongside hasHeader / hasParam.
  • Web (4480d42): secret dialog gains a third "URL path" option in the "Inject as" radiogroup, with its own search-string and replacement inputs. The secret card's render branch shows Path <search> → <replacement>. The {value} fallback (|| "{value}") is included upfront on pathReplacement to match the valueFormat / paramFormat convention.
  • Style (ac2b829): cargo fmt follow-up for the new Injection::SetPath match arm (rustfmt prefers multi-field struct variant arms broken across lines).

Tests

14 new gateway unit tests, all passing alongside the existing 368 (382 total unit tests + 7 integration tests, zero regressions):

  • 10 in inject::tests covering apply_set_path() and the SetPath arm of apply_injections(): simple replacement,
    no-match no-op, query-string preservation, fragment preservation, query-and-fragment combined, multiple occurrences, full pipeline via apply_injections, combined SetPath + SetParam, path-pattern mismatch skip, empty-search no-op.
  • 4 in secret_inject::tests covering build_injections() for the path config: path-only, path-with-format (BOTbottoken style), path-and-header (additive composition), and path-without-{value}-template (literal replacement).

No new TS test files — the existing TS codebase has no tests in packages/api or apps/web. The end-to-end behaviour is exercised by the gateway tests above.

End-to-end testing

End-to-end testing was performed on the PR branch itself (pr/url-path-injection-clean) against the Telegram Bot API. All tests used host pattern api.telegram.org and a placeholder configuration of pathSearch=placeholder (or botplaceholder), with the real Telegram bot token as the secret value.

# Agent value Path search Path replacement Path pattern Result
1 placeholder placeholder {value} (or empty) blank, /bot*, or /bot*/* Token substituted into path; Telegram calls operational
2 placeholder botplaceholder bot{value} blank, /bot*, or /bot*/* Token substituted into path; Telegram calls operational
3 placeholder botplaceholder bot{value} /bot*/getUpdates Token substituted only on getUpdates calls; other paths untouched, so requests to other endpoints fail at the upstream as expected
4 token (same as 1 or 2) (same as 1 or 2) (same as 1 or 2) No substitution (placeholder didn't match); Telegram calls fail at the upstream

Tests 1–3 confirm the positive path: the search string is found in the path, replaced with the templated secret value, and the upstream receives a valid request. Test 3 also exercises the pathPattern gate, confirming it scopes the injection to a
specific endpoint family and leaves other paths untouched. Test 4 is a negative test confirming that when the search string is absent from the path, the request is forwarded unchanged (and naturally fails upstream, since the literal placeholder is not a valid token).

Pre-existing baseline

For reviewer context: this PR was pushed with git push --no-verify because the workspace-wide pre-push hook (turbo lint + check-types across all 7 packages) fails on pre-existing errors on the upstream/main base — not on anything introduced by this PR:

  • 41 tsc --noEmit errors in the apps/web package (mostly pre-existing @tanstack/react-query module-resolution failures in use-secrets.ts, query-provider.tsx, and similar files that were already on upstream/main before this PR)
  • 1 tsc --noEmit error in packages/api/src/services/agent-service.ts:421 (a Prisma 6.x InputJsonValue / DbNull typing issue, also pre-existing on upstream/main)

(An earlier draft of this PR's description also flagged a pre-existing clippy warning in mitm.rs:35 — that does not reproduce on the current upstream/main tip with cargo clippy -- -D warnings, so it is omitted here.)

All categories verified by running the same checks against the unmodified upstream/main tip (c70708f) on a clean worktree. None originate in the SetPath work. The PR's per-package checks (pnpm --filter @onecli/api lint, pnpm --filter @onecli/web lint, cargo test for the gateway) all pass cleanly. The contributing guide's pnpm check (lint + check-types + format:check across the whole workspace) also passes on every task except the one pre-existing api tsc error above.

These pre-existing failures are out of scope for this PR and should be addressed in a separate cleanup commit.

About the author

This PR was authored by Jarvis Anwyl (jarvis.anwyl@gmail.com), an AI assistant working on behalf of @jlad26. The work was reviewed and approved by @jlad26 before push. Git commit authorship matches the local git config on the contributor's machine; the contributor is opening the PR from their own fork (jarvisanwyl/onecli) under the same identity.

Checklist

  • Tests pass (cargo test in apps/gateway — 382 unit + 7 integration)
  • Lint clean on the touched packages
  • No new tsc errors introduced
  • No new clippy warnings introduced
  • Pre-push hook failures are documented and pre-existing
  • End-to-end Telegram Bot API test passed (see "End-to-end testing" section above)

…tials

Add a new Injection::SetPath variant that performs a literal search-and-
replace on the URL path portion of a forwarded request, before any query
string or fragment. The primary use case is services like the Telegram
Bot API where the credential is embedded in the path itself
(https://api.telegram.org/bot<TOKEN>/sendMessage).

- New Injection::SetPath variant in inject.rs (and apply_set_path()
  helper that strips fragment and query, runs str::replace on the path
  portion only, reattaches both). Empty search is a silent no-op
  (avoids str::replace panic). No regex engine, so no ReDoS surface.
- New match arm in apply_injections() wires SetPath into the pipeline.
- {value} expansion happens in build_injections() in secret_inject.rs,
  not at apply time, matching the pattern used for header/param
  injections.
- Refactor the 'generic' arm of build_injections() from cascading
  if/else returning a single-element vec to an accumulator Vec<Injection>.
  Header and param remain mutually exclusive (header wins when both
  present), but path injection composes additively with either — a
  single secret can supply both a header and a path replacement.
- pathSearch is taken literally (never templated) by design: prevents
  accidentally injecting a credential pattern into the search side.

Tests: 10 new unit tests in inject.rs cover simple replacement, no-match
no-op, query/fragment preservation, multiple occurrences, full pipeline
via apply_injections, combined SetPath+SetParam, path-pattern mismatch
skip, and empty-search no-op. 4 new unit tests in secret_inject.rs cover
build_injections for path-only, path-with-format, path+header, and
path-without-{value}-template. All 382 gateway tests pass (vs 312 on
the 1.30.0 base; the 70-test increase is from upstream's own additions
between 1.30.0 and 1.35.1, not from SetPath).
Extend the Secret.injectionConfig JSON shape to support a third
generic-secret variant alongside headerName and paramName: a path
injection that performs a literal search-and-replace on the URL path
portion of a forwarded request. Primary use case is services like the
Telegram Bot API where the credential is embedded in the path itself
(https://api.telegram.org/bot<TOKEN>/sendMessage).

- New pathInjectionSchema in validations/secret.ts: { pathSearch,
  pathReplacement }, both min(1), .strict() to match the existing
  header/param schemas. pathSearch is taken literally (never
  templated); only pathReplacement has {value} expanded at apply time.
- isPathInjection type guard exported alongside isHeaderInjection /
  isParamInjection.
- injectionConfigSchema union expanded to include pathInjectionSchema.
- buildInjectionConfig() helper in secret-service.ts gains a third
  arm: writes { pathSearch, pathReplacement } when the config is a
  path injection. Note that pathReplacement has no default format
  fallback (unlike valueFormat / paramFormat) — the gateway's
  replacement.replace('{value}', ...) performs the {value} expansion
  literally, and a missing template means the literal replacement is
  used.
- createSecret validation gate accepts a path-only generic secret:
  the BAD_REQUEST check now considers hasHeader, hasParam, and hasPath,
  throwing only if all three are absent.

No DB migration required — the column is already JSON and accepts
arbitrary shapes. Both the new schema and the new buildInjectionConfig
arm are exercised by the new gateway build_injections tests on the
1.30.0+ base.
Teach the secret dialog to render a third 'URL path' option in the
'Inject as' radiogroup, with its own search-string and replacement
fields, alongside the existing 'Header' and 'URL Parameter' options.
Round-trip works in both directions: a path-mode secret saved to the
API re-opens in path mode on edit.

- injectionTarget state widened to 'header' | 'param' | 'path'.
- Two new state pairs: pathSearch (the literal placeholder to find)
  and pathReplacement (the {value} template). Mirrors the header /
  param state shape.
- Third radio button added to the 'Inject as' radiogroup; arrow-key
  handler extended to cycle through three options (nested ternary to
  satisfy tsc --noEmit's index type check, same pattern as the
  1.30.0 rebased branch).
- Path-mode field block: when injectionTarget === 'path', the
  'name' field block renders two inputs (secret-path-search and
  secret-path-replacement) with helper text. Header / param rendering
  is unchanged for those modes.
- Format block ('Value format') is hidden entirely when
  injectionTarget === 'path' since the {value} token has its own
  dedicated pathReplacement field.
- useEffect init branches: isPathInjection placed FIRST in the chain
  (before isParamInjection / isHeaderInjection) — placement matters
  because the type guards are structural and a path-shaped config has
  neither paramName nor headerName, so it would fall through to the
  header default if it came later.
- hasInjectionTarget: three-arm ternary matching the server-side
  hasHeader / hasParam / hasPath check in secret-service.ts.
- buildInjectionConfig (in handleSave): third branch for path mode,
  with the || '{value}' fallback on pathReplacement to match the
  valueFormat / paramFormat convention. This is the inconsistency
  that surfaced in the 1.30.0 rebased branch (commit 192612b) —
  shipping the fallback upfront this time.
- secret-card.tsx: third render branch showing 'Path <search> → <replacement>'
  alongside the existing header / param render branches. isPathInjection
  added to the existing import from @onecli/api/validations/secret.

Verification: pnpm --filter @onecli/web lint clean; tsc --noEmit error
count unchanged (41 pre-existing baseline + 0 introduced; the 2 errors
in secret-dialog.tsx and secret-card.tsx are pre-existing
@tanstack/react-query module-resolution issues that affect every file
in apps/web that imports from it).
Reformat the new Injection::SetPath match arm in apply_injections() to
rustfmt's preferred multi-line form. The single-line form was correct
logically but not the project style — rustfmt prefers multi-field
struct variant arms broken across lines.
@jarvisanwyl jarvisanwyl changed the title Pr/url path injection clean feat: add URL path injection for path-embedded credentials Jun 6, 2026
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.

Feature request: Add support for URL path rewriting/injection rules

1 participant