Files
software-mansion__argent/packages/tool-server/test/metro/source-maps-ssrf.test.ts
Ignacy Łątka 74c2065ca0 fix: multiple minor bugs (#194)
## 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_id`**
— `detectRunningApp(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
getFlowPath`** — `getFlowPath()` 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 extensions`** — `readSourceFragment()` 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):

- [x] `npm run build` passes (workspace tsc clean)
- [x] `npm run test -w @argent/tool-server` — **510 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):

- [x] Launcher spawns tool-server; `~/.argent/tool-server.json` contains
a 64-char hex token and is mode `0600`
- [x] `GET /tools` without `Authorization` → **401**
- [x] `GET /tools` with wrong token → **401**
- [x] `GET /tools` with correct token → **200** (61 tools)
- [x] Request with `Host: attacker.example` → **403**
- [x] No `Access-Control-Allow-Origin` header on responses
- [x] Cross-origin command-injection probe via `device_id=\"x;touch
/tmp/marker;#\"` does NOT create the marker file (Vuln 4 closed)
- [x] `flow-start-recording` with `name=\"../../../tmp/escape\"`
rejected with `Invalid flow name`
- [x] `flow-start-recording` with a valid name still works
- [x] `tools-client.fetchTools()` and
`tools-client.callTool('list-simulators')` succeed via the auth-token
threading (the path the MCP server uses internally)
- [x] MCP-stdio entry point (`startMcpServer`) end-to-end: `initialize`
→ `tools/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.

</details>
2026-06-03 15:57:42 +02:00

98 lines
3.5 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { isAllowedSourceMapURL, readCappedJson } from "../../src/utils/debugger/source-maps";
describe("isAllowedSourceMapURL", () => {
it("accepts a Metro localhost URL", () => {
expect(isAllowedSourceMapURL("http://localhost:8081/index.map")).toBe(true);
});
it("accepts http://127.0.0.1", () => {
expect(isAllowedSourceMapURL("http://127.0.0.1:8082/foo.map")).toBe(true);
});
it("accepts http://[::1]", () => {
expect(isAllowedSourceMapURL("http://[::1]:8081/foo.map")).toBe(true);
});
it("accepts https on loopback", () => {
expect(isAllowedSourceMapURL("https://localhost:8443/foo.map")).toBe(true);
});
it("rejects an attacker-controlled public host", () => {
expect(isAllowedSourceMapURL("http://attacker.example/leak")).toBe(false);
});
it("rejects the AWS metadata endpoint", () => {
expect(isAllowedSourceMapURL("http://169.254.169.254/latest/meta-data/")).toBe(false);
});
it("rejects a private-network host that is not loopback", () => {
expect(isAllowedSourceMapURL("http://10.0.0.1/secret.map")).toBe(false);
expect(isAllowedSourceMapURL("http://192.168.1.1/secret.map")).toBe(false);
});
it("rejects file:// URLs", () => {
expect(isAllowedSourceMapURL("file:///etc/passwd")).toBe(false);
});
it("rejects malformed URLs", () => {
expect(isAllowedSourceMapURL("not a url")).toBe(false);
expect(isAllowedSourceMapURL("")).toBe(false);
});
it("rejects an unbracketed IPv6 typo", () => {
expect(isAllowedSourceMapURL("http://::1:8081/")).toBe(false);
});
// PR #194 follow-up G: require a `.map` path so an attacker-set
// sourceMapURL can't make us GET an arbitrary loopback endpoint.
it("rejects a loopback URL whose path is not *.map", () => {
expect(isAllowedSourceMapURL("http://127.0.0.1:8081/json")).toBe(false);
expect(isAllowedSourceMapURL("http://localhost:8081/shutdown")).toBe(false);
expect(isAllowedSourceMapURL("http://localhost:8081/")).toBe(false);
});
it("still accepts a loopback *.map path (incl. with a query string)", () => {
expect(isAllowedSourceMapURL("http://localhost:8081/index.bundle.map")).toBe(true);
expect(isAllowedSourceMapURL("http://127.0.0.1:8081/index.bundle.map?platform=ios")).toBe(true);
});
});
// PR #194 follow-up F: source-map bodies are capped before JSON.parse.
describe("readCappedJson (body cap)", () => {
it("rejects when content-length exceeds the cap", async () => {
const res = {
headers: { get: (n: string) => (n === "content-length" ? "999999999" : null) },
body: null,
json: async () => ({ should: "not reach" }),
};
await expect(readCappedJson(res, 1024)).rejects.toThrow(/too large/);
});
it("falls back to .json() when no stream body is available", async () => {
const res = {
headers: { get: () => null },
body: null,
json: async () => ({ ok: 1 }),
};
expect(await readCappedJson(res, 1024)).toEqual({ ok: 1 });
});
it("aborts a streamed body that exceeds the cap", async () => {
const big = new Uint8Array(2048);
let sent = false;
const res = {
headers: { get: () => null },
body: {
getReader: () => ({
read: async () =>
sent ? { done: true, value: undefined } : ((sent = true), { done: false, value: big }),
cancel: async () => {},
}),
},
json: async () => ({ should: "not reach" }),
};
await expect(readCappedJson(res, 1024)).rejects.toThrow(/exceeded/);
});
});