Skip to content

Commit 6042a14

Browse files
MatousMarikyfe404
andauthored
feat: keep upstream proxy credentials out of transcripts (#22, #23) (#24)
* redact credentials from upstream success messages proxy_set_upstream and proxy_set_host_upstream echoed proxy_url verbatim, writing the upstream password into the MCP client transcript. * add redactProxyUrl() in utils: replaces the password with ***, keeps scheme, username, host and port so the confirmation stays useful * username preserved deliberately: for some providers it is configuration rather than a secret (Apify Proxy encodes group, country and session id there) * splices the original string rather than re-serializing, so an authority-only URL does not gain a trailing slash * unparseable input returns a placeholder, never the raw value * take the upstream password from the environment An authenticated upstream had to carry its password in the tool call, which the MCP client persists in the transcript. mergeUpstreamPassword() fills the password slot from PROXY_MCP_UPSTREAM_PASSWORD when the URL has a username but no password. The value can only land in the password slot — the field redaction masks — so no echo-back path exists by construction, and URL serialization percent-encodes it, so a password containing "@" or "/" cannot re-point the upstream at another host. One credential for all upstreams; per-provider secrets can wait for a second provider. A URL with no username, or one that already carries a password, is untouched, so existing calls are unaffected. Redaction also masks query values now, covering a pac+http:// token, and re-serializes the URL so a password duplicated into the query cannot survive. * redact upstream credentials in status output getStatus() returned globalUpstream and hostUpstreams as stored, and both callers serialized them raw: proxy_status (tools/lifecycle.ts) and the proxy://status resource, which clients read unprompted. Status is read far more often than a set-upstream call, so this was the larger writer of credentials into a transcript and left the redaction added earlier only half done. * redact on the way out inside getStatus(), the one place both callers route through * the stored configs keep their real credentials; resolveProxyConfig() reads proxyUrl to route, so mutating them would break proxying * test pins both halves: status carries no raw password, and the stored config still does * getGlobalUpstream()/getHostUpstreams() intentionally still return raw values; they have no callers today, and a future routing consumer needs the real thing * document the upstream password env var The env-sourced password and the redaction behaviour existed only in tool descriptions. * README upstream section: how to keep the password out of the transcript, what redaction covers, and that the username is deliberately not redacted * mobile section: cross-reference, since proxy_mobile_setup honours the same variable * README:601 needed no change; its "process environment is not exposed" claim is scoped to Camoufox launch/list/info responses, which this does not touch * pin the real limits of upstream password delivery The round-trip test used a secret containing ":" and asserted losslessness, but it only checked url.parse().auth. socks-proxy-agent@7 then does auth.split(":") and takes [1] (mockttp/node_modules/socks-proxy-agent/dist/index.js:78-81), so "pa:ss" authenticates as "pa" — the test passed while giving false assurance for a socks upstream. * drop ":" from the lossless-round-trip secret; it holds for everything else * add a test through the https-proxy-agent path, where ":" is safe * add a test pinning the socks truncation, so the restriction is visible and the test fails if socks-proxy-agent ever honours the full password * README: note the socks restriction next to the env var Not a regression: a literal socks5://user:pa%3Ass@host:1080 truncates identically, with no merge involved. * drop a needless generic and a non-null assertion * redactUpstreamConfig: plain function over UpstreamProxyConfig instead of a generic with a conditional return type and two "as never" casts; the null case falls out of && at the call site * mobile: branch on resolvedUpstream rather than upstream_proxy_url, so the non-null assertion goes away * respond to review: encode the env password, report which credential was used 1. mergeUpstreamPassword() assigned the env value to url.password raw. That setter escapes "@" and "/" but not "%", while mockttp reads the credential back through url.parse().auth, which decodeURIComponent()s it — so a password of "100%pass" made that decode throw URIError, and "p%20ss" silently authenticated as "p ss". encodeURIComponent() first is lossless: the setter escapes nothing encodeURIComponent leaves alone. Pinned by a round-trip test over "100%pass", "p%20ss" and "%". 2. Omitting the password when the server has no PROXY_MCP_UPSTREAM_PASSWORD was a silent no-op reported as success, surfacing later as unexplained 407s. The set-upstream responses now carry passwordSource: env | url | none. 3. README: the env var has to be in the spawned server's environment, so the 'export' example could not work for a stdio server. Replaced with 'claude mcp add -e' and an .mcp.json "env" block. * bind the env password to one host, and mask every field that can carry a token Second review round. The env-sourced password was merged on "username present, password absent" alone, so a caller could name any host and have the credential delivered there — the proxy sends it on the first request, and the transcript shows only "***". That is the same exfiltration class that got the ${VAR} design withdrawn, by delivery rather than echo-back, which redaction cannot see. The merge now also requires PROXY_MCP_UPSTREAM_HOST to match the URL's hostname (case-insensitive, exact, port-independent), and fails closed when that variable is unset so a half-configuration cannot become an unbound credential. redactProxyUrl() masked userinfo and query values but left the path and fragment verbatim, so a PAC provider carrying its token in the path — the one scheme the README singles out — had it echoed in full by proxy_set_upstream, proxy_status and the proxy://status resource. Path segments and the fragment are now masked for every scheme; an upstream URL's path is never the useful part of a confirmation message. A proxy_url the code cannot parse no longer reports success. Redaction had removed the echo that used to make a typo self-evident, and nothing downstream validates it either. No scheme allowlist — an unsupported-but-parseable scheme failing per request is pre-existing and unrelated to these issues. passwordSource is omitted for a URL with no username, where the question does not arise, instead of reporting "none" at a correctly unauthenticated upstream. Username-only auth is a real credential, so "none" stays a success rather than an error; the README documents that it cannot be expressed at the pinned host while the password variable is set. Also: proxy_mobile_setup's field renamed password_source -> passwordSource for parity with the other two tools, docblocks trimmed where the PR body and README already carry the rationale, and a stale review-round label dropped from a test. * refuse a socks upstream that would truncate the password Third review round. socks-proxy-agent splits the credential on the first ":" and keeps only what follows, so an env password of "pa:ss" authenticated as "pa" — silently. The README documented it and a test pinned it, but a caveat is thin protection against a failure that looks like a provider outage. mergeUpstreamPassword() now throws for a socks* upstream when the password contains ":", naming the variable and the remedy. The literal-URL form still truncates; nothing can guard that, and the test says so. Also: isParseableUrl() replaced by URL.canParse(), available since Node 20 and the engines floor is >=20; and the README now says PROXY_MCP_UPSTREAM_HOST must be a bare hostname, since copying the host:port pair out of the proxy URL fails closed and the fail-closed path cannot explain why. * drop the query when redacting a proxy URL Masking query values still echoed a bare token. "?SECRETTOKEN" has no "=", so it parses as a key with an empty value and came back as "?SECRETTOKEN=***" — the token in full, wearing the mask that is supposed to mean it is gone, so nobody would spot it. It reached the set-upstream message, proxy_status and the proxy://status resource. * drop url.search instead of masking values, matching how the fragment is already handled * less code than masking keys as well, and nothing in a proxy URL query is worth echoing in a confirmation * tests cover the bare-token forms, including one mixed with a normal pair * correct the tool descriptions and match IPv6 upstream hosts Two gaps between what the code does and what a caller is told. * the three proxy_url/upstream_proxy_url descriptions still said the password comes from PROXY_MCP_UPSTREAM_PASSWORD alone. Since the host pinning landed, PROXY_MCP_UPSTREAM_HOST must also be set and match, or nothing is merged — an agent reading the old text would expect a merge that silently does not happen. They now state both conditions and mention passwordSource. The README already documented this correctly; the tool descriptions are what an agent actually reads. * url.hostname keeps the brackets on an IPv6 literal, so a bare "::1" — the form the README documents — never matched and the merge failed closed. Strip brackets from both sides before comparing. A different IPv6 host is still refused. * stop leaking the upstream password into spawned processes interceptor_spawn and the camoufox launcher both spread ...process.env into the child. Since this PR puts PROXY_MCP_UPSTREAM_PASSWORD in the server environment, any caller could read it straight back: interceptor_spawn { command: "sh", args: ["-c", "env"] } That defeats the point of the variable — the premise is that the caller cannot see the password, only have it merged into a URL on their behalf. Verified against a running server before and after. * delete the password from the child environment in terminal.ts and camoufox.ts * export UPSTREAM_PASSWORD_ENV rather than restating the literal in three places * PROXY_MCP_UPSTREAM_HOST stays: it is configuration, not a secret, and a spawned tool may legitimately need to know where traffic goes * refuse a ':' username on every scheme, redact opaque-path URLs Two ways a credential still slipped through, plus a docblock fix. * a ":" in the username was guarded only for socks. Basic auth splits the decoded pair at the first colon too (RFC 7617), so "gro:ups" + "s3cret" reaches an http proxy as user "gro", password "ups:s3cret" — the merged password discarded while the response still says passwordSource: env. The guard is now scheme-agnostic. Checking url.parse().auth alone made http look harmless, which is one layer short of what the proxy reads. * redactProxyUrl left an opaque-path URL untouched: "pac+http:host/TOKEN.pac" has no authority, so assigning pathname is a silent no-op and the token was echoed verbatim. Such a URL is not a usable upstream, so it is reduced to its scheme. * stripBrackets had been inserted between mergeUpstreamPassword and its docblock, so the 15-line comment documented the helper instead. Moved above. * fix(upstream): accept '%' usernames, tighten claims, add spawn coverage Review round 4. * mergeUpstreamPassword() called decodeURIComponent(url.username), which throws URIError on a bare "%" — "http://100%pass@pinned/" failed with "URI malformed" and no mention of the username. The WHATWG parser always encodes a literal ":" in userinfo as "%3A", so /%3a/i is total and cannot throw. Same failure the password path fixed earlier, reintroduced one field over. * README overstated the spawn fix: interceptor_spawn runs arbitrary commands as the server user and can read the client config the password lives in, so removing it from the child environment is defence-in-depth, not a boundary. Says so now. * README claimed exporting in your own shell does not reach the server. CLI clients pass their environment through, so it can; the advice to use the client config stands, the absolute did not. * mobile: password_source to match its snake_case siblings, and the last non-null assertion is gone. * dropped a socks paragraph that had been pasted above the username guard, where it does not apply. * unit coverage for the spawn scrub, per AGENTS.md. Mutation-checked: removing the delete fails it. * refactor(interceptors): share one spawn-env scrub, document password_source Review round 5. * the camoufox scrub was the only behaviour in the diff no test could kill — deleting it left the suite green. terminal.ts and camoufox.ts were building the same child env and repeating the same delete, so both now go through spawnEnv() in utils. One pure function, unit-tested, and removing the scrub now fails three tests instead of none. * proxy_mobile_setup emits password_source (snake_case, matching its siblings) while the README said passwordSource. Both are now stated, and the mobile tool description mentions the field at all — it was the only one of the three that did not, so an agent had no in-band way to learn the key. * dropped the CHANGELOG entry and the 3.4.0 bump. src/index.ts hardcodes the server version separately and I had left it at 3.3.2, so the bump would have shipped a server advertising the old version. Choosing minor-vs-patch for someone else s release cadence is the maintainer s call anyway; the precedent commit cited for it was their own. * test(utils): pin empty-password (user:@host) redaction #22's acceptance criteria list this case for redactProxyUrl; the behavior was already correct — the WHATWG parser erases the empty password — but untested. --------- Co-authored-by: yfe404 <yfe.github@protonmail.com>
1 parent 215cc9f commit 6042a14

10 files changed

Lines changed: 798 additions & 19 deletions

File tree

README.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ node dist/index.js --transport http --port 3001
7171

7272
`--transport` and `--port` also accept env vars `TRANSPORT` and `PORT`.
7373

74+
`PROXY_MCP_UPSTREAM_PASSWORD` and `PROXY_MCP_UPSTREAM_HOST` keep an upstream
75+
proxy password out of the transcript — see
76+
[Keeping the upstream password out of the transcript](#keeping-the-upstream-password-out-of-the-transcript).
77+
7478
### Manual MCP configuration
7579

7680
The configured server alias controls Claude's generated tool prefix. The examples below use `proxy-mcp`, so Claude Code exposes tools as `mcp__proxy-mcp__<tool_name>`. If you rename the server key to `proxy`, use `mcp__proxy__<tool_name>` instead.
@@ -185,6 +189,122 @@ proxy_set_upstream --proxy_url "socks5://user:pass@upstream.example:1080"
185189

186190
Supported upstream URL schemes: `socks4://`, `socks5://`, `http://`, `https://`, `pac+http://`.
187191

192+
#### Keeping the upstream password out of the transcript
193+
194+
Tool calls and tool results are both persisted by the MCP client. To avoid
195+
writing an upstream password there on every call, set it in the server's
196+
environment and pass a URL with a username but no password.
197+
198+
Two variables are required, and both have to be in the environment of the
199+
**server process**, which the MCP client spawns. Exporting them in the shell you
200+
launch the client from may reach it — CLI clients pass their own environment
201+
through — but that depends on the client and is lost the moment the server is
202+
started any other way. Put them in the client's server config:
203+
204+
| variable | meaning |
205+
|---|---|
206+
| `PROXY_MCP_UPSTREAM_PASSWORD` | the password to fill in |
207+
| `PROXY_MCP_UPSTREAM_HOST` | the only hostname it may be sent to — a bare hostname, no scheme, port or path |
208+
209+
```bash
210+
claude mcp add proxy-mcp \
211+
-e PROXY_MCP_UPSTREAM_PASSWORD=s3cret \
212+
-e PROXY_MCP_UPSTREAM_HOST=upstream.example \
213+
-- npx -y proxy-mcp@latest
214+
```
215+
216+
```json
217+
{
218+
"mcpServers": {
219+
"proxy-mcp": {
220+
"command": "npx",
221+
"args": ["-y", "proxy-mcp@latest"],
222+
"env": {
223+
"PROXY_MCP_UPSTREAM_PASSWORD": "s3cret",
224+
"PROXY_MCP_UPSTREAM_HOST": "upstream.example"
225+
}
226+
}
227+
}
228+
}
229+
```
230+
231+
Then omit the password from the call:
232+
233+
```bash
234+
proxy_set_upstream --proxy_url "http://user@upstream.example:1080"
235+
# routes as http://user:s3cret@upstream.example:1080
236+
```
237+
238+
**Why the host variable exists.** Without it, a caller who cannot read the
239+
password could still name any host and have the password delivered there — the
240+
proxy sends it on the first request, and the transcript would show only `***`.
241+
The hostname is matched case-insensitively and exactly, with no wildcards; the
242+
port is not part of the match, so one variable covers a provider offering
243+
several. A URL naming any other host is left alone. If
244+
`PROXY_MCP_UPSTREAM_PASSWORD` is set and `PROXY_MCP_UPSTREAM_HOST` is not,
245+
nothing is merged at all: a half-configuration fails closed rather than
246+
becoming an unbound credential.
247+
248+
> **This keeps the password out of tool arguments and responses, not out of
249+
> reach.** `interceptor_spawn` runs an arbitrary command as the server user, so
250+
> a caller can read the client config file the password is configured in — and
251+
> on Linux `/proc/<pid>/environ`. The variable removes the routine exposure of
252+
> writing a credential into every tool call; it is not a sandbox, and anyone who
253+
> can call `interceptor_spawn` should be treated as able to obtain the password.
254+
255+
The response reports which credential was used — `passwordSource` is `env`,
256+
`url` or `none`. `proxy_mobile_setup` spells it `password_source`, matching the
257+
snake_case of the rest of that tool's response. `none` means no password was applied to a URL that names a
258+
user: either the credential is genuinely username-only, or the server does not
259+
have both variables set for this host. The field is omitted for a URL with no
260+
username, where the question does not arise.
261+
262+
Applies to `proxy_set_upstream`, `proxy_set_host_upstream` and
263+
`proxy_mobile_setup`. A URL that already carries a password is used as-is, so
264+
existing calls are unaffected. One credential covers all upstreams at the
265+
pinned host; a URL without a username is left alone.
266+
267+
> **Username-only credentials at the pinned host cannot be expressed.** A URL
268+
> with a username and no password is exactly the syntax that requests the
269+
> merge, and `user:@host` cannot signal otherwise — the URL parser erases the
270+
> empty password before the server sees it. If the pinned host authenticates on
271+
> the username alone, unset `PROXY_MCP_UPSTREAM_PASSWORD` for that server.
272+
273+
> **`socks*://` upstreams: no `:` in the password.** socks-proxy-agent splits
274+
> the credential on the first `:` and keeps only what follows, so `pa:ss` would
275+
> authenticate as `pa`. Rather than deliver half a password silently, a socks
276+
> upstream is **refused** with an error when `PROXY_MCP_UPSTREAM_PASSWORD`
277+
> contains `:`. The truncation itself is a toolchain limitation, not something
278+
> this introduces — a literal `socks5://user:pa%3Ass@host:1080` truncates the
279+
> same way, and nothing can guard that. `http://`, `https://` and `pac+http://`
280+
> upstreams take the whole password.
281+
282+
> **A `:` in the *username* is refused on every scheme.** Basic auth splits the
283+
> decoded pair at the first colon (RFC 7617), and socks-proxy-agent does the
284+
> same, so a username of `gro:ups` with password `s3cret` reaches the proxy as
285+
> user `gro`, password `ups:s3cret` — the merged password silently discarded.
286+
> No scheme can carry it, so the merge refuses rather than guess. Put the whole
287+
> credential in `proxy_url` instead.
288+
289+
Responses redact credentials — the password in userinfo, with path segments
290+
masked and the query and fragment dropped, since a `pac+http://` token may live
291+
in any of those:
292+
293+
```
294+
Global upstream set to http://user:***@upstream.example:1080/
295+
Global upstream set to pac+http://pac.example.com/***
296+
```
297+
298+
`proxy_status` and the `proxy://status` resource are redacted the same way. A
299+
PAC URL's filename is masked along with the rest of the path, so a confirmation
300+
message shows the host and nothing else.
301+
302+
**The username is not redacted.** For several providers it is configuration
303+
rather than a secret — Apify Proxy encodes proxy group, country and
304+
sticky-session id there — and showing it is what makes the confirmation useful.
305+
If your provider puts a secret in the username field, do not rely on these
306+
messages being safe to share.
307+
188308
Typical geo-routing examples:
189309

190310
```bash
@@ -368,6 +488,10 @@ proxy_mobile_setup \
368488

369489
Applies to BOTH listeners. Use `proxy_set_upstream` after the fact to change it without restarting.
370490

491+
As with `proxy_set_upstream`, omit the password and set
492+
`PROXY_MCP_UPSTREAM_PASSWORD` and `PROXY_MCP_UPSTREAM_HOST` in the server's
493+
environment to keep it out of the call.
494+
371495
### Verifying each step
372496

373497
| Check | Command | Expected |

src/interceptors/camoufox.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { join } from "node:path";
3030
import type {
3131
Interceptor, InterceptorMetadata, ActivateOptions, ActivateResult, ActiveTarget,
3232
} from "./types.js";
33+
import { spawnEnv } from "../utils.js";
3334

3435
type CamoufoxOs = "windows" | "macos" | "linux";
3536

@@ -189,14 +190,12 @@ export class CamoufoxInterceptor implements Interceptor {
189190
const scriptPath = join(launcherDir, "launch.py");
190191
await writeFile(scriptPath, buildLauncherScript(params, wsEndpointFile), "utf-8");
191192

193+
const childEnv = spawnEnv({ NO_COLOR: "1", PYTHONIOENCODING: "utf-8" });
194+
192195
const proc = spawn(pythonExe, [scriptPath], {
193196
stdio: ["ignore", "pipe", "pipe"],
194197
cwd: launcherDir,
195-
env: {
196-
...process.env,
197-
NO_COLOR: "1",
198-
PYTHONIOENCODING: "utf-8",
199-
},
198+
env: childEnv,
200199
});
201200

202201
let handshake: WsHandshake;

src/interceptors/terminal.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { spawn, type ChildProcess } from "node:child_process";
1010
import { writeCertTempFile } from "./cert-utils.js";
1111
import type { Interceptor, InterceptorMetadata, ActivateOptions, ActivateResult, ActiveTarget } from "./types.js";
12+
import { spawnEnv } from "../utils.js";
1213

1314
const MAX_OUTPUT_BUFFER = 8192;
1415

@@ -84,7 +85,7 @@ export class TerminalInterceptor implements Interceptor {
8485

8586
const child = spawn(command, args ?? [], {
8687
cwd,
87-
env: proxyEnv,
88+
env: spawnEnv(proxyEnv),
8889
stdio: ["ignore", "pipe", "pipe"],
8990
detached: false,
9091
});

src/state.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type * as mockttp from "mockttp";
1313
import type { CompletedRequest, CompletedResponse, ProxyConfig } from "mockttp";
1414
import { randomUUID } from "node:crypto";
1515
import { gunzipSync, inflateSync, brotliDecompressSync } from "node:zlib";
16-
import { serializeHeaders, capString } from "./utils.js";
16+
import { serializeHeaders, capString, redactProxyUrl } from "./utils.js";
1717
import { enableServerTlsCapture, type ServerTlsCapture } from "./tls-utils.js";
1818
import { spoofedRequest, shutdownSpoofContainer, stripHopByHopHeaders } from "./tls-spoof.js";
1919
import { applyFingerprintHeaderOverrides } from "./spoof-headers.js";
@@ -342,6 +342,11 @@ function rewriteReplayUrl(originalUrl: string, targetBaseUrl?: string): string {
342342
return new URL(`${original.pathname}${original.search}`, base).toString();
343343
}
344344

345+
/** Copy an upstream config with its proxy URL redacted, for status output. */
346+
function redactUpstreamConfig(config: UpstreamProxyConfig): UpstreamProxyConfig {
347+
return { ...config, proxyUrl: redactProxyUrl(config.proxyUrl) };
348+
}
349+
345350
// ── ProxyManager ──
346351

347352
let nextRuleId = 1;
@@ -502,8 +507,12 @@ export class ProxyManager {
502507
port: this.port,
503508
url: this.server?.url ?? null,
504509
certFingerprint: this.cert?.fingerprint ?? null,
505-
globalUpstream: this.globalUpstream,
506-
hostUpstreams: Object.fromEntries(this.hostUpstreams),
510+
// Redacted on the way out only: resolveProxyConfig() reads the stored
511+
// proxyUrl to actually route, so those must keep their real credentials.
512+
globalUpstream: this.globalUpstream && redactUpstreamConfig(this.globalUpstream),
513+
hostUpstreams: Object.fromEntries(
514+
[...this.hostUpstreams].map(([host, config]) => [host, redactUpstreamConfig(config)]),
515+
),
507516
ruleCount: this.rules.size,
508517
trafficCount: this.traffic.length,
509518
transparentProxy: this.getTransparentStatus(),

src/tools/mobile.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { join } from "node:path";
2424
import { randomBytes } from "node:crypto";
2525
import { proxyManager } from "../state.js";
2626
import { interceptorManager } from "../interceptors/manager.js";
27+
import { mergeUpstreamPassword, upstreamPasswordSource } from "../utils.js";
2728

2829
function errorToString(e: unknown): string {
2930
if (e instanceof Error) return e.message;
@@ -198,7 +199,7 @@ export function registerMobileTools(server: McpServer): void {
198199
explicit_port: z.number().optional().default(8080).describe("Port for the explicit HTTP proxy (default: 8080)."),
199200
transparent_port: z.number().optional().default(8443).describe("Port for the transparent HTTPS listener (default: 8443)."),
200201
block_quic: z.boolean().optional().default(true).describe("Drop UDP/443 on the AP iface so apps fall back to TCP/TLS (capturable). Default: true."),
201-
upstream_proxy_url: z.string().optional().describe("Optional upstream proxy URL (socks5://user:pass@host:port or http://...). Sets the global upstream for BOTH listeners."),
202+
upstream_proxy_url: z.string().optional().describe("Optional upstream proxy URL (socks5://user:pass@host:port or http://...). Sets the global upstream for BOTH listeners. If it has a username but no password, the password is filled in from PROXY_MCP_UPSTREAM_PASSWORD, but only when PROXY_MCP_UPSTREAM_HOST is also set and matches this URL's hostname. The response reports password_source: env | url | none."),
202203
android_serial: z.string().optional().describe("ADB serial of an Android device to inject the CA on. If omitted, no cert injection is attempted."),
203204
inject_cert: z.boolean().optional().default(true).describe("Inject the CA into the Android device's system store. Ignored if android_serial is omitted."),
204205
},
@@ -215,6 +216,18 @@ export function registerMobileTools(server: McpServer): void {
215216
inject_cert,
216217
}) => {
217218
try {
219+
// Validate and resolve the upstream before starting anything, so a
220+
// typo'd URL cannot leave both listeners running behind an error.
221+
if (upstream_proxy_url && !URL.canParse(upstream_proxy_url)) {
222+
throw new Error("upstream_proxy_url is not a parseable URL — check the scheme, e.g. socks5://host:1080");
223+
}
224+
let resolvedUpstream: string | undefined;
225+
let passwordSource: ReturnType<typeof upstreamPasswordSource> = null;
226+
if (upstream_proxy_url) {
227+
resolvedUpstream = mergeUpstreamPassword(upstream_proxy_url);
228+
passwordSource = upstreamPasswordSource(upstream_proxy_url, resolvedUpstream);
229+
}
230+
218231
// 1. Resolve AP iface.
219232
let apIface = ap_iface;
220233
let ifaceReason = "user-provided";
@@ -254,8 +267,8 @@ export function registerMobileTools(server: McpServer): void {
254267

255268
// 5. Upstream (optional).
256269
let upstreamSet = false;
257-
if (upstream_proxy_url) {
258-
await proxyManager.setGlobalUpstream({ proxyUrl: upstream_proxy_url });
270+
if (resolvedUpstream) {
271+
await proxyManager.setGlobalUpstream({ proxyUrl: resolvedUpstream });
259272
upstreamSet = true;
260273
}
261274

@@ -306,6 +319,7 @@ export function registerMobileTools(server: McpServer): void {
306319
transparent_port: transparentPortUsed,
307320
block_quic,
308321
upstream_set: upstreamSet,
322+
...(passwordSource ? { password_source: passwordSource } : {}),
309323
cert_injected: certInjected,
310324
android_target_id: androidTargetId,
311325
sudo_script: scriptPath,

src/tools/upstream.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,46 @@
55
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
66
import { z } from "zod";
77
import { proxyManager } from "../state.js";
8+
import { mergeUpstreamPassword, redactProxyUrl, upstreamPasswordSource } from "../utils.js";
9+
10+
/**
11+
* A proxy_url the code cannot parse must not report success: redaction removed
12+
* the echo that used to make a typo self-evident, and nothing downstream
13+
* validates it either. The value is not repeated back — it may hold a password.
14+
*/
15+
function unparseable() {
16+
return {
17+
content: [{
18+
type: "text" as const,
19+
text: JSON.stringify({
20+
status: "error",
21+
error: "proxy_url is not a parseable URL — check the scheme, e.g. socks5://host:1080",
22+
}),
23+
}],
24+
};
25+
}
826

927
export function registerUpstreamTools(server: McpServer): void {
1028
server.tool(
1129
"proxy_set_upstream",
1230
"Set a global upstream proxy for all outgoing traffic. Supports socks4://, socks5://, http://, https://, and pac+http:// URLs.",
1331
{
14-
proxy_url: z.string().describe("Upstream proxy URL (e.g., socks5://user:pass@host:port)"),
32+
proxy_url: z.string().describe("Upstream proxy URL (e.g., socks5://user:pass@host:port). If the URL has a username but no password, and the server has both PROXY_MCP_UPSTREAM_PASSWORD and PROXY_MCP_UPSTREAM_HOST set with the host matching this URL's hostname, the password is filled in from the environment so it need not appear in this call. Otherwise the URL is used as given; the response reports passwordSource: env | url | none."),
1533
no_proxy: z.array(z.string()).optional().describe("Hostnames to bypass the upstream proxy"),
1634
},
1735
async ({ proxy_url, no_proxy }) => {
1836
try {
19-
await proxyManager.setGlobalUpstream({ proxyUrl: proxy_url, noProxy: no_proxy });
37+
if (!URL.canParse(proxy_url)) return unparseable();
38+
const resolved = mergeUpstreamPassword(proxy_url);
39+
const passwordSource = upstreamPasswordSource(proxy_url, resolved);
40+
await proxyManager.setGlobalUpstream({ proxyUrl: resolved, noProxy: no_proxy });
2041
return {
2142
content: [{
2243
type: "text",
2344
text: JSON.stringify({
2445
status: "success",
25-
message: `Global upstream set to ${proxy_url}`,
46+
message: `Global upstream set to ${redactProxyUrl(resolved)}`,
47+
...(passwordSource ? { passwordSource } : {}),
2648
noProxy: no_proxy || [],
2749
}),
2850
}],
@@ -57,18 +79,22 @@ export function registerUpstreamTools(server: McpServer): void {
5779
"Set a per-host upstream proxy override. Traffic to this hostname will use the specified proxy instead of the global one.",
5880
{
5981
hostname: z.string().describe("Hostname to override (e.g., api.example.com)"),
60-
proxy_url: z.string().describe("Upstream proxy URL for this host"),
82+
proxy_url: z.string().describe("Upstream proxy URL for this host. If it has a username but no password, the password is filled in from PROXY_MCP_UPSTREAM_PASSWORD, but only when PROXY_MCP_UPSTREAM_HOST is also set and matches this URL's hostname. The response reports passwordSource: env | url | none."),
6183
no_proxy: z.array(z.string()).optional().describe("Hostnames to bypass this proxy"),
6284
},
6385
async ({ hostname, proxy_url, no_proxy }) => {
6486
try {
65-
await proxyManager.setHostUpstream(hostname, { proxyUrl: proxy_url, noProxy: no_proxy });
87+
if (!URL.canParse(proxy_url)) return unparseable();
88+
const resolved = mergeUpstreamPassword(proxy_url);
89+
const passwordSource = upstreamPasswordSource(proxy_url, resolved);
90+
await proxyManager.setHostUpstream(hostname, { proxyUrl: resolved, noProxy: no_proxy });
6691
return {
6792
content: [{
6893
type: "text",
6994
text: JSON.stringify({
7095
status: "success",
71-
message: `Upstream for '${hostname}' set to ${proxy_url}`,
96+
message: `Upstream for '${hostname}' set to ${redactProxyUrl(resolved)}`,
97+
...(passwordSource ? { passwordSource } : {}),
7298
}),
7399
}],
74100
};

0 commit comments

Comments
 (0)