mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
223c9734d8
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
93 lines
3.8 KiB
Go
93 lines
3.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// EnvToken returns the value of the PAD_TOKEN environment variable,
|
|
// trimmed of surrounding whitespace. When non-empty, it overrides any
|
|
// credential stored in ~/.pad/credentials.json — the same convention as
|
|
// gh's GH_TOKEN (issue #879, layer 1).
|
|
//
|
|
// Why an env override at all: the credential store is per-server, not
|
|
// per-process, so several agent processes on one machine cannot act as
|
|
// different Pad users without contending over the file. Reads never
|
|
// write credentials.json (only login/setup/logout do), so a read-only
|
|
// override sidesteps the identity-switching contention completely —
|
|
// each process carries its own token in its environment and the store
|
|
// is never touched.
|
|
//
|
|
// Accepts either token type the server takes (padsess_ session or
|
|
// pad_ API token); API tokens are the intended fit (mint under
|
|
// Settings → API tokens in the web UI).
|
|
//
|
|
// Lifecycle asymmetry, deliberate: `pad auth logout` never invalidates
|
|
// the env token's own server-side session — it pins its Logout() call
|
|
// to the STORED token and only deletes the stored entry. The env
|
|
// token's lifecycle belongs to whoever minted it (revoke it where it
|
|
// was minted), exactly gh's GH_TOKEN posture. This is the read-only
|
|
// contract applied to logout, not an oversight.
|
|
func EnvToken() string {
|
|
return strings.TrimSpace(os.Getenv("PAD_TOKEN"))
|
|
}
|
|
|
|
// PAD_TOKEN is bound to the server the user chose (BUG-3646): PAD_URL, --url,
|
|
// the user config, or the default. A repository's .pad.toml can replace that
|
|
// URL (BUG-1535), and anyone can commit a .pad.toml, so a checkout must not be
|
|
// able to redirect the exported token to a server of its choosing. When the
|
|
// override names a different server, EnvTokenFor withholds PAD_TOKEN from it
|
|
// and the client falls back to the credential saved for that server, as if
|
|
// PAD_TOKEN were unset. Setting PAD_URL to that server, or passing --url,
|
|
// makes it the chosen server and the token is sent.
|
|
var (
|
|
padTomlOverrideMu sync.Mutex
|
|
padTomlChosenURL string // the destination before .pad.toml replaced it
|
|
padTomlURL string // the destination .pad.toml replaced it with
|
|
envTokenWarnOnce sync.Once
|
|
// envTokenWarnOut is where the one-line withheld notice goes; a var so
|
|
// tests can capture it.
|
|
envTokenWarnOut io.Writer = os.Stderr
|
|
)
|
|
|
|
// NotePadTomlURLOverride records that .pad.toml replaced the chosen server
|
|
// URL with another. Called with two empty strings when no override applies,
|
|
// so a later call in one process never inherits an earlier one's state.
|
|
func NotePadTomlURLOverride(chosen, override string) {
|
|
padTomlOverrideMu.Lock()
|
|
defer padTomlOverrideMu.Unlock()
|
|
padTomlChosenURL, padTomlURL = chosen, override
|
|
}
|
|
|
|
// EnvTokenFor returns PAD_TOKEN when it may be sent to baseURL, and "" when
|
|
// baseURL is a server only .pad.toml chose (see NotePadTomlURLOverride).
|
|
// Every decision about whether the env token is in play goes through here, so
|
|
// no command reports the token as in use where it is withheld.
|
|
func EnvTokenFor(baseURL string) string {
|
|
tok := EnvToken()
|
|
if tok == "" {
|
|
return ""
|
|
}
|
|
padTomlOverrideMu.Lock()
|
|
chosen, override := padTomlChosenURL, padTomlURL
|
|
padTomlOverrideMu.Unlock()
|
|
if override == "" || !sameServer(baseURL, override) || sameServer(override, chosen) {
|
|
return tok
|
|
}
|
|
envTokenWarnOnce.Do(func() {
|
|
fmt.Fprintf(envTokenWarnOut, "pad: PAD_TOKEN not sent to %s, which comes from .pad.toml; it is bound to %s. To send it there, set PAD_URL=%s or pass --url.\n",
|
|
override, chosen, override)
|
|
})
|
|
return ""
|
|
}
|
|
|
|
// sameServer compares two base URLs the way saved credentials are keyed
|
|
// (normalizeServerURL): a differently spelled URL counts as a different
|
|
// server, which errs toward withholding.
|
|
func sameServer(a, b string) bool {
|
|
return normalizeServerURL(a) == normalizeServerURL(b)
|
|
}
|