fix: persist MCP OAuth tokens in sandboxes via file-backed keyring fallback (#3037)#3255
Conversation
…ixes docker#3037) In sandboxed environments the OS keyring is absent. The old code silently fell back to an in-memory store, losing OAuth tokens on every restart. Restrict openKeyring to OS-native backends so a missing keyring is a detectable error, then try a file-backed fallback before in-memory. Assisted-By: Claude
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
2 finding(s): 1 medium, 1 low — both in newly introduced code.
The three-tier fallback chain (OS-native → file-backed → in-memory) is well-structured and the injectable openers make the ordering verifiable in tests. The main concerns are around the security model of the file-backed keyring tier.
| // directory it lives in, which is the same boundary already protecting | ||
| // the encrypted token file beside it. It exists solely because the | ||
| // file backend requires a password function. | ||
| fallbackKeyringPassword = "docker-agent-oauth-fallback" |
There was a problem hiding this comment.
[MEDIUM] Hardcoded file-keyring password provides no additional protection over plaintext key storage
fallbackKeyringPassword = "docker-agent-oauth-fallback" is a well-known constant in public source code. The file-backed keyring uses it to "encrypt" the AES-256 key that seals mcp-oauth-tokens.enc. Since the password is public, any process that can read the mcp-oauth-keyring/ directory can trivially recover the AES key — meaning the token file's AES-256 encryption provides no defence beyond filesystem permissions.
The comment acknowledges this ("the fallback's only real boundary is the 0700 directory"), which is correct, but it also means the layered appearance of "keyring → encrypted token file" is somewhat misleading: if the 0700 boundary is ever relaxed (chmod race, misconfigured umask, root access, path traversal bug, or volume mount in a container context), the tokens are immediately fully exposed with no additional cost to an attacker.
Suggested improvement: Rather than a fixed password, derive the key from a per-machine or per-install secret (e.g. generate a random 32-byte value on first run and store it in a separate file, or use the machine-id as a salt). This wouldn't break the sandbox use-case and would ensure that knowing the source code alone is not sufficient to decrypt the keyring.
This is an intentional documented tradeoff, so whether to address it is a reviewer decision — just flagging it so the threat model is explicitly confirmed.
| } | ||
| return newKeyringTokenStore(ring, filepath.Join(paths.GetConfigDir(), tokenFileName)) | ||
| }) | ||
| return newKeyringTokenStore(ring, filepath.Join(configDir, tokenFileName)) |
There was a problem hiding this comment.
[LOW] No nil-ring guard after successful keyring.Open — potential nil pointer dereference
In buildDefaultStore, after openNative() or openFallback() returns (ring, nil), ring is passed directly to newKeyringTokenStore(ring, ...) without a nil check. The 99designs/keyring library normally returns an error if it cannot open a backend, but the interface contract doesn't strictly prohibit a (nil, nil) return. If that ever occurs (e.g., a future library version or a mock in a test that accidentally returns nil, nil), the store will be constructed with a nil keyring and panic on the first Get/Set call.
Suggested fix: add a guard after each Open call:
if ring == nil {
// treat same as an error — fall through to next tier
}This costs one line and makes the fallback chain robust against unexpected library behaviour.
Replace the hardcoded fallbackKeyringPassword constant with a randomly generated passphrase persisted to a 0600 file inside the keyring dir. Also guard buildDefaultStore against (nil, nil) opener returns. Assisted-By: Claude
|
Thanks for the review! Addressed both findings in 525ecab: [MEDIUM] Hardcoded file-keyring password — Replaced the public [LOW] Nil-ring guard — All tests, |
What
Fixes #3037 — remote MCP OAuth tokens cannot be stored when running inside a sandbox (
sbx), because no OS keyring is available there.Why it failed
keyring.Open()was called with no backend restriction. The genericfilebackend's opener always succeeds (it just constructs a struct), so in a sandboxopenKeyring()returned a half-broken file keyring with an emptyFileDirinstead of an error. The in-memory fallback was therefore never reached, and the first token write failed with the opaque"No directory provided for file keyring"error — so the agent failed to initialize after completing the OAuth flow.The fix
Backend resolution now follows a clear, layered order: OS-native keyring → encrypted file-backed keyring → in-memory.
openKeyring()to secure OS-native backends (wincred,keychain,secret-service,kwallet,keyctl) viaAllowedBackends. The genericfile/passbackends are excluded, so a missing OS keyring is now a detectableOpenerror we can act on instead of a deferred failure.openFileKeyring(dir)— a persistent file-backed fallback rooted in a0700directory under the config dir. This lets OAuth tokens survive sandbox restarts rather than being lost (or forcing a fresh login) every run, which is the "fallback storage mechanism" the issue asks for.(nil, nil)return from any opener is treated as a failure and falls through to the next tier, so the store can never be built around a nil keyring.Security model of the file-backed fallback
The file keyring is sealed with a per-install random passphrase, not a hardcoded constant: a random 32-byte secret is generated on first use and persisted to a
0600passphrasefile inside the keyring directory, then reused. This means reading the source code alone is not enough to decrypt the keyring — an attacker also needs read access to the install's passphrase file.The owner-only
0700directory remains the real security boundary (the same boundary already protecting the encrypted token file beside it). This tier is intentionally weaker than an OS keyring and is only used when no native store exists.Tests
secureBackendsexcludes the genericfile/passbackends.https://mcp.notion.com/sse).0600, reused across calls, and unique per install dir.buildDefaultStoreordering is exercised directly via injectable openers: native preferred, file fallback (the Remote MCP server OAuth flow fails insidesbx— no keyring directory available #3037 regression path), last-resort in-memory, and(nil, nil)rings falling through.All tests,
golangci-lint, and the custom linter pass cleanly.