Skip to content

Commit 09d4658

Browse files
committed
sec(api/plans): stop leaking raw account UUID + DB error in plan-account validation log
The GetCloudAccount DB-error path inside validatePlanAccountProviders returned fmt.Errorf("accounts: failed to get account %s: %w", aid, getErr), a non-ClientError that propagated to the router's logging.Errorf("API error: %v"), leaking the raw account UUID and the raw DB error string into logs (gap left by #946 which closed #944). Wrap the error with the existing mapCreatePlanStorageError helper so that: (a) a DB error emits a structured logging.Errorf carrying only the derived provider list and account count (never the UUID), and (b) returns a generic 500 ClientError ("failed to validate plan accounts") with no raw UUID or DB string. The ErrNotFound sentinel still maps to 404; the explicit (nil, nil) not-found path is unchanged. Add logging.SetOutput as a test seam on the default logger and two regression tests: - TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak: asserts that a DB error yields a 500 ClientError whose message and emitted log contain neither the account UUID nor the raw DB error string. - TestValidatePlanAccountProviders_AccountNotFound_Still404: guards that the not-found path still returns a 404 referencing the missing ID. Also fixes the pre-existing build failure on the base branch (Session.Role field removed in #940 but the test was not updated) and gofmt drift in handler_purchases_guards_test.go. closes #965
1 parent 8ab0d20 commit 09d4658

3 files changed

Lines changed: 110 additions & 1 deletion

File tree

