Skip to content

Commit 53da451

Browse files
authored
fix: harden install and credential cleanup (#14)
* feat: remove smoke test * feat: fail intall if checksum cannot be verified * fix: clean up stale keyring credentials Store API keys under a stable keyring entry instead of deriving the entry name from base URL and account ID. The old scheme could leave credentials behind if config.yaml was edited manually before logout. On first read, migrate an existing URL/account-scoped key to the stable api-key entry and delete the legacy entry. Keep a TODO to remove the migration in v1 after users have had a release cycle to move forward. Logout now clears the whole chatwoot-cli keyring service even when config.yaml is missing, so stale entries are removed independently of the current config state. * fix: sanitize server-controlled CLI output Sanitize server-provided strings before writing auth login output, raw API responses, verbose non-JSON bodies, and API error bodies to the terminal. Expand verbose JSON redaction to cover hmac_identifier and common secret-like field names such as *_token, *_secret, *_key, and hmac_* values. Tests cover terminal escape stripping for the affected output paths and redaction of HMAC/secret-like response fields. * fix: scope saved keyring credentials Store stable keyring credentials as scoped JSON containing the normalized base URL, account ID, and API key instead of a raw token. ResolveAPIKey now rejects saved credentials when the current config points at a different instance or account, preventing a token from being sent to the wrong host after config.yaml is edited or restored. Legacy scoped entries are migrated into the new scoped JSON format and then deleted.
1 parent 4e1a0a5 commit 53da451

11 files changed

Lines changed: 363 additions & 396 deletions

File tree

install.sh

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -130,27 +130,27 @@ fi
130130
# ---------------------------------------------------------------------------
131131
# Verify checksum (sha256)
132132
# ---------------------------------------------------------------------------
133-
if curl -fsSL "$checksum_url" -o "$tmp/checksums.txt" 2>/dev/null; then
134-
expected=$(grep " ${asset}\$" "$tmp/checksums.txt" | awk '{print $1}')
135-
if [ -n "$expected" ]; then
136-
if has sha256sum; then
137-
actual=$(sha256sum "$tmp/$asset" | awk '{print $1}')
138-
elif has shasum; then
139-
actual=$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')
140-
else
141-
warn "no sha256 tool found — skipping checksum verification"
142-
actual=""
143-
fi
144-
if [ -n "$actual" ] && [ "$expected" != "$actual" ]; then
145-
err "checksum mismatch (expected=${expected} got=${actual})"
146-
fi
147-
[ -n "$actual" ] && step "Verified checksum"
148-
else
149-
warn "${asset} not listed in checksums.txt — skipping verification"
150-
fi
133+
if ! curl -fsSL "$checksum_url" -o "$tmp/checksums.txt" 2>/dev/null; then
134+
err "failed to download checksums.txt; refusing to install without checksum verification"
135+
fi
136+
137+
expected=$(grep " ${asset}\$" "$tmp/checksums.txt" | awk '{print $1}')
138+
if [ -z "$expected" ]; then
139+
err "${asset} not listed in checksums.txt; refusing to install without checksum verification"
140+
fi
141+
142+
if has sha256sum; then
143+
actual=$(sha256sum "$tmp/$asset" | awk '{print $1}')
144+
elif has shasum; then
145+
actual=$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')
151146
else
152-
warn "could not fetch checksums.txt — skipping verification"
147+
err "no sha256 tool found; install sha256sum or shasum and retry"
148+
fi
149+
150+
if [ "$expected" != "$actual" ]; then
151+
err "checksum mismatch (expected=${expected} got=${actual})"
153152
fi
153+
step "Verified checksum"
154154

155155
# ---------------------------------------------------------------------------
156156
# Extract and install

internal/cmd/api.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"net/url"
1111
"os"
1212
"strings"
13+
14+
"github.com/chatwoot/cli/internal/output"
1315
)
1416

1517
type ApiCmd struct {
@@ -131,8 +133,9 @@ func printAPIResponse(w io.Writer, body []byte) {
131133
return
132134
}
133135

134-
_, _ = w.Write(body)
135-
if body[len(body)-1] != '\n' {
136+
safeBody := output.SanitizeText(string(body))
137+
_, _ = io.WriteString(w, safeBody)
138+
if !strings.HasSuffix(safeBody, "\n") {
136139
_, _ = fmt.Fprintln(w)
137140
}
138141
}

internal/cmd/api_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,22 @@ func TestApiCmdSendsMethodBodyAndHeaders(t *testing.T) {
123123
}
124124
}
125125

