diff --git a/secrets.go b/secrets.go index 79105a4..632f740 100644 --- a/secrets.go +++ b/secrets.go @@ -10,8 +10,9 @@ import ( // empty value returns empty with no error. A "env:NAME" value returns // os.Getenv(NAME), erroring if the variable is unset or empty. A // "file:/path" value returns the file's contents with surrounding -// whitespace trimmed, erroring if the file cannot be read. Any other value -// is returned unchanged as a literal. +// whitespace trimmed, erroring if the file cannot be read or its trimmed +// contents are empty — symmetric with the env: case. Any other value is +// returned unchanged as a literal. // // Error messages never include a resolved secret value — only the env var // name or file path, which are config, not secrets. @@ -31,7 +32,11 @@ func resolveSecret(v string) (string, error) { if err != nil { return "", fmt.Errorf("llmgateway: cannot read secret file %q: %w", path, err) } - return strings.TrimSpace(string(b)), nil + val := strings.TrimSpace(string(b)) + if val == "" { + return "", fmt.Errorf("llmgateway: secret file %q is empty or whitespace-only", path) + } + return val, nil } return v, nil } diff --git a/secrets_test.go b/secrets_test.go index df01069..f91644e 100644 --- a/secrets_test.go +++ b/secrets_test.go @@ -3,6 +3,7 @@ package traefikllmgateway import ( "os" "path/filepath" + "strings" "testing" ) @@ -13,6 +14,14 @@ func TestResolveSecret(t *testing.T) { if err := os.WriteFile(fp, []byte("from-file\n"), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } + emptyFP := filepath.Join(dir, "empty") + if err := os.WriteFile(emptyFP, []byte(""), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + blankFP := filepath.Join(dir, "blank") + if err := os.WriteFile(blankFP, []byte(" \n\t\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } cases := []struct { in string @@ -24,6 +33,8 @@ func TestResolveSecret(t *testing.T) { {"env:LLMGW_MISSING", "", true}, {"file:" + fp, "from-file", false}, {"file:" + fp + ".nope", "", true}, + {"file:" + emptyFP, "", true}, + {"file:" + blankFP, "", true}, {"", "", false}, } for _, c := range cases { @@ -35,3 +46,66 @@ func TestResolveSecret(t *testing.T) { }) } } + +// TestResolveSecret_EmptyFile_ErrorNamesPathNotContents is the symmetry +// regression: env: and file: must both reject an empty resolved secret, and +// the error must name the path, never echo the (empty) contents. +func TestResolveSecret_EmptyFile_ErrorNamesPathNotContents(t *testing.T) { + dir := t.TempDir() + emptyFP := filepath.Join(dir, "empty") + if err := os.WriteFile(emptyFP, []byte(""), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, err := resolveSecret("file:" + emptyFP) + if err == nil { + t.Fatal("want error for empty file-resolved secret, got nil") + } + if !strings.Contains(err.Error(), emptyFP) { + t.Fatalf("want error to name the path %q, got %q", emptyFP, err.Error()) + } +} + +// TestResolveSecret_ErrorNeverLeaksSecretValue is a regression test: across +// env and file resolution, on both the success and the failure path, the +// returned error (when non-nil) must never contain the resolved secret +// value — only the env var name or file path, which are config, not secrets. +func TestResolveSecret_ErrorNeverLeaksSecretValue(t *testing.T) { + const envSecret = "super-secret-env-value" + const fileSecret = "super-secret-file-value" + t.Setenv("LLMGW_LEAK_T", envSecret) + + dir := t.TempDir() + fp := filepath.Join(dir, "leak") + if err := os.WriteFile(fp, []byte(fileSecret+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + emptyFP := filepath.Join(dir, "empty") + if err := os.WriteFile(emptyFP, []byte(""), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cases := []struct { + name string + in string + }{ + {"env success", "env:LLMGW_LEAK_T"}, + {"env failure (unset)", "env:LLMGW_LEAK_MISSING"}, + {"file success", "file:" + fp}, + {"file failure (empty)", "file:" + emptyFP}, + {"file failure (missing)", "file:" + fp + ".nope"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := resolveSecret(c.in) + if err == nil { + return // success path: nothing was resolved, nothing to leak + } + for _, secret := range []string{envSecret, fileSecret} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("error leaks secret value: %v", err) + } + } + }) + } +}