Skip to content

Commit 77aa802

Browse files
Dumbrisclaude
andcommitted
refactor(connect): undo takes a validated backup basename, path resolved server-side
CodeQL flagged path injection at the connect read() seam: the undo request's user-supplied backup_path flowed into os.ReadFile. Rather than sanitize a client-controlled path, stop trusting one entirely. The request now carries only backup_name — the bare filename of the backup the connect returned. Undo resolves the full path itself by joining it with THIS client's own config directory (derived from the client registry, never from the request), after rejecting anything whose filepath.Base differs from the input and requiring the strict "<config-basename>.bak." prefix. The user input can no longer contribute a directory component, so traversal is impossible by construction and the taint path breaks at the Base()==input guard + constant-dir join. - API: UndoConnectRequest.BackupPath -> BackupName (json backup_name); server resolves + validates; unknown basename still 404, path-shaped name -> 400. - Frontend: api.undoConnectClient sends filepath.Base (strips / and \) so both wizard and ConnectModal call sites emit a bare name; new connect-undo-api spec asserts the wire payload. - Docs + OpenAPI regenerated for backup_name. - Tests: absolute path rejected, separators-in-name rejected, unknown name 404, path-shaped name -> 400 at the HTTP boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 50e1c08 commit 77aa802

9 files changed

Lines changed: 225 additions & 76 deletions

File tree

docs/api/rest-api.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -711,14 +711,20 @@ would use.
711711
One-click undo of the immediately-preceding connect (Spec 078 US3). Body:
712712

713713
```json
714-
{ "server_name": "mcpproxy", "backup_path": "<backup_path from the connect result>" }
714+
{ "server_name": "mcpproxy", "backup_name": "<basename of backup_path from the connect result>" }
715715
```
716716

