diff --git a/cloudflare.go b/cloudflare.go index 659c5e5..c4fa0d4 100644 --- a/cloudflare.go +++ b/cloudflare.go @@ -17,6 +17,7 @@ package cloudflare import ( "fmt" "regexp" + "strings" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" @@ -50,11 +51,21 @@ func (p *Provider) Provision(ctx caddy.Context) error { p.Provider.APIToken = caddy.NewReplacer().ReplaceAll(p.Provider.APIToken, "") p.Provider.ZoneToken = caddy.NewReplacer().ReplaceAll(p.Provider.ZoneToken, "") if !validCloudflareToken(p.Provider.APIToken) { - return fmt.Errorf("API token '%s' appears invalid; ensure it's correctly entered and not wrapped in braces nor quotes", p.Provider.APIToken) + return fmt.Errorf("API token '%s' appears invalid; ensure it's correctly entered and not wrapped in braces nor quotes", redactToken(p.Provider.APIToken)) } return nil } +// redactToken returns a redacted version of the token, showing only the first 8 +// and last 4 characters with the middle replaced by asterisks. This prevents +// accidental credential leakage in error messages and log output. +func redactToken(token string) string { + if len(token) <= 12 { + return strings.Repeat("*", len(token)) + } + return token[:8] + strings.Repeat("*", len(token)-12) + token[len(token)-4:] +} + // validCloudflareToken returns true for legacy API tokens (35–50 chars) or cfut_/cfat_ tokens. func validCloudflareToken(token string) bool { return newCloudflareTokenRegexp.MatchString(token) || legacyCloudflareTokenRegexp.MatchString(token) diff --git a/cloudflare_test.go b/cloudflare_test.go index dca51b0..67688f1 100644 --- a/cloudflare_test.go +++ b/cloudflare_test.go @@ -198,6 +198,60 @@ func min(a, b int) int { return b } +func TestProvisionErrorRedactsToken(t *testing.T) { + // A token that contains characters outside [A-Za-z0-9_-] so it fails + // both regexes, but is long enough (>12 chars) to exercise redaction. + badToken := "this_is_not_a_valid_token!!" + p := Provider{&cloudflare.Provider{APIToken: badToken}} + + err := p.Provision(caddy.Context{}) + if err == nil { + t.Fatal("expected Provision to fail for an invalid token") + } + + errMsg := err.Error() + + // The full token must NOT appear anywhere in the error message. + if strings.Contains(errMsg, badToken) { + t.Errorf("error message contains the full unredacted token: %s", errMsg) + } + + // The redacted form should be present (first 8 + last 4 visible). + redacted := redactToken(badToken) + if !strings.Contains(errMsg, redacted) { + t.Errorf("error message does not contain the redacted token %q: %s", redacted, errMsg) + } +} + +func TestRedactToken(t *testing.T) { + tests := []struct { + name string + token string + expected string + }{ + {"short token (<= 12 chars)", "abc", "***"}, + {"exactly 12 chars", "123456789012", "************"}, + {"13 chars shows first 8 + last 4", "1234567890123", "12345678*0123"}, + {"legacy token", "Sqqty8-Vn0iOP29rvqYgwKz_xqGQ4y5JhuVL1-qU", "Sqqty8-V****************************1-qU"}, + {"cfat_ token", "cfat_THIS_IS_A_FAKE_TOKEN_FOR_TESTING", "cfat_THI*************************TING"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := redactToken(tt.token) + if got != tt.expected { + t.Errorf("redactToken(%q) = %q, want %q", tt.token, got, tt.expected) + } + // Regardless of expected value, the full token must not survive redaction + // (unless the token is <= 12 chars, in which case it's fully masked). + if len(tt.token) > 12 && strings.Contains(got, tt.token) { + t.Errorf("redactToken(%q) still contains the full token", tt.token) + } + }) + } +} + func TestValidToken(t *testing.T) { goodToken := "Sqqty8-Vn0iOP29rvqYgwKz_xqGQ4y5JhuVL1-qU" config := fmt.Sprintf(`cloudflare %s`, goodToken)