Skip to content

Commit 68c2559

Browse files
committed
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.
1 parent e35bbba commit 68c2559

5 files changed

Lines changed: 90 additions & 8 deletions

File tree

README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,15 +189,38 @@ Supported upstream URL schemes: `socks4://`, `socks5://`, `http://`, `https://`,
189189

190190
Tool calls and tool results are both persisted by the MCP client. To avoid
191191
writing an upstream password there on every call, set it in the server's
192-
environment and pass a URL with a username but no password:
192+
environment and pass a URL with a username but no password.
193+
194+
The variable has to be in the environment of the **server process**, which the
195+
MCP client spawns — exporting it in your own shell does not reach it:
193196

194197
```bash
195-
export PROXY_MCP_UPSTREAM_PASSWORD="s3cret"
198+
claude mcp add proxy-mcp -e PROXY_MCP_UPSTREAM_PASSWORD=s3cret -- npx -y proxy-mcp@latest
199+
```
196200

201+
```json
202+
{
203+
"mcpServers": {
204+
"proxy-mcp": {
205+
"command": "npx",
206+
"args": ["-y", "proxy-mcp@latest"],
207+
"env": { "PROXY_MCP_UPSTREAM_PASSWORD": "s3cret" }
208+
}
209+
}
210+
}
211+
```
212+
213+
Then omit the password from the call:
214+
215+
```bash
197216
proxy_set_upstream --proxy_url "http://user@upstream.example:1080"
198217
# routes as http://user:s3cret@upstream.example:1080
199218
```
200219

220+
The response reports which credential was used — `passwordSource` is `env`,
221+
`url` or `none`. `none` on a URL you expected the variable to complete means the
222+
server does not have it in its environment.
223+
201224
Applies to `proxy_set_upstream`, `proxy_set_host_upstream` and
202225
`proxy_mobile_setup`. A URL that already carries a password is used as-is, so
203226
existing calls are unaffected. One credential covers all upstreams; a URL
@@ -410,7 +433,8 @@ proxy_mobile_setup \
410433
Applies to BOTH listeners. Use `proxy_set_upstream` after the fact to change it without restarting.
411434

412435
As with `proxy_set_upstream`, omit the password and set
413-
`PROXY_MCP_UPSTREAM_PASSWORD` in the environment to keep it out of the call.
436+
`PROXY_MCP_UPSTREAM_PASSWORD` in the server's environment to keep it out of the
437+
call.
414438

415439
### Verifying each step
416440

src/tools/mobile.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +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 } from "../utils.js";
27+
import { mergeUpstreamPassword, upstreamPasswordSource } from "../utils.js";
2828

2929
function errorToString(e: unknown): string {
3030
if (e instanceof Error) return e.message;
@@ -313,6 +313,9 @@ export function registerMobileTools(server: McpServer): void {
313313
transparent_port: transparentPortUsed,
314314
block_quic,
315315
upstream_set: upstreamSet,
316+
...(upstream_proxy_url && resolvedUpstream
317+
? { password_source: upstreamPasswordSource(upstream_proxy_url, resolvedUpstream) }
318+
: {}),
316319
cert_injected: certInjected,
317320
android_target_id: androidTargetId,
318321
sudo_script: scriptPath,

src/tools/upstream.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
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 } from "../utils.js";
8+
import { mergeUpstreamPassword, redactProxyUrl, upstreamPasswordSource } from "../utils.js";
99

1010
export function registerUpstreamTools(server: McpServer): void {
1111
server.tool(
@@ -25,6 +25,7 @@ export function registerUpstreamTools(server: McpServer): void {
2525
text: JSON.stringify({
2626
status: "success",
2727
message: `Global upstream set to ${redactProxyUrl(resolved)}`,
28+
passwordSource: upstreamPasswordSource(proxy_url, resolved),
2829
noProxy: no_proxy || [],
2930
}),
3031
}],
@@ -72,6 +73,7 @@ export function registerUpstreamTools(server: McpServer): void {
7273
text: JSON.stringify({
7374
status: "success",
7475
message: `Upstream for '${hostname}' set to ${redactProxyUrl(resolved)}`,
76+
passwordSource: upstreamPasswordSource(proxy_url, resolved),
7577
}),
7678
}],
7779
};

