mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +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
231 lines
7.2 KiB
Go
231 lines
7.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// BUG-3645: a workspace string from .pad.toml or --workspace was concatenated
|
|
// raw into request paths, so "victim/#" turned an item delete into
|
|
// DELETE /workspaces/victim/ (the rest was a fragment, never sent).
|
|
|
|
func TestBUG3645_CheckRequestPath(t *testing.T) {
|
|
ok := []string{
|
|
"",
|
|
"/health",
|
|
"/workspaces/w/items/TASK-1",
|
|
"/workspaces/w%2F%23/items/TASK-1",
|
|
"/workspaces/w/items?limit=5&q=a%2Fb",
|
|
"/workspaces/w/items/x..y",
|
|
"/workspaces/w/items/.hidden",
|
|
}
|
|
for _, p := range ok {
|
|
if err := checkRequestPath(p); err != nil {
|
|
t.Errorf("checkRequestPath(%q) = %v, want nil", p, err)
|
|
}
|
|
}
|
|
refused := []string{
|
|
"/workspaces/victim/#/items/TASK-1",
|
|
"/workspaces/victim?/items/TASK-1",
|
|
"/workspaces/../items/TASK-1",
|
|
"/workspaces/./items",
|
|
"/workspaces//items",
|
|
"/workspaces/w/items/..",
|
|
"/",
|
|
}
|
|
for _, p := range refused {
|
|
if err := checkRequestPath(p); err == nil {
|
|
t.Errorf("checkRequestPath(%q) = nil, want a refusal", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// recordingServer answers every request 404 and records its escaped path and
|
|
// raw query.
|
|
func recordingServer(t *testing.T) (*httptest.Server, func() []string) {
|
|
t.Helper()
|
|
var mu sync.Mutex
|
|
var seen []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
seen = append(seen, r.Method+" "+r.URL.EscapedPath()+"?"+r.URL.RawQuery)
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"nope"}}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv, func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]string(nil), seen...)
|
|
}
|
|
}
|
|
|
|
func isolatedClient(t *testing.T, base string) *Client {
|
|
t.Helper()
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("PAD_TOKEN", "")
|
|
c := NewClientFromURL(base)
|
|
c.httpClient.Timeout = 2 * time.Second
|
|
return c
|
|
}
|
|
|
|
// The reported scenario, end to end: the DELETE must not reach the
|
|
// workspace, escaped or refused.
|
|
func TestBUG3645_ItemDeleteWithHostileWorkspaceNeverHitsTheWorkspace(t *testing.T) {
|
|
srv, seen := recordingServer(t)
|
|
c := isolatedClient(t, srv.URL)
|
|
for _, ws := range []string{"victim/#", "victim?", "victim/..", ".."} {
|
|
_ = c.DeleteItem(ws, "TASK-1")
|
|
_, _ = c.GetItem(ws, "TASK-1")
|
|
}
|
|
for _, req := range seen() {
|
|
path, _, _ := strings.Cut(strings.SplitN(req, " ", 2)[1], "?")
|
|
if path == "/api/v1/workspaces/victim" || path == "/api/v1/workspaces/victim/" || path == "/api/v1/workspaces" || path == "/api/v1/items/TASK-1" {
|
|
t.Errorf("request re-routed by the workspace string: %s", req)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The census instrument. Every exported *Client method is called with the
|
|
// same hostile string in every string argument (zero values elsewhere). A
|
|
// request that reaches the server must carry that string escaped inside one
|
|
// segment, or carry none of its pieces: a decoded path or query holding "/HX/"
|
|
// or "HY" without the whole hostile value is a re-route.
|
|
//
|
|
// It is a population instrument, not a sample: a builder added later without
|
|
// PathEscape fails here without anyone listing it.
|
|
func TestBUG3645_EveryClientMethodKeepsHostileStringsInOneSegment(t *testing.T) {
|
|
const hostile = "HX/HY?HZ#HW"
|
|
escaped := "HX%2FHY%3FHZ%23HW"
|
|
srv, seen := recordingServer(t)
|
|
c := isolatedClient(t, srv.URL)
|
|
|
|
// Methods whose string argument IS the request path, by contract. The
|
|
// path builders' callers own escaping for these; only the newRequest
|
|
// chokepoint stands between them and a re-route, and the census checks
|
|
// that it refuses them (no request reaches the server).
|
|
rawPath := map[string]bool{
|
|
"RawGet": true, "RawStream": true, "PostRaw": true, "PostRawWithContentType": true,
|
|
"PostStreamContext": true, "PostStreamWithContentType": true,
|
|
"PostStreamWithContentTypeHeaders": true,
|
|
}
|
|
|
|
cv := reflect.ValueOf(c)
|
|
ct := cv.Type()
|
|
var called, skipped []string
|
|
byMethod := map[string][]string{}
|
|
for i := 0; i < ct.NumMethod(); i++ {
|
|
m := ct.Method(i)
|
|
mt := m.Type
|
|
hasString := false
|
|
args := []reflect.Value{cv}
|
|
for j := 1; j < mt.NumIn(); j++ {
|
|
in := mt.In(j)
|
|
if in.Kind() == reflect.String {
|
|
hasString = true
|
|
args = append(args, reflect.ValueOf(hostile).Convert(in))
|
|
continue
|
|
}
|
|
args = append(args, argFor(in))
|
|
}
|
|
if !hasString || mt.IsVariadic() {
|
|
continue
|
|
}
|
|
before := len(seen())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
defer func() { _ = recover() }()
|
|
m.Func.Call(args)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
called = append(called, m.Name)
|
|
byMethod[m.Name] = seen()[before:]
|
|
case <-time.After(5 * time.Second):
|
|
skipped = append(skipped, m.Name+" (timeout)")
|
|
}
|
|
}
|
|
sort.Strings(skipped)
|
|
t.Logf("called %d methods with a string argument; skipped %v", len(called), skipped)
|
|
if len(called) < 60 {
|
|
t.Fatalf("only %d methods were exercised; the instrument has lost its population", len(called))
|
|
}
|
|
|
|
for name := range rawPath {
|
|
if _, ok := byMethod[name]; !ok {
|
|
t.Errorf("raw-path method %s was not exercised; drop it from the allowlist or find why", name)
|
|
}
|
|
}
|
|
reached := 0
|
|
for name, reqs := range byMethod {
|
|
if rawPath[name] {
|
|
if len(reqs) != 0 {
|
|
t.Errorf("%s sent a hostile raw path the chokepoint should refuse: %v", name, reqs)
|
|
}
|
|
continue
|
|
}
|
|
for _, req := range reqs {
|
|
checkCensusRequest(t, name, req, escaped, &reached)
|
|
}
|
|
}
|
|
t.Logf("%d requests carried the hostile string escaped in their path", reached)
|
|
// Measured 86 when written (BUG-3645). A builder that stops escaping
|
|
// drops below this: its hostile path is refused by the chokepoint
|
|
// instead of sent escaped.
|
|
if reached < 86 {
|
|
t.Fatalf("only %d requests carried the hostile string in their path; expected the census population", reached)
|
|
}
|
|
}
|
|
|
|
func checkCensusRequest(t *testing.T, method, req, escaped string, reached *int) {
|
|
t.Helper()
|
|
{
|
|
target := strings.SplitN(req, " ", 2)[1]
|
|
if strings.Contains(target, "#") {
|
|
t.Errorf("%s: raw '#' reached the server: %s", method, req)
|
|
}
|
|
path, _, _ := strings.Cut(target, "?")
|
|
if strings.Contains(path, "HX") {
|
|
*reached++
|
|
if !strings.Contains(path, escaped) {
|
|
t.Errorf("%s: hostile string split across segments: %s", method, req)
|
|
}
|
|
}
|
|
if strings.Contains(path, "/HY") || strings.HasSuffix(path, "/HX") {
|
|
t.Errorf("%s: hostile string re-routed the path: %s", method, req)
|
|
}
|
|
}
|
|
}
|
|
|
|
// argFor returns a usable non-string argument: a context, a buffer for any
|
|
// interface it satisfies (io.Reader, io.Writer), a fresh value behind a
|
|
// pointer, an empty map; the zero value otherwise. Nil readers and writers
|
|
// panic inside the methods' own goroutines, where no recover reaches.
|
|
func argFor(in reflect.Type) reflect.Value {
|
|
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
|
|
switch {
|
|
case in == ctxType:
|
|
return reflect.ValueOf(context.Background())
|
|
case in.Kind() == reflect.Interface && reflect.TypeOf(&bytes.Buffer{}).Implements(in):
|
|
return reflect.ValueOf(&bytes.Buffer{}).Convert(in)
|
|
case in.Kind() == reflect.Pointer:
|
|
return reflect.New(in.Elem())
|
|
case in.Kind() == reflect.Map:
|
|
return reflect.MakeMap(in)
|
|
}
|
|
return reflect.Zero(in)
|
|
}
|