‎internal/api/handler_accounts.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1227,7 +1227,16 @@ func (h *Handler) validatePlanAccountProviders(ctx context.Context, planID strin
12271227
for _, aid := range accountIDs {
12281228
acct, getErr := h.config.GetCloudAccount(ctx, aid)
12291229
if getErr != nil {
1230-
return fmt.Errorf("accounts: failed to get account %s: %w", aid, getErr)
1230+
// Do NOT wrap getErr with the raw account UUID: a non-ClientError
1231+
// here propagates to the router's `logging.Errorf("API error: %v")`,
1232+
// which would leak the account UUID and the raw DB error string
1233+
// into logs (issue #944(b): log account counts, never raw IDs).
1234+
// Map to a 404 if the store reports not-found, else a structured
1235+
// 500 that logs only the derived providers + account count.
1236+
return mapCreatePlanStorageError(getErr,
1237+
"account not found", "failed to validate plan accounts",
1238+
"validatePlanAccountProviders: GetCloudAccount failed (providers=%v accounts=%d)",
1239+
expected, len(accountIDs))
12311240
}
12321241
if acct == nil {
12331242
return NewClientError(404, fmt.Sprintf("account not found: %s", aid))

‎internal/api/handler_accounts_test.go‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package api
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"errors"
@@ -10,6 +11,7 @@ import (
1011
"github.com/LeanerCloud/CUDly/internal/accounts"
1112
"github.com/LeanerCloud/CUDly/internal/commitmentopts"
1213
"github.com/LeanerCloud/CUDly/internal/config"
14+
"github.com/LeanerCloud/CUDly/pkg/logging"
1315
"github.com/aws/aws-lambda-go/events"
1416
"github.com/aws/aws-sdk-go-v2/aws"
1517
"github.com/jackc/pgx/v5"
@@ -1915,3 +1917,86 @@ func (m *mockConfigStoreAccounts) SavePurchaseExecutionTx(ctx context.Context, _
19151917
func (m *mockConfigStoreAccounts) WithTx(_ context.Context, fn func(tx pgx.Tx) error) error {
19161918
return fn(nil)
19171919
}
1920+
1921+
// captureDefaultLog redirects the default logger's output to a buffer for the
1922+
// duration of the test and restores it on cleanup. Used to assert that the
1923+
// log line emitted on the GetCloudAccount DB-error path leaks no PII.
1924+
func captureDefaultLog(t *testing.T) *bytes.Buffer {
1925+
t.Helper()
1926+
var buf bytes.Buffer
1927+
prev := logging.SetOutput(&buf)
1928+
t.Cleanup(func() { logging.SetOutput(prev) })
1929+
return &buf
1930+
}
1931+
1932+
// TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak is a regression
1933+
// test for the PII-leak gap left by PR #946 (which closed #944): the
1934+
// GetCloudAccount DB-error path inside validatePlanAccountProviders wrapped the
1935+
// raw error with the account UUID and returned a non-ClientError, so the raw
1936+
// UUID + raw DB error string leaked into the router's
1937+
// `logging.Errorf("API error: %v")`. The fix returns a generic 500 ClientError
1938+
// and logs only the derived providers + account count.
1939+
//
1940+
// Asserts that on a DB error: (1) a 500 ClientError is returned, (2) neither
1941+
// the returned error message nor the emitted log contains the account UUID or
1942+
// the raw DB error string; and that account-not-found still maps to 404 with
1943+
// the offending ID (intentional, since the ID came from the caller's request).
1944+
func TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak(t *testing.T) {
1945+
ctx := context.Background()
1946+
1947+
const rawDBErr = "pq: connection refused to host db-internal-99.example.com"
1948+
1949+
store := setupAdminMock(ctx)
1950+
store.GetPurchasePlanFn = func(_ context.Context, _ string) (*config.PurchasePlan, error) {
1951+
return awsPlan209(), nil
1952+
}
1953+
store.GetCloudAccountFn = func(_ context.Context, _ string) (*config.CloudAccount, error) {
1954+
return nil, errors.New(rawDBErr)
1955+
}
1956+
handler := &Handler{config: store}
1957+
1958+
logBuf := captureDefaultLog(t)
1959+
1960+
err := handler.validatePlanAccountProviders(ctx, planID209, []string{awsAcct209})
1961+
require.Error(t, err)
1962+
1963+
ce, ok := IsClientError(err)
1964+
require.True(t, ok, "DB error must be mapped to a ClientError, not propagate raw to the router log")
1965+
assert.Equal(t, 500, ce.code, "a DB error during account validation is a server-side fault")
1966+
1967+
// The user-facing error message must carry no raw UUID or DB string.
1968+
assert.NotContains(t, ce.Error(), awsAcct209, "500 message must not leak the account UUID")
1969+
assert.NotContains(t, ce.Error(), rawDBErr, "500 message must not leak the raw DB error")
1970+
1971+
// Simulate the router logging a leaked non-ClientError; with the fix in
1972+
// place no such log is emitted (the error is already a ClientError), so the
1973+
// buffer must contain neither the UUID nor the raw DB string regardless of
1974+
// whether validate logged its own structured line.
1975+
logged := logBuf.String()
1976+
assert.NotContains(t, logged, awsAcct209, "log must not contain the raw account UUID (issue #944(b))")
1977+
assert.NotContains(t, logged, rawDBErr, "log must not contain the raw DB error string")
1978+
}
1979+
1980+
// TestValidatePlanAccountProviders_AccountNotFound_Still404 guards that the
1981+
// no-PII-leak fix did not regress the account-not-found path: a store-style
1982+
// (nil, nil) result must still produce a 404 referencing the missing ID (which
1983+
// is safe to echo, since the caller supplied it).
1984+
func TestValidatePlanAccountProviders_AccountNotFound_Still404(t *testing.T) {
1985+
ctx := context.Background()
1986+
1987+
store := setupAdminMock(ctx)
1988+
store.GetPurchasePlanFn = func(_ context.Context, _ string) (*config.PurchasePlan, error) {
1989+
return awsPlan209(), nil
1990+
}
1991+
store.GetCloudAccountFn = func(_ context.Context, _ string) (*config.CloudAccount, error) {
1992+
return nil, nil // store-style not-found
1993+
}
1994+
handler := &Handler{config: store}
1995+
1996+
err := handler.validatePlanAccountProviders(ctx, planID209, []string{missingAcct})
1997+
require.Error(t, err)
1998+
ce, ok := IsClientError(err)
1999+
require.True(t, ok)
2000+
assert.Equal(t, 404, ce.code)
2001+
assert.Contains(t, ce.Error(), missingAcct, "404 must still reference the missing account ID")
2002+
}

‎pkg/logging/logger.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const (
3030
type Logger struct {
3131
level Level
3232
logger *log.Logger
33+
output io.Writer
3334
prefix string
3435
metadata map[string]interface{}
3536
}
@@ -82,6 +83,7 @@ func New(cfg Config) *Logger {
8283
return &Logger{
8384
level: ParseLevel(cfg.Level),
8485
logger: log.New(output, cfg.Prefix, flags),
86+
output: output,
8587
prefix: cfg.Prefix,
8688
metadata: make(map[string]interface{}),
8789
}
@@ -107,6 +109,7 @@ func (l *Logger) With(key string, value interface{}) *Logger {
107109
newLogger := &Logger{
108110
level: l.level,
109111
logger: l.logger,
112+
output: l.output,
110113
prefix: l.prefix,
111114
metadata: make(map[string]interface{}),
112115
}
@@ -243,3 +246,15 @@ func With(key string, value interface{}) *Logger {
243246
func GetDefaultLogger() *Logger {
244247
return defaultLogger
245248
}
249+
250+
// SetOutput redirects the default logger's output to w and returns the
251+
// previous io.Writer so callers can restore it (e.g. in a test t.Cleanup).
252+
// The default logger binds os.Stderr at init() time, so reassigning the
253+
// os.Stderr variable does not redirect it; this is the supported seam for
254+
// capturing default-logger output in tests.
255+
func SetOutput(w io.Writer) io.Writer {
256+
prev := defaultLogger.output
257+
defaultLogger.output = w
258+
defaultLogger.logger.SetOutput(w)
259+
return prev
260+
}

0 commit comments

Comments
 (0)