From 4125a4c102ec403b6f3f289463f067cecd7b315a Mon Sep 17 00:00:00 2001 From: hzhang Date: Sun, 31 May 2026 20:16:36 +0100 Subject: [PATCH] fix(security): keep credentials off argv and plaintext transports - M7: ResolveToken accepts the token via the HF_TOKEN env var (so it need not appear in argv, where it's visible in ps/shell history); the HTTP client refuses to send a token / API key over plaintext http:// to a non-loopback host (use https://). Loopback http is still allowed for local dev. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/client/client.go | 33 +++++++++++++++++++++++++++++++++ internal/commands/auth.go | 16 ++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 194f6da..14d2f2f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" + neturl "net/url" "strings" "time" ) @@ -70,8 +72,39 @@ func (e *RequestError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body) } +// guardPlaintextCreds refuses to transmit a token / API key over a plaintext +// http:// connection to a non-loopback host (prevents credential interception). +func (c *Client) guardPlaintextCreds() error { + if c.Token == "" && c.APIKey == "" { + return nil + } + u, err := neturl.Parse(c.BaseURL) + if err != nil || u.Scheme != "http" { + return nil // parse errors and https:// are fine + } + if isLoopbackHost(u.Hostname()) { + return nil + } + return fmt.Errorf("refusing to send credentials over plaintext http:// to non-loopback host %q — use an https:// base URL (hf config set-url ...)", u.Hostname()) +} + +// isLoopbackHost reports whether h is a loopback address or localhost name. +func isLoopbackHost(h string) bool { + h = strings.ToLower(h) + if h == "localhost" || strings.HasSuffix(h, ".localhost") { + return true + } + if ip := net.ParseIP(h); ip != nil { + return ip.IsLoopback() + } + return false +} + // Do executes an HTTP request and returns the response body bytes. func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) { + if err := c.guardPlaintextCreds(); err != nil { + return nil, err + } url := c.BaseURL + path req, err := http.NewRequest(method, url, body) if err != nil { diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 1c42f2a..34585b4 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -1,6 +1,9 @@ package commands import ( + "os" + "strings" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr" @@ -20,11 +23,16 @@ func ResolveToken(tokenFlag string) string { } return tok } - // manual mode - if tokenFlag == "" { - output.Error("--token required or execute this with pcexec") + // manual mode — prefer the explicit flag, else fall back to the HF_TOKEN + // env var so the token need not appear in argv (visible via `ps`/history). + if tokenFlag != "" { + return tokenFlag } - return tokenFlag + if env := strings.TrimSpace(os.Getenv("HF_TOKEN")); env != "" { + return env + } + output.Error("--token or HF_TOKEN env required, or execute this with pcexec") + return "" } // RejectTokenInPaddedCell checks if --token was passed in padded-cell mode