Defense-in-depth follow-up to the review on #518. That PR closed the reachable vector (forge mcp login now validates the server name before it reaches the store), so this is not a reachable bug — it's hardening the credential store so it stays safe regardless of what any future caller passes.
Context
The plaintext credential helpers in forge-core/llm/oauth/store.go build the file path by joining a caller-supplied provider/key directly:
recordPath(key) / saveRecordPlaintext(key, …) / removeRecordFile(key) → filepath.Join(dir, key+".json")
savePlaintext(provider, …) / loadPlaintext(provider) / removePlaintextFile(provider) → filepath.Join(dir, provider+".json")
And forge-core/mcp/oauth_flow.go's storeKey(name) just prefixes mcp_ (which doesn't neutralize ..). None of these sanitize the key, so a caller that passes an unsanitized key with path separators or .. could read/write/delete a *.json file outside ~/.forge/credentials. Today the only callers (LLM provider OAuth, MCP login via #518's validated name) supply safe keys, but the store layer shouldn't depend on every caller getting that right.
Proposal
Make the store self-protecting at its boundary:
- Route every plaintext helper through a single
credentialFilePath(key) (string, error) that:
- rejects keys containing
/, \, or .. (or restricts to a safe charset), and
- defensively verifies the resolved,
filepath.Clean-ed path stays within DefaultCredentialsDir() (prefix check) before any read/write/delete.
- Apply it in
recordPath, savePlaintext, loadPlaintext, removePlaintextFile, saveRecordPlaintext, removeRecordFile.
- (Optional) validate the key in
SaveCredentials/LoadCredentials/SaveRecord/DeleteRecord too, so callers get a clear error rather than a silently-clamped path.
The encrypted store (secrets.enc) keys aren't filesystem paths, so traversal doesn't apply there; a key-charset check is still nice-to-have for consistency but lower priority.
Acceptance criteria
Touch points
forge-core/llm/oauth/store.go (path builders + optional key validation in the public helpers)
forge-core/mcp/oauth_flow.go (storeKey — optional)
Relationship
Complements #518 (which validated the CLI-supplied name). No dependency; independently mergeable.
Defense-in-depth follow-up to the review on #518. That PR closed the reachable vector (
forge mcp loginnow validates the server name before it reaches the store), so this is not a reachable bug — it's hardening the credential store so it stays safe regardless of what any future caller passes.Context
The plaintext credential helpers in
forge-core/llm/oauth/store.gobuild the file path by joining a caller-suppliedprovider/keydirectly:recordPath(key)/saveRecordPlaintext(key, …)/removeRecordFile(key)→filepath.Join(dir, key+".json")savePlaintext(provider, …)/loadPlaintext(provider)/removePlaintextFile(provider)→filepath.Join(dir, provider+".json")And
forge-core/mcp/oauth_flow.go'sstoreKey(name)just prefixesmcp_(which doesn't neutralize..). None of these sanitize the key, so a caller that passes an unsanitized key with path separators or..could read/write/delete a*.jsonfile outside~/.forge/credentials. Today the only callers (LLM provider OAuth, MCP login via #518's validated name) supply safe keys, but the store layer shouldn't depend on every caller getting that right.Proposal
Make the store self-protecting at its boundary:
credentialFilePath(key) (string, error)that:/,\, or..(or restricts to a safe charset), andfilepath.Clean-ed path stays withinDefaultCredentialsDir()(prefix check) before any read/write/delete.recordPath,savePlaintext,loadPlaintext,removePlaintextFile,saveRecordPlaintext,removeRecordFile.SaveCredentials/LoadCredentials/SaveRecord/DeleteRecordtoo, so callers get a clear error rather than a silently-clamped path.The encrypted store (
secrets.enc) keys aren't filesystem paths, so traversal doesn't apply there; a key-charset check is still nice-to-have for consistency but lower priority.Acceptance criteria
/,\, or..is rejected by the plaintext Save/Load/Delete helpers (tokens and records).~/.forge/credentials.mcp_<name>tokens) round-trip unchanged.Touch points
forge-core/llm/oauth/store.go(path builders + optional key validation in the public helpers)forge-core/mcp/oauth_flow.go(storeKey— optional)Relationship
Complements #518 (which validated the CLI-supplied name). No dependency; independently mergeable.