126+
func TestPrintAPIResponseSanitizesNonJSONBody(t *testing.T) {
127+
var out bytes.Buffer
128+
129+
printAPIResponse(&out, []byte("hello\x1b]52;c;Zm9v\aworld\x1b[31m"))
130+
131+
got := out.String()
132+
for _, disallowed := range []string{"\x1b", "\a", "]52", "[31m"} {
133+
if strings.Contains(got, disallowed) {
134+
t.Fatalf("non-JSON API response contained terminal control %q: %q", disallowed, got)
135+
}
136+
}
137+
if !strings.Contains(got, "helloworld") {
138+
t.Fatalf("non-JSON API response stripped printable content: %q", got)
139+
}
140+
}
141+
126142
func TestNormalizeAPIPathRejectsAbsoluteURLs(t *testing.T) {
127143
if _, _, err := normalizeAPIPath("https://example.com/api/v1/profile", false); err == nil {
128144
t.Fatal("expected absolute URL error")

internal/cmd/auth.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,14 @@ func (c *AuthLoginCmd) Run(app *App) error {
7474
return fmt.Errorf("failed to save config: %w", err)
7575
}
7676

77-
fmt.Printf("Logged in as %s (%s)\n", profile.Name, profile.Email)
77+
fmt.Print(loginSuccessMessage(profile.Name, profile.Email))
7878
return nil
7979
}
8080

81+
func loginSuccessMessage(name, email string) string {
82+
return fmt.Sprintf("Logged in as %s (%s)\n", output.SanitizeText(name), output.SanitizeText(email))
83+
}
84+
8185
func readAPIKey(reader *bufio.Reader) (string, error) {
8286
fmt.Print("API Key: ")
8387

@@ -111,10 +115,8 @@ func (c *AuthLogoutCmd) Run(app *App) error {
111115
return err
112116
}
113117

114-
if cfg != nil {
115-
if err := config.DeleteAPIKey(cfg); err != nil {
116-
return err
117-
}
118+
if err := config.DeleteAPIKey(cfg); err != nil {
119+
return err
118120
}
119121

120122
if err := os.Remove(path); err != nil {

internal/cmd/auth_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"bytes"
5+
"errors"
56
"net/http"
67
"net/http/httptest"
78
"strings"
@@ -112,6 +113,18 @@ func TestAuthStatusReportsIdentityAndCredentialSource(t *testing.T) {
112113
}
113114
}
114115

116+
func TestLoginSuccessMessageStripsTerminalControls(t *testing.T) {
117+
got := loginSuccessMessage("Eve\x1b]52;c;Zm9v\a", "eve@example.com\x1b[31m")
118+
for _, disallowed := range []string{"\x1b", "\a", "]52", "[31m"} {
119+
if strings.Contains(got, disallowed) {
120+
t.Fatalf("login success message contained terminal control %q: %q", disallowed, got)
121+
}
122+
}
123+
if !strings.Contains(got, "Logged in as Eve (eve@example.com)") {
124+
t.Fatalf("login success message stripped printable content: %q", got)
125+
}
126+
}
127+
115128
func TestMeAndWhoamiAliasAuthStatus(t *testing.T) {
116129
profile := `{
117130
"id": 7,
@@ -152,6 +165,27 @@ func TestAuthStatusNotLoggedIn(t *testing.T) {
152165
}
153166
}
154167

168+
func TestAuthLogoutRemovesKeyringTokenWithoutConfig(t *testing.T) {
169+
keyring.MockInit()
170+
if err := keyring.DeleteAll("chatwoot-cli"); err != nil {
171+
t.Fatalf("keyring.DeleteAll: %v", err)
172+
}
173+
t.Setenv("HOME", t.TempDir())
174+
t.Setenv(config.APIKeyEnv, "")
175+
176+
if err := keyring.Set("chatwoot-cli", "api-key", "stale-token"); err != nil {
177+
t.Fatalf("keyring.Set: %v", err)
178+
}
179+
180+
if err := (&AuthLogoutCmd{}).Run(&App{}); err != nil {
181+
t.Fatalf("Run: %v", err)
182+
}
183+
184+
if _, err := keyring.Get("chatwoot-cli", "api-key"); !errors.Is(err, keyring.ErrNotFound) {
185+
t.Fatalf("expected logout to delete stale keyring token, err = %v", err)
186+
}
187+
}
188+
155189
func TestAuthStatusSelfHealsCachedUserID(t *testing.T) {
156190
profile := `{
157191
"id": 99,

internal/config/credentials.go

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"encoding/json"
45
"errors"
56
"fmt"
67
"os"
@@ -10,8 +11,9 @@ import (
1011
)
1112

1213
const (
13-
APIKeyEnv = "CHATWOOT_API_KEY"
14-
keyringService = "chatwoot-cli"
14+
APIKeyEnv = "CHATWOOT_API_KEY"
15+
keyringService = "chatwoot-cli"
16+
apiKeyKeyringEntry = "api-key"
1517
)
1618

1719
type CredentialSource string
@@ -24,6 +26,12 @@ const (
2426

2527
var ErrAPIKeyNotFound = errors.New("api key not found")
2628

29+
type savedCredential struct {
30+
BaseURL string `json:"base_url"`
31+
AccountID int `json:"account_id"`
32+
APIKey string `json:"api_key"`
33+
}
34+
2735
// ResolveAPIKey implements the auth flow for the CLI. YAML config intentionally
2836
// stores only non-secrets, and plaintext api_key values from older configs are
2937
// ignored. CHATWOOT_API_KEY wins for CI, coding agents, and temporary overrides;
@@ -37,14 +45,33 @@ func ResolveAPIKey(cfg *Config) (string, CredentialSource, error) {
3745
return "", CredentialSourceMissing, missingAPIKeyError()
3846
}
3947

40-
apiKey, err := keyring.Get(keyringService, credentialKey(cfg))
48+
stored, err := keyring.Get(keyringService, apiKeyKeyringEntry)
4149
if err == nil {
50+
apiKey, err := parseSavedCredential(stored, cfg)
51+
if err != nil {
52+
return "", CredentialSourceMissing, err
53+
}
4254
return apiKey, CredentialSourceKeyring, nil
4355
}
44-
if errors.Is(err, keyring.ErrNotFound) {
45-
return "", CredentialSourceMissing, missingAPIKeyError()
56+
if !errors.Is(err, keyring.ErrNotFound) {
57+
return "", CredentialSourceMissing, fmt.Errorf("failed to read API key from keyring: %w", err)
4658
}
47-
return "", CredentialSourceMissing, fmt.Errorf("failed to read API key from keyring: %w", err)
59+
60+
// TODO(v1): remove this legacy key migration after users have had a release
61+
// cycle to move from URL/account-scoped keyring entries to api-key.
62+
apiKey, err := keyring.Get(keyringService, legacyCredentialKey(cfg))
63+
if err == nil {
64+
if err := saveAPIKeyToKeyring(cfg, apiKey); err != nil {
65+
return "", CredentialSourceMissing, fmt.Errorf("failed to migrate API key in keyring: %w", err)
66+
}
67+
_ = keyring.Delete(keyringService, legacyCredentialKey(cfg))
68+
return apiKey, CredentialSourceKeyring, nil
69+
}
70+
if !errors.Is(err, keyring.ErrNotFound) {
71+
return "", CredentialSourceMissing, fmt.Errorf("failed to read legacy API key from keyring: %w", err)
72+
}
73+
74+
return "", CredentialSourceMissing, missingAPIKeyError()
4875
}
4976

5077
func SaveAPIKey(cfg *Config, apiKey string) error {
@@ -55,30 +82,58 @@ func SaveAPIKey(cfg *Config, apiKey string) error {
5582
if cfg == nil || !cfg.IsValid() {
5683
return fmt.Errorf("valid config is required to save API key")
5784
}
58-
if err := keyring.Set(keyringService, credentialKey(cfg), apiKey); err != nil {
85+
if err := saveAPIKeyToKeyring(cfg, apiKey); err != nil {
5986
return fmt.Errorf("failed to save API key to keyring: %w", err)
6087
}
6188
return nil
6289
}
6390

64-
func DeleteAPIKey(cfg *Config) error {
65-
if cfg == nil || !cfg.IsValid() {
66-
return nil
91+
func saveAPIKeyToKeyring(cfg *Config, apiKey string) error {
92+
credential := savedCredential{
93+
BaseURL: normalizeBaseURL(cfg.BaseURL),
94+
AccountID: cfg.AccountID,
95+
APIKey: apiKey,
6796
}
68-
if err := keyring.Delete(keyringService, credentialKey(cfg)); err != nil {
69-
if errors.Is(err, keyring.ErrNotFound) {
70-
return nil
71-
}
72-
return fmt.Errorf("failed to delete API key from keyring: %w", err)
97+
98+
data, err := json.Marshal(credential)
99+
if err != nil {
100+
return err
73101
}
74-
return nil
102+
return keyring.Set(keyringService, apiKeyKeyringEntry, string(data))
103+
}
104+
105+
func parseSavedCredential(stored string, cfg *Config) (string, error) {
106+
var credential savedCredential
107+
if err := json.Unmarshal([]byte(stored), &credential); err != nil {
108+
return "", credentialScopeMismatchError()
109+
}
110+
if credential.APIKey == "" ||
111+
credential.BaseURL != normalizeBaseURL(cfg.BaseURL) ||
112+
credential.AccountID != cfg.AccountID {
113+
return "", credentialScopeMismatchError()
114+
}
115+
return credential.APIKey, nil
116+
}
117+
118+
// DeleteAPIKey removes every credential saved by this CLI service. This avoids
119+
// leaving stale keyring entries behind when config.yaml was edited or removed.
120+
func DeleteAPIKey(_ *Config) error {
121+
err := keyring.DeleteAll(keyringService)
122+
if err == nil || errors.Is(err, keyring.ErrNotFound) {
123+
return nil
124+
}
125+
return fmt.Errorf("failed to delete API keys from keyring: %w", err)
75126
}
76127

77128
func missingAPIKeyError() error {
78129
return fmt.Errorf("%w; run 'chatwoot auth login' or set %s", ErrAPIKeyNotFound, APIKeyEnv)
79130
}
80131

81-
func credentialKey(cfg *Config) string {
132+
func credentialScopeMismatchError() error {
133+
return fmt.Errorf("%w; saved keyring credential does not match configured instance; run 'chatwoot auth login' for this base URL and account", ErrAPIKeyNotFound)
134+
}
135+
136+
func legacyCredentialKey(cfg *Config) string {
82137
return fmt.Sprintf("%s/accounts/%d", normalizeBaseURL(cfg.BaseURL), cfg.AccountID)
83138
}
84139

0 commit comments

Comments
 (0)