Files
pad/internal/cli/request_path.go
T
xarmian 223c9734d8 fix(cli): a workspace or ref string cannot re-route a request, and PAD_TOKEN is not sent to a server only .pad.toml named (BUG-3645, BUG-3646) (#2127)
BUG-3645: workspace slugs and refs were concatenated raw into request
paths, so a .pad.toml workspace of "victim/#" turned `pad item delete
TASK-1` into DELETE /workspaces/victim/. Every dynamic segment in the
client builders now goes through url.PathEscape. Client.newRequest
refuses a path with a raw '#', an empty, "." or ".." segment (PathEscape
leaves dots alone), or a '/' after its '?', which catches any builder
that misses escaping, plus the raw-path methods' callers. A reflection
census calls every *Client method with a hostile string in each string
argument and asserts that the string stays escaped inside one segment.

BUG-3646: PAD_TOKEN was attached to whatever URL the client was given,
including a committed .pad.toml's url. It is now bound to the server
chosen before that override (--url, PAD_URL, the config or the default).
Requests to a server that only .pad.toml names use the credential saved
for it, and the CLI warns once. Setting PAD_URL to that server or passing
--url opts in. Every check of whether the env token is in play (whoami,
server info, init, login/logout, 2fa) goes through the same accessor.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-13 15:19:37 -04:00

44 lines
1.6 KiB
Go

package cli
import (
"fmt"
"strings"
)
// checkRequestPath refuses an API path that would route somewhere other than
// the segments it was built from (BUG-3645).
//
// Every builder escapes its dynamic segments with url.PathEscape, so a
// workspace or ref carrying '/', '?' or '#' stays inside its segment. This is
// the backstop for a builder that does not: the CLI would otherwise send an
// item delete for workspace "victim/#" to /workspaces/victim/ (the rest is a
// fragment, never sent), and the server deletes the workspace. It also covers
// what escaping cannot: PathEscape leaves "." and ".." unchanged, and a proxy
// or server that cleans the path then lands on a different route.
//
// Refused: a raw '#' anywhere; an empty, "." or ".." segment in the path; a
// raw '/' in the query, which no builder emits (query values go through
// url.Values.Encode or url.QueryEscape) and which is what a '?' smuggled into
// a segment leaves behind.
func checkRequestPath(path string) error {
if strings.ContainsRune(path, '#') {
return fmt.Errorf("refusing request: path %q contains '#'", path)
}
p, query, hasQuery := strings.Cut(path, "?")
if hasQuery && strings.ContainsRune(query, '/') {
return fmt.Errorf("refusing request: path %q has a '/' after its '?'", path)
}
if p == "" {
return nil
}
for _, seg := range strings.Split(strings.TrimPrefix(p, "/"), "/") {
switch seg {
case "":
return fmt.Errorf("refusing request: path %q has an empty segment", path)
case ".", "..":
return fmt.Errorf("refusing request: path %q has a %q segment", path, seg)
}
}
return nil
}