717-
- **`backup_path` set** — restores the config **byte-for-byte** from that
717+
`backup_name` is the **bare filename** of the backup the connect returned in
718+
`backup_path` — a name, never a path. The server resolves the full path itself
719+
inside that client's own config directory (derived from the client registry, not
720+
the request), so a caller-supplied value can never contribute a directory
721+
component and cannot escape the config dir (defense against path injection).
722+
723+
- **`backup_name` set** — restores the config **byte-for-byte** from that
718724
backup. This is the only revert that can bring back a pre-existing
719725
same-named entry that a `force=true` connect overwrote (surgical
720726
`DELETE /connect/{client}` cannot).
721-
- **`backup_path` empty** — the connect created the file (its result carried no
727+
- **`backup_name` empty** — the connect created the file (its result carried no
722728
`backup_path`); undo deletes the created file, restoring the "no file" state.
723729

724730
Safety semantics:
@@ -727,8 +733,9 @@ Safety semantics:
727733
connect (it verifies the current file is byte-identical to what that connect
728734
wrote) — it never clobbers later edits. Fall back to
729735
`DELETE /connect/{client}` for a surgical entry removal.
730-
- A vanished backup returns `404`; a backup path that is not a
731-
`<config>.bak.*` sibling of that client's config returns `400`.
736+
- A vanished backup returns `404`; a `backup_name` that is a path (contains a
737+
directory separator) or does not match `<config>.bak.*` for that client
738+
returns `400`.
732739
- Undo takes its **own safety backup** of the current file before restoring or
733740
deleting, returned as `backup_path` in the result
734741
(`action` = `restored` or `deleted`).

frontend/src/services/api.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1192,14 +1192,21 @@ class APIService {
11921192
// backupPath is the backup_path that connect returned (null = no prior file
11931193
// existed, so undo removes the file connect created). The backend refuses
11941194
// with 409 when the config changed since the connect (never clobbers edits).
1195+
//
1196+
// The wire payload carries only the backup's bare FILENAME (backup_name), not
1197+
// a path: the server resolves the full path inside the client's own config
1198+
// directory and never trusts a client-supplied path (defense against path
1199+
// injection). We strip any directory component here on both / and \ so a
1200+
// Windows path resolves the same way.
11951201
async undoConnectClient(
11961202
clientId: string,
11971203
serverName = 'mcpproxy',
11981204
backupPath: string | null = null
11991205
): Promise<APIResponse<ConnectResult>> {
1206+
const backupName = backupPath ? (backupPath.split(/[/\\]/).pop() ?? '') : ''
12001207
return this.request<ConnectResult>(`/api/v1/connect/${encodeURIComponent(clientId)}/undo`, {
12011208
method: 'POST',
1202-
body: JSON.stringify({ server_name: serverName, backup_path: backupPath ?? '' }),
1209+
body: JSON.stringify({ server_name: serverName, backup_name: backupName }),
12031210
})
12041211
}
12051212

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2+
3+
// Spec 078 US3 / CodeQL hardening: the undo request must carry only the backup's
4+
// bare FILENAME (backup_name), never a path. The server resolves the full path
5+
// inside the client's own config dir and never trusts a client-supplied path, so
6+
// api.undoConnectClient strips any directory component before sending.
7+
8+
describe('api.undoConnectClient — sends a bare backup_name (not a path)', () => {
9+
let fetchMock: ReturnType<typeof vi.fn>
10+
11+
beforeEach(() => {
12+
fetchMock = vi.fn().mockResolvedValue({
13+
ok: true,
14+
status: 200,
15+
json: async () => ({ success: true, data: { success: true, action: 'restored' } }),
16+
})
17+
vi.stubGlobal('fetch', fetchMock)
18+
})
19+
20+
afterEach(() => {
21+
vi.unstubAllGlobals()
22+
vi.resetModules()
23+
})
24+
25+
async function freshApi() {
26+
vi.resetModules()
27+
const mod = await import('@/services/api')
28+
return mod.default
29+
}
30+
31+
it('POSTs the basename of a POSIX backup path', async () => {
32+
const api = await freshApi()
33+
await api.undoConnectClient('cursor', 'mcpproxy', '/Users/test/.cursor/mcp.json.bak.20260703-101530')
34+
35+
const [url, opts] = fetchMock.mock.calls[0]
36+
expect(url).toBe('/api/v1/connect/cursor/undo')
37+
expect(opts.method).toBe('POST')
38+
expect(JSON.parse(opts.body)).toEqual({
39+
server_name: 'mcpproxy',
40+
backup_name: 'mcp.json.bak.20260703-101530',
41+
})
42+
})
43+
44+
it('strips a Windows backslash directory component too', async () => {
45+
const api = await freshApi()
46+
await api.undoConnectClient(
47+
'claude-desktop',
48+
'mcpproxy',
49+
'C:\\Users\\test\\AppData\\claude_desktop_config.json.bak.20260703-101530'
50+
)
51+
const [, opts] = fetchMock.mock.calls[0]
52+
expect(JSON.parse(opts.body).backup_name).toBe('claude_desktop_config.json.bak.20260703-101530')
53+
})
54+
55+
it('sends an empty backup_name for the no-prior-file (null) case', async () => {
56+
const api = await freshApi()
57+
await api.undoConnectClient('cursor', 'mcpproxy', null)
58+
const [, opts] = fetchMock.mock.calls[0]
59+
expect(JSON.parse(opts.body)).toEqual({ server_name: 'mcpproxy', backup_name: '' })
60+
})
61+
62+
it('percent-encodes a "/"-containing client id', async () => {
63+
const api = await freshApi()
64+
await api.undoConnectClient('weird/client', 'mcpproxy', null)
65+
const [url] = fetchMock.mock.calls[0]
66+
expect(url).toBe('/api/v1/connect/weird%2Fclient/undo')
67+
})
68+
})

internal/connect/undo.go

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import (
1010
"github.com/BurntSushi/toml"
1111
)
1212

13-
// Undo reverts the connect that produced backupPath, restoring the client
14-
// config to its exact pre-connect state (Spec 078 US3 / FR-008):
13+
// Undo reverts the connect that produced the named backup, restoring the client
14+
// config to its exact pre-connect state (Spec 078 US3 / FR-008). backupName is
15+
// the bare filename (filepath.Base) of the backup the connect returned, NOT a
16+
// path — undo resolves the full path itself against the client's own config
17+
// directory (see the resolution/validation below):
1518
//
16-
// - backupPath != "": the config is restored byte-for-byte from that backup —
19+
// - backupName != "": the config is restored byte-for-byte from that backup —
1720
// this is the only revert that can bring back a pre-existing same-named
1821
// entry a force-connect overwrote (surgical disconnect cannot).
19-
// - backupPath == "": the preceding connect created the file (no prior file
22+
// - backupName == "": the preceding connect created the file (no prior file
2023
// existed, ConnectResult.backup_path was empty); undo deletes the file so
2124
// the pre-connect "no file" state is restored.
2225
//
@@ -35,7 +38,7 @@ import (
3538
// address / API key / require_mcp_auth changed since the connect: the entry the
3639
// service would write today no longer matches the one on disk, so mcpproxy can
3740
// no longer prove the file is untouched.
38-
func (s *Service) Undo(clientID, serverName, backupPath string) (*ConnectResult, error) {
41+
func (s *Service) Undo(clientID, serverName, backupName string) (*ConnectResult, error) {
3942
client := FindClient(clientID)
4043
if client == nil {
4144
return nil, fmt.Errorf("unknown client: %s", clientID)
@@ -51,18 +54,24 @@ func (s *Service) Undo(clientID, serverName, backupPath string) (*ConnectResult,
5154
return nil, fmt.Errorf("cannot determine config path for %s", clientID)
5255
}
5356

54-
// The backup must be a real backup OF THIS client's config, living beside it:
55-
// undo must not become an arbitrary-file-restore primitive for API callers.
56-
// A prefix-only check is not enough — a path like "<cfg>.bak.x/../../secret"
57-
// keeps the prefix yet escapes the directory, so anchor on the cleaned path's
58-
// parent directory and basename (both traversal-proof).
59-
if backupPath != "" {
60-
cleaned := filepath.Clean(backupPath)
61-
if filepath.Dir(cleaned) != filepath.Dir(cfgPath) ||
62-
!strings.HasPrefix(filepath.Base(cleaned), filepath.Base(cfgPath)+".bak.") {
63-
return nil, fmt.Errorf("invalid backup path %q: not a backup of %s", backupPath, cfgPath)
57+
// Resolve the backup path entirely server-side: the request carries only a
58+
// bare FILENAME (backupName), never a path. Undo joins it with THIS client's
59+
// own config directory — derived from the client registry, never from the
60+
// request — so a client-supplied value can never contribute a directory
61+
// component. That makes traversal impossible by construction (undo must not
62+
// become an arbitrary-file-restore primitive): we reject anything whose
63+
// filepath.Base is not identical to the input, and require the strict
64+
// "<config-basename>.bak." prefix the connect write produced.
65+
backupPath := ""
66+
if backupName != "" {
67+
base := filepath.Base(backupName)
68+
if base != backupName {
69+
return nil, fmt.Errorf("invalid backup name %q: must be a bare filename, not a path", backupName)
70+
}
71+
if !strings.HasPrefix(base, filepath.Base(cfgPath)+".bak.") {
72+
return nil, fmt.Errorf("invalid backup name %q: not a backup of %s", backupName, filepath.Base(cfgPath))
6473
}
65-
backupPath = cleaned
74+
backupPath = filepath.Join(filepath.Dir(cfgPath), base)
6675
}
6776

6877
res, err := s.undo(client, cfgPath, serverName, backupPath)

internal/connect/undo_test.go

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestUndo_RestoresByteIdenticalPreConnectFile(t *testing.T) {
4646
t.Fatalf("connect result unexpected: %+v", res)
4747
}
4848

49-
undo, err := svc.Undo("claude-code", "mcpproxy", res.BackupPath)
49+
undo, err := svc.Undo("claude-code", "mcpproxy", filepath.Base(res.BackupPath))
5050
if err != nil {
5151
t.Fatalf("undo: %v", err)
5252
}
@@ -89,7 +89,7 @@ func TestUndo_TOML_RestoresByteIdentical(t *testing.T) {
8989
t.Fatalf("connect result unexpected: %+v", res)
9090
}
9191

92-
undo, err := svc.Undo("codex", "mcpproxy", res.BackupPath)
92+
undo, err := svc.Undo("codex", "mcpproxy", filepath.Base(res.BackupPath))
9393
if err != nil {
9494
t.Fatalf("undo: %v", err)
9595
}
@@ -124,7 +124,7 @@ func TestUndo_RefusesWhenFileDriftedSinceConnect(t *testing.T) {
124124
t.Fatal(err)
125125
}
126126

127-
undo, err := svc.Undo("claude-code", "mcpproxy", res.BackupPath)
127+
undo, err := svc.Undo("claude-code", "mcpproxy", filepath.Base(res.BackupPath))
128128
if err != nil {
129129
t.Fatalf("undo returned hard error, want refusal result: %v", err)
130130
}
@@ -214,7 +214,9 @@ func TestUndo_BackupMissingReturnsNotFound(t *testing.T) {
214214
t.Fatalf("connect: %v", err)
215215
}
216216
cfgPath := res.ConfigPath
217-
missing := cfgPath + ".bak.19990101-000000"
217+
// A well-formed but non-existent backup NAME (bare filename, correct prefix)
218+
// must resolve to not_found, not a hard error.
219+
missing := filepath.Base(cfgPath) + ".bak.19990101-000000"
218220

219221
undo, err := svc.Undo("claude-code", "mcpproxy", missing)
220222
if err != nil {
@@ -225,41 +227,44 @@ func TestUndo_BackupMissingReturnsNotFound(t *testing.T) {
225227
}
226228
}
227229

228-
func TestUndo_RejectsForeignBackupPath(t *testing.T) {
230+
// The request carries a bare backup FILENAME; an absolute path must be rejected
231+
// outright — undo must not become an arbitrary-file-restore primitive, and the
232+
// directory is always the client's own config dir, never the caller's.
233+
func TestUndo_RejectsAbsoluteBackupPath(t *testing.T) {
229234
home := t.TempDir()
230235
svc := NewServiceWithHome("127.0.0.1:8080", "", home)
231236
if _, err := svc.Connect("claude-code", "mcpproxy", false); err != nil {
232237
t.Fatalf("connect: %v", err)
233238
}
234239

235-
// A path that is not a backup of THIS client's config must be rejected —
236-
// the endpoint must not become an arbitrary-file-restore primitive.
240+
// An absolute path is not a bare filename (filepath.Base != input).
237241
foreign := filepath.Join(home, "evil.json")
238242
if err := os.WriteFile(foreign, []byte(`{}`), 0o644); err != nil {
239243
t.Fatal(err)
240244
}
241245
_, err := svc.Undo("claude-code", "mcpproxy", foreign)
242246
if err == nil {
243-
t.Fatal("undo must reject a backup path outside <config>.bak.*")
247+
t.Fatal("undo must reject an absolute backup path")
244248
}
245249
if !strings.Contains(err.Error(), "backup") {
246-
t.Fatalf("error should mention the backup path problem: %v", err)
250+
t.Fatalf("error should mention the backup name problem: %v", err)
247251
}
248252
}
249253

250-
// A path that keeps the "<config>.bak." prefix but traverses out of the config
251-
// directory (e.g. "<config>.bak.x/../../secret.json") must be rejected before
252-
// any read — a prefix-only check would let undo read/restore an arbitrary file.
253-
func TestUndo_RejectsBackupPathTraversal(t *testing.T) {
254+
// A backup name that carries any directory separator or traversal component
255+
// (e.g. "<config>.bak.x/../../secret.json") must be rejected before any read:
256+
// resolution joins the name with the client's own config dir, so only a bare
257+
// filename is ever admitted (traversal impossible by construction).
258+
func TestUndo_RejectsBackupNameWithSeparators(t *testing.T) {
254259
home := t.TempDir()
255260
svc := NewServiceWithHome("127.0.0.1:8080", "", home)
256261
res, err := svc.Connect("claude-code", "mcpproxy", false)
257262
if err != nil {
258263
t.Fatalf("connect: %v", err)
259264
}
260265

261-
// Craft a traversal path that still starts with "<config>.bak." so a
262-
// prefix-only guard would admit it, then climbs back out to a foreign file.
266+
// Keep the "<config>.bak." prefix but climb out via separators — a prefix-
267+
// only guard would admit it; the bare-filename guard must not.
263268
secret := filepath.Join(home, "secret.json")
264269
if err := os.WriteFile(secret, []byte(`{"secret":true}`), 0o644); err != nil {
265270
t.Fatal(err)
@@ -268,14 +273,14 @@ func TestUndo_RejectsBackupPathTraversal(t *testing.T) {
268273
if err != nil {
269274
t.Fatal(err)
270275
}
271-
traversal := res.ConfigPath + ".bak.x/../" + rel
276+
traversal := filepath.Base(res.ConfigPath) + ".bak.x/../" + rel
272277

273278
_, err = svc.Undo("claude-code", "mcpproxy", traversal)
274279
if err == nil {
275-
t.Fatal("undo must reject a traversal backup path that escapes the config directory")
280+
t.Fatal("undo must reject a backup name that contains path separators")
276281
}
277282
if !strings.Contains(err.Error(), "backup") {
278-
t.Fatalf("error should mention the backup path problem: %v", err)
283+
t.Fatalf("error should mention the backup name problem: %v", err)
279284
}
280285
}
281286

@@ -290,7 +295,8 @@ func TestUndo_MissingConfigFileRefuses(t *testing.T) {
290295
t.Fatal(err)
291296
}
292297

293-
undo, err := svc.Undo("claude-code", "mcpproxy", res.BackupPath)
298+
// No prior file existed, so the connect returned an empty backup name.
299+
undo, err := svc.Undo("claude-code", "mcpproxy", "")
294300
if err != nil {
295301
t.Fatalf("undo: %v", err)
296302
}

internal/httpapi/connect.go

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -258,30 +258,36 @@ func (s *Server) handleDisconnectClient(w http.ResponseWriter, r *http.Request)
258258
// UndoConnectRequest is the JSON body for POST /api/v1/connect/{client}/undo.
259259
type UndoConnectRequest struct {
260260
ServerName string `json:"server_name,omitempty"` // Defaults to "mcpproxy"
261-
// BackupPath is the backup_path returned by the preceding connect for this
262-
// client. Empty means the connect created the file (no prior file existed),
263-
// so undo removes the created file.
264-
BackupPath string `json:"backup_path,omitempty"`
261+
// BackupName is the bare filename (filepath.Base) of the backup returned as
262+
// backup_path by the preceding connect — a name, never a path. Undo resolves
263+
// the full path server-side by joining it with the client's own config
264+
// directory, so a client-supplied value can never contribute a directory
265+
// component (traversal is impossible by construction). Empty means the
266+
// connect created the file (no prior file existed), so undo removes it.
267+
BackupName string `json:"backup_name,omitempty"`
265268
}
266269

267270
// handleUndoConnectClient godoc
268271
// @Summary Undo a connect, restoring the pre-connect config
269-
// @Description Reverts the connect that produced the given backup_path (Spec 078 US3):
272+
// @Description Reverts the connect that produced the named backup (Spec 078 US3):
270273
// @Description restores the client config byte-for-byte from that backup, or — when
271-
// @Description backup_path is empty because the connect created the file — deletes the
272-
// @Description created file. Refuses with 409 when the config changed since the connect
273-
// @Description (undo never clobbers later edits; use DELETE /connect/{client} for a
274-
// @Description surgical entry removal instead). Takes its own safety backup first;
275-
// @Description its path is returned as backup_path in the result.
274+
// @Description backup_name is empty because the connect created the file — deletes the
275+
// @Description created file. backup_name is the bare filename of the backup the connect
276+
// @Description returned (never a path); undo resolves the full path server-side inside
277+
// @Description the client's own config directory, so a client value cannot escape it.
278+
// @Description Refuses with 409 when the config changed since the connect (undo never
279+
// @Description clobbers later edits; use DELETE /connect/{client} for a surgical entry
280+
// @Description removal instead). Takes its own safety backup first; its path is returned
281+
// @Description as backup_path in the result.
276282
// @Tags connect
277283
// @Accept json
278284
// @Produce json
279285
// @Security ApiKeyAuth
280286
// @Security ApiKeyQuery
281287
// @Param client path string true "Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)"
282-
// @Param body body UndoConnectRequest false "Undo parameters (server_name, backup_path from the preceding connect)"
288+
// @Param body body UndoConnectRequest false "Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"
283289
// @Success 200 {object} contracts.APIResponse "ConnectResult (action restored|deleted)"
284-
// @Failure 400 {object} contracts.ErrorResponse "Bad request (e.g. backup path not a backup of this client's config)"
290+
// @Failure 400 {object} contracts.ErrorResponse "Bad request (e.g. backup_name is a path, or not a backup of this client's config)"
285291
// @Failure 403 {object} contracts.ErrorResponse "Permission denied (macOS App-Data block)"
286292
// @Failure 404 {object} contracts.ErrorResponse "Unknown client or backup no longer exists"
287293
// @Failure 409 {object} contracts.ErrorResponse "Config changed since connect; undo refused"
@@ -308,7 +314,7 @@ func (s *Server) handleUndoConnectClient(w http.ResponseWriter, r *http.Request)
308314
}
309315
}
310316

311-
result, err := svc.Undo(clientID, req.ServerName, req.BackupPath)
317+
result, err := svc.Undo(clientID, req.ServerName, req.BackupName)
312318
if err != nil {
313319
if s.writeIfAccessDenied(w, r, err) {
314320
return

0 commit comments

Comments
 (0)