fix(transport): keep harmless query values visible in --debug output

--debug replaced every query value with REDACTED, so it could not show which
filters were sent. Redact only parameters that can authenticate a request
(tokens, passwords, secrets, signatures, storage credentials, OAuth codes and
the OAuth state), and log the sanitized server message of failed responses.

A 404 now names the request instead of blaming the instance, and API errors
recorded from a request keep its instance and credential so a caller can look
up why it failed. Network failures keep the server they could not reach.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 22:48:49 +02:00
1 parent 62962b4f70
commit 0254c0509e
7 files changed
+225 -17

No files matched your search

+49 -5
View File
@@ -4,10 +4,12 @@
package transport
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
@@ -19,32 +21,74 @@ type debugTransport struct {
func (t debugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := t.base.RoundTrip(req)
var outcome string
var outcome, message string
if err != nil {
outcome = "error: " + err.Error()
} else {
outcome = resp.Status
message = failureMessage(req, resp)
}
_, _ = fmt.Fprintf(t.out, "[debug] %s %s -> %s (%s)\n", req.Method, redactURL(req.URL), outcome, time.Since(start).Round(time.Millisecond))
line := fmt.Sprintf("[debug] %s %s -> %s (%s)", req.Method, redactURL(req.URL), outcome, time.Since(start).Round(time.Millisecond))
if message != "" {
line += ": " + message
}
_, _ = fmt.Fprintln(t.out, line)
return resp, err
}
// redactURL drops userinfo and query values, which can carry tokens or signatures.
// failureMessage returns the sanitized server message of a failed response and keeps its body
// readable, so --debug shows what the server said when tea explains the failure in its own words.
func failureMessage(req *http.Request, resp *http.Response) string {
if resp.StatusCode < 400 {
return ""
}
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
resp.Body = struct {
io.Reader
io.Closer
}{io.MultiReader(bytes.NewReader(data), resp.Body), resp.Body}
token := strings.TrimPrefix(req.Header.Get("Authorization"), "token ")
return newAPIError(resp.StatusCode, req.Method, req.URL.Path, token, data).Message
}
// redactURL drops userinfo and the values of query parameters that can carry credentials or
// signatures, keeping filters such as limit, page and state readable.
func redactURL(u *url.URL) string {
clean := *u
clean.User = nil
if clean.RawQuery != "" {
query := clean.Query()
for key := range query {
query[key] = []string{"REDACTED"}
if sensitiveQuery(clean.Path, key) {
query[key] = []string{"REDACTED"}
}
}
clean.RawQuery = query.Encode()
}
return clean.String()
}
// sensitiveQuery reports query parameters whose values can authenticate a request: tokens,
// passwords, secrets, signatures and credentials of signed storage URLs, and OAuth codes and state.
func sensitiveQuery(path, key string) bool {
key = strings.ToLower(key)
switch {
case key == "code" || key == "key" || strings.HasPrefix(key, "sig"):
return true
case key == "state":
return strings.Contains(path, "/login/oauth/")
}
for _, word := range []string{"token", "password", "secret", "signature", "credential"} {
if strings.Contains(key, word) {
return true
}
}
return false
}
// Debug returns a copy of c that logs the method, redacted URL, status, and duration of each
// request to w. Headers and bodies are never logged.
// request to w, plus the sanitized server message of a failed response. Headers and bodies are
// never logged.
func (c *Client) Debug(w io.Writer) *Client {
clone := *c
httpClient := *c.HTTP