Skip to content

fix: multiple minor bugs#194

Draft
latekvo wants to merge 7 commits intomainfrom
security/csrf-and-injection-bundle
Draft

fix: multiple minor bugs#194
latekvo wants to merge 7 commits intomainfrom
security/csrf-and-injection-bundle

Conversation

@latekvo
Copy link
Copy Markdown
Member

@latekvo latekvo commented May 8, 2026

WIP - I'm yet to review this code in detail.

Human Summary

This PR aims to fix bugs that are benign for now, could become serious in the future:

  • multiple CSRF via CORS misconfiguration on tool-server
  • multiple SSRF that were possible due to the CSRF
  • path traversal via flow creator
  • multiple shell injections
  • lack of auth on MCP/CLI <-> tool-server path (bug details discussed in person). After the other fixes this is somewhat redundant, but doesn't hurt and protects against theoretical access attempts from within other processes running on the user's device.

AI Summary

Details Bundles six independent fixes that together close a CSRF cluster on the local tool-server. The vulnerabilities chain because the HTTP API has no auth and serves wildcard CORS, so any web origin (Firefox unconditionally; Chrome via PNA-disabled or loopback-served) can drive the tool-server from a cross-origin tab. Each individual fix lives on its own branch (linked below) and stands alone — this PR exists so the cluster can be reviewed and shipped as one piece.