src/utils.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export function redactProxyUrl(proxyUrl: string): string {
124124
*
125125
* The value can only ever land in the password slot, which is the one field
126126
* redactProxyUrl() masks — a substituted secret cannot be echoed back through
127-
* the username, host, path or query. URL serialization percent-encodes it, so a
127+
* the username, host, path or query. The value is percent-encoded, so a
128128
* password containing "@" or "/" cannot re-point the upstream at another host.
129129
*
130130
* A URL with no username, or one that already carries a password, is returned
@@ -145,6 +145,32 @@ export function mergeUpstreamPassword(
145145
}
146146
if (!url.username || url.password) return proxyUrl;
147147

148-
url.password = password;
148+
// encodeURIComponent, not the raw value: the password setter escapes "@" and
149+
// "/" but leaves "%" alone, and mockttp reads the credential back through
150+
// url.parse().auth, which decodeURIComponent()s it. A raw "%" therefore makes
151+
// that decode throw, and a raw "%20" silently decodes to a space. Encoding
152+
// first is lossless — the setter escapes nothing encodeURIComponent leaves.
153+
url.password = encodeURIComponent(password);
149154
return url.toString();
150155
}
156+
157+
/**
158+
* Which credential a resolved upstream URL ended up using, for the tool
159+
* response.
160+
*
161+
* A caller that omits the password when the server has no
162+
* PROXY_MCP_UPSTREAM_PASSWORD otherwise gets a plain "success" and finds out
163+
* only later, as unexplained 407s on unrelated requests. "none" says so at the
164+
* call that can still be corrected.
165+
*/
166+
export function upstreamPasswordSource(
167+
original: string,
168+
resolved: string,
169+
): "env" | "url" | "none" {
170+
if (resolved !== original) return "env";
171+
try {
172+
return new URL(original).password ? "url" : "none";
173+
} catch {
174+
return "none";
175+
}
176+
}

test/unit/utils.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it } from "node:test";
22
import assert from "node:assert/strict";
33
import { parse } from "node:url";
4-
import { truncateResult, getLocalIP, serializeHeaders, capString, redactProxyUrl, mergeUpstreamPassword } from "../../src/utils.js";
4+
import { truncateResult, getLocalIP, serializeHeaders, capString, redactProxyUrl, mergeUpstreamPassword, upstreamPasswordSource } from "../../src/utils.js";
55

66
describe("truncateResult", () => {
77
it("returns short data unchanged", () => {
@@ -158,6 +158,18 @@ describe("mergeUpstreamPassword", () => {
158158
assert.equal(new URL(out).host, "host:8000");
159159
});
160160

161+
it("survives the round-trip with a '%' in the password", () => {
162+
// The password setter escapes "@" and "/" but not "%", while url.parse()
163+
// decodeURIComponent()s the auth: a raw "%" made that decode throw, and a
164+
// raw "%20" decoded to a space. Both must come back byte-identical.
165+
for (const secret of ["100%pass", "p%20ss", "%"]) {
166+
const out = mergeUpstreamPassword("http://user@host:8000", {
167+
PROXY_MCP_UPSTREAM_PASSWORD: secret,
168+
});
169+
assert.equal(parse(out).auth, `user:${secret}`);
170+
}
171+
});
172+
161173
it("reaches https-proxy-agent intact, including a ':' in the password", () => {
162174
// https-proxy-agent takes the whole auth string, so ":" is safe here.
163175
const secret = "pa:ss/word";
@@ -206,3 +218,18 @@ describe("mergeUpstreamPassword", () => {
206218
assert.equal(mergeUpstreamPassword("not a url", env), "not a url");
207219
});
208220
});
221+
222+
describe("upstreamPasswordSource", () => {
223+
const env = { PROXY_MCP_UPSTREAM_PASSWORD: "s3cret" };
224+
const source = (url: string, e: NodeJS.ProcessEnv) =>
225+
upstreamPasswordSource(url, mergeUpstreamPassword(url, e));
226+
227+
it("reports where the password came from", () => {
228+
assert.equal(source("http://user@host:8000", env), "env");
229+
assert.equal(source("http://user:mine@host:8000", env), "url");
230+
// The case worth reporting: caller omitted it and the server has no
231+
// variable, so the upstream is about to be used unauthenticated.
232+
assert.equal(source("http://user@host:8000", {}), "none");
233+
assert.equal(source("http://host:8000", env), "none");
234+
});
235+
});

0 commit comments

Comments
 (0)