Fixes

  1. fix(http): require auth token + drop wildcard CORS — per-process 32-byte hex Bearer token, generated by the launcher, persisted in ~/.argent/tool-server.json with mode 0600, required on every route except /preview. Constant-time compare. Wildcard CORS dropped. Threaded through MCP server + CLI + tools-client. Backwards-compat: when ARGENT_AUTH_TOKEN is unset (e.g. npm run dev), the server logs a one-shot warning and skips the gate.
  2. fix(http): reject non-loopback Host headers — middleware (before the auth gate) returns 403 for any request whose Host is not 127.0.0.1, localhost, or ::1. Closes DNS-rebinding bypass.
  3. fix(ios-profiler-start): prevent shell injection via device_iddetectRunningApp(udid) no longer interpolates params.device_id into execSync template strings. Both xcrun simctl spawn ${udid} … and the piped xcrun simctl listapps ${udid} | plutil … are rewritten to execFileSync array form, with the pipe to plutil happening in JS (input option) rather than the shell.
  4. fix(flows): validate name to close path-traversal in getFlowPathgetFlowPath() requires ^[A-Za-z0-9_\\-]+$ and verifies path.relative(getFlowsDir(), filePath) does not start with ... Centralizing the check in getFlowPath covers every caller (start-recording, add-step, insert-echo, finish-recording, run, read-prerequisite) without per-tool churn.
  5. fix(source-resolver): reject paths outside projectRoot + non-source extensionsreadSourceFragment() previously accepted any file field on the SourceLocation it was given (the field traces back to a React fiber's _debugSource.fileName, which is attacker-controllable). Now: containment check via path.relative(projectRoot, abs) plus an extension allowlist (.js/.jsx/.ts/.tsx/.mjs/.cjs/.json).
  6. fix(source-maps): reject non-loopback URLs (close SSRF)doRegister() validates sourceMapURL before fetching: only http:///https:// URLs whose hostname is loopback. Closes a blind-SSRF probe of the host network (cloud metadata at 169.254.169.254, intranet pages, etc.).

Individual branches

  • fix/auth-token-drop-cors
  • fix/host-header-validation
  • fix/profiler-command-injection
  • fix/flow-name-validation
  • fix/source-resolver-traversal
  • fix/source-map-ssrf

If you want to land them as separate PRs, the integration branch can be dropped after each lands.

Test plan

Unit tests (already in CI):

  • npm run build passes (workspace tsc clean)
  • npm run test -w @argent/tool-server510 passed (was 473 on main), +37 new tests across:
    • test/http-auth.test.ts — 10 tests (auth, CORS removal, dev-mode escape hatch)
    • test/http-host-validation.test.ts — 7 tests (loopback allow, DNS-rebinding reject)
    • test/flows/flow-utils.test.ts — +5 tests (path-traversal name rejection)
    • test/metro/source-resolver.test.ts — +5 tests (containment + extension allowlist)
    • test/metro/source-maps-ssrf.test.ts — 10 tests (loopback-only allow)

Live end-to-end (run locally against this branch's bundled artifacts):

  • Launcher spawns tool-server; ~/.argent/tool-server.json contains a 64-char hex token and is mode 0600
  • GET /tools without Authorization401
  • GET /tools with wrong token → 401
  • GET /tools with correct token → 200 (61 tools)
  • Request with Host: attacker.example403
  • No Access-Control-Allow-Origin header on responses
  • Cross-origin command-injection probe via device_id=\"x;touch /tmp/marker;#\" does NOT create the marker file (Vuln 4 closed)
  • flow-start-recording with name=\"../../../tmp/escape\" rejected with Invalid flow name
  • flow-start-recording with a valid name still works
  • tools-client.fetchTools() and tools-client.callTool('list-simulators') succeed via the auth-token threading (the path the MCP server uses internally)
  • MCP-stdio entry point (startMcpServer) end-to-end: initializetools/list (61 tools) → tools/call('list-simulators') returns content. Confirms the launcher → token → fetch → tool-server flow is intact for the production argent mcp path.

Notes for reviewers

  • /preview is intentionally exempt from the auth gate (it was not gated before; tightening it deserves a separate PR).
  • The ARGENT_AUTH_TOKEN-unset escape hatch keeps npm run dev workable; production argent mcp always passes a token via env, so the warning never fires there.
  • The auth bundle changes the public return shape of ensureToolsServer from Promise<string> to Promise<{ url, token }>. The two in-tree callers (argent-mcp/mcp-server.ts, argent-tools-client/tools-client.ts) were updated; external consumers using ARGENT_TOOLS_URL should also set ARGENT_AUTH_TOKEN to match the server.

latekvo added 6 commits May 8, 2026 22:58
`detectRunningApp(udid)` interpolated `params.device_id` directly into
two execSync template strings:

  execSync(`xcrun simctl spawn ${udid} launchctl list`)
  execSync(`xcrun simctl listapps ${udid} | plutil -convert json -o - -`)

A device_id like `x;touch /tmp/pwn;#` was parsed by /bin/sh as additional
commands, giving any caller (including a cross-origin browser POST, since
the tool-server has no auth and serves wildcard CORS) arbitrary command
execution as the user running the tool-server.

Switched both calls to execFileSync with array args, which never invokes
a shell. The piped `xcrun listapps | plutil` is now two stages with the
plist piped via stdin (input option) instead of through /bin/sh.
The unsanitized `name` parameter was joined into
  <project_root>/.argent/flows/<name>.yaml
so a name like "../../../tmp/x" landed the YAML file at an
attacker-chosen path. Combined with the no-auth/wildcard-CORS HTTP
surface, a cross-origin browser POST could write attacker-controlled
YAML anywhere the user can write — .github/workflows/*.yaml,
docker-compose.yml, etc.

Adds assertSafeFlowName() in flow-utils.ts. getFlowPath() now requires
^[A-Za-z0-9_\-]+$ (letters, digits, underscore, hyphen — no separators,
no "..", no spaces) and additionally checks that the resolved path
stays inside the flows directory via path.relative.

Centralizing the check in getFlowPath() means every caller
(flow-start-recording, flow-add-step, flow-insert-echo,
flow-finish-recording, flow-run, flow-read-prerequisite) is covered
without per-tool churn.
The tool-server binds to 127.0.0.1, but a public attacker page that
DNS-rebinds its hostname to 127.0.0.1 still reaches us — the Host header
is the only signal that distinguishes that traffic from a legitimate
same-origin request.

Adds an early middleware (runs before CORS) that 403s any request whose
Host header is not a loopback name (127.0.0.1, localhost, ::1). IPv6
literals in brackets are normalised before the check.

Without this, an attacker can pair a DNS-rebinding flow with the
existing wildcard CORS to read /tools and POST to /tools/:name from any
public origin.
…xtensions

readSourceFragment() previously accepted any `file` field on the
SourceLocation it was given:

  - if absolute, it read it directly (anywhere on disk);
  - if relative, it joined with projectRoot but never verified the
    resolved path stayed inside (so "../../../etc/passwd" worked).

The `file` field originates in a React fiber's _debugSource.fileName,
which a malicious app — or a cross-origin browser tab leveraging Vuln 1
to call debugger-evaluate — can mutate to any string. Combined with the
no-auth HTTP surface, this was an arbitrary file-read primitive
(~/.gitconfig, ~/.aws/credentials, /etc/passwd, ...).

Adds two gates inside readSourceFragment:

  1. Containment: path.resolve the absolute candidate, then
     path.relative(projectRoot, absPath) — null if it starts with `..`
     or is itself absolute (escaped projectRoot).
  2. Extension allowlist: only .js/.jsx/.ts/.tsx/.mjs/.cjs/.json. Even
     .env files inside the project are out of scope here.

Both checks return null silently; the fiber-source-fragment path is
opportunistic (the inspector never required it to succeed) so attackers
get no signal about whether a file existed.
doRegister() fetched the sourceMapURL from a Debugger.scriptParsed CDP
event with no URL validation. Combined with the no-auth tool-server
HTTP surface, a malicious app — or any caller of debugger-evaluate /
debugger-connect — could inject a script whose //# sourceMappingURL
pointed at an attacker-chosen URL. The tool-server would then dutifully
fetch it, turning it into a blind SSRF probe of the host network
(internal services, AWS metadata at 169.254.169.254, intranet pages,
etc.).

Adds isAllowedSourceMapURL(): only http/https URLs whose hostname is
localhost, 127.0.0.1, or ::1 (IPv6 brackets stripped). Anything else,
including malformed URLs and file:// schemes, is rejected and the map
is silently skipped.

The data: URI branch is untouched — those are inline and don't fetch.
The tool-server exposed every registered tool over HTTP with no
authentication and Access-Control-Allow-Origin: * on every response, so
any web origin (Firefox unconditionally; Chrome via PNA-disabled or
loopback-served) could read /tools and invoke /tools/:name from a
cross-origin browser tab.

Adds a per-process auth token:

  - launcher.ts generates a 32-byte hex token at server start, passes it
    to the spawned tool-server via ARGENT_AUTH_TOKEN, and persists it in
    ~/.argent/tool-server.json with mode 0600.
  - http.ts requires Authorization: Bearer <token> on every route except
    /preview (which is the in-process UI and was not gated before;
    tightening it is a follow-up). Constant-time compare prevents
    byte-by-byte timing recovery. Wrong/missing token returns 401.
  - argent-tools-client and argent-mcp/mcp-server thread the token
    through every fetch (fetchTools, callTool, healthcheck, /shutdown).
  - argent-cli/server.ts reads the token for `argent server status`
    healthcheck. The token is hidden from --json output (replaced with
    `hasToken: bool`).
  - ensureToolsServer now returns { url, token } instead of just url.

Also drops the global Access-Control-Allow-Origin: * middleware. The
tool-server is in-process stdio for MCP and same-origin for /preview;
no browser needs cross-origin access. Without CORS, the browser cannot
even read response bodies for hostile cross-origin probes.

Backward compat: when ARGENT_AUTH_TOKEN is unset (e.g., `npm run dev`)
the server logs a one-shot warning and skips the gate, so existing local
dev workflows keep working. Stale state files lacking a token are
treated as a launcher-generation mismatch and the server is respawned.
@latekvo latekvo changed the title security: close CSRF / RCE / SSRF / path-traversal cluster on tool-server fix: multiple minor bugs May 8, 2026
…gnature

- Run prettier --write on source-resolver.ts and source-resolver.test.ts
  (collapsed extension allowlist + readSourceFragment call sites onto
  single lines, per repo style).
- Update launcher.test.ts to match the new buildToolsServerEnv signature
  (token is now the third positional arg, baseEnv moved to fourth).
  Adds an assertion that the auth token is forwarded into the spawned
  tool-server's env.
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