Skip to content

Commit 7529656

Browse files
committed
loopdb: add Secret type for reading password from file
Introduce a Secret type that implements go-flags Unmarshaler to support reading sensitive values from files using @/path/to/file syntax. This allows the postgres password to be stored in a file rather than passed directly on the command line, avoiding exposure in process listings and shell history. Trailing whitespace (spaces, tabs, newlines) is automatically stripped from file contents. Fixes #1088 Signed-off-by: kaldun-tech <tsmereka@protonmail.com>
1 parent 787519e commit 7529656

5 files changed

Lines changed: 249 additions & 3 deletions

File tree

loopdb/postgres.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ type PostgresConfig struct {
3333
Host string `long:"host" description:"Database server hostname."`
3434
Port int `long:"port" description:"Database server port."`
3535
User string `long:"user" description:"Database user."`
36-
Password string `long:"password" description:"Database user's password."` //nolint:gosec
36+
Password Secret `long:"password" description:"Database user's password. Use @/path/to/file to read from a file."`
3737
DBName string `long:"dbname" description:"Database name to use."`
3838
MaxOpenConnections int32 `long:"maxconnections" description:"Max open connections to keep alive to the database server."`
3939
RequireSSL bool `long:"requiressl" description:"Whether to require using SSL (mode: require) when connecting to the server."`
@@ -46,7 +46,7 @@ func (s *PostgresConfig) DSN(hidePassword bool) string {
4646
sslMode = "require"
4747
}
4848

49-
password := s.Password
49+
password := string(s.Password)
5050
if hidePassword {
5151
// Placeholder used for logging the DSN safely.
5252
password = "****"

loopdb/postgres_fixture.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (f *TestPgFixture) GetConfig() *PostgresConfig {
113113
Host: f.host,
114114
Port: f.port,
115115
User: testPgUser,
116-
Password: testPgPass,
116+
Password: Secret(testPgPass),
117117
DBName: testPgDBName,
118118
RequireSSL: false,
119119
}

loopdb/secret.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package loopdb
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
)
8+
9+
// Secret is a string type that can unmarshal values from files when prefixed
10+
// with '@'. This allows sensitive values like passwords to be stored in files
11+
// rather than directly in configuration.
12+
type Secret string
13+
14+
// UnmarshalFlag implements go-flags Unmarshaler. If value starts with '@',
15+
// reads from file at that path. Otherwise uses value directly.
16+
func (s *Secret) UnmarshalFlag(value string) error {
17+
if strings.HasPrefix(value, "@") {
18+
filePath := value[1:]
19+
content, err := os.ReadFile(filePath)
20+
if err != nil {
21+
if os.IsNotExist(err) {
22+
return fmt.Errorf("secret file not found: %s",
23+
filePath)
24+
}
25+
if os.IsPermission(err) {
26+
return fmt.Errorf("unable to read secret "+
27+
"file (permission denied): %s",
28+
filePath)
29+
}
30+
31+
return fmt.Errorf("failed to read secret file %s: %w",
32+
filePath, err)
33+
}
34+
// Trim trailing whitespace (spaces, tabs, newlines) to handle
35+
// files created on Windows (CRLF) or Unix (LF), and to avoid
36+
// invisible trailing spaces causing authentication failures.
37+
*s = Secret(strings.TrimRight(string(content), " \t\r\n"))
38+
39+
return nil
40+
}
41+
*s = Secret(value)
42+
43+
return nil
44+
}

loopdb/secret_test.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package loopdb
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/jessevdk/go-flags"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// TestSecretUnmarshalFlag tests the Secret type's UnmarshalFlag method.
13+
func TestSecretUnmarshalFlag(t *testing.T) {
14+
t.Parallel()
15+
16+
t.Run("direct value", func(t *testing.T) {
17+
t.Parallel()
18+
19+
var s Secret
20+
err := s.UnmarshalFlag("mypassword")
21+
require.NoError(t, err)
22+
require.Equal(t, Secret("mypassword"), s)
23+
})
24+
25+
t.Run("empty value", func(t *testing.T) {
26+
t.Parallel()
27+
28+
var s Secret
29+
err := s.UnmarshalFlag("")
30+
require.NoError(t, err)
31+
require.Equal(t, Secret(""), s)
32+
})
33+
34+
t.Run("file reference", func(t *testing.T) {
35+
t.Parallel()
36+
37+
// Create a temp file with a password.
38+
tmpDir := t.TempDir()
39+
passFile := filepath.Join(tmpDir, "password.txt")
40+
err := os.WriteFile(passFile, []byte("secretpassword"), 0600)
41+
require.NoError(t, err)
42+
43+
var s Secret
44+
err = s.UnmarshalFlag("@" + passFile)
45+
require.NoError(t, err)
46+
require.Equal(t, Secret("secretpassword"), s)
47+
})
48+
49+
t.Run("file with trailing newline", func(t *testing.T) {
50+
t.Parallel()
51+
52+
tmpDir := t.TempDir()
53+
passFile := filepath.Join(tmpDir, "password.txt")
54+
err := os.WriteFile(passFile, []byte("secretpassword\n"), 0600)
55+
require.NoError(t, err)
56+
57+
var s Secret
58+
err = s.UnmarshalFlag("@" + passFile)
59+
require.NoError(t, err)
60+
require.Equal(t, Secret("secretpassword"), s)
61+
})
62+
63+
t.Run("file with CRLF", func(t *testing.T) {
64+
t.Parallel()
65+
66+
tmpDir := t.TempDir()
67+
passFile := filepath.Join(tmpDir, "password.txt")
68+
err := os.WriteFile(passFile, []byte("secretpassword\r\n"), 0600)
69+
require.NoError(t, err)
70+
71+
var s Secret
72+
err = s.UnmarshalFlag("@" + passFile)
73+
require.NoError(t, err)
74+
require.Equal(t, Secret("secretpassword"), s)
75+
})
76+
77+
t.Run("file with trailing whitespace", func(t *testing.T) {
78+
t.Parallel()
79+
80+
tmpDir := t.TempDir()
81+
passFile := filepath.Join(tmpDir, "password.txt")
82+
err := os.WriteFile(passFile, []byte("secretpassword \t\n"), 0600)
83+
require.NoError(t, err)
84+
85+
var s Secret
86+
err = s.UnmarshalFlag("@" + passFile)
87+
require.NoError(t, err)
88+
require.Equal(t, Secret("secretpassword"), s)
89+
})
90+
91+
t.Run("empty file", func(t *testing.T) {
92+
t.Parallel()
93+
94+
tmpDir := t.TempDir()
95+
passFile := filepath.Join(tmpDir, "password.txt")
96+
err := os.WriteFile(passFile, []byte(""), 0600)
97+
require.NoError(t, err)
98+
99+
var s Secret
100+
err = s.UnmarshalFlag("@" + passFile)
101+
require.NoError(t, err)
102+
require.Equal(t, Secret(""), s)
103+
})
104+
105+
t.Run("file with only newlines", func(t *testing.T) {
106+
t.Parallel()
107+
108+
tmpDir := t.TempDir()
109+
passFile := filepath.Join(tmpDir, "password.txt")
110+
err := os.WriteFile(passFile, []byte("\n\n\n"), 0600)
111+
require.NoError(t, err)
112+
113+
var s Secret
114+
err = s.UnmarshalFlag("@" + passFile)
115+
require.NoError(t, err)
116+
require.Equal(t, Secret(""), s)
117+
})
118+
119+
t.Run("file with newline in middle", func(t *testing.T) {
120+
t.Parallel()
121+
122+
tmpDir := t.TempDir()
123+
passFile := filepath.Join(tmpDir, "password.txt")
124+
err := os.WriteFile(passFile, []byte("pass\nword\n"), 0600)
125+
require.NoError(t, err)
126+
127+
var s Secret
128+
err = s.UnmarshalFlag("@" + passFile)
129+
require.NoError(t, err)
130+
require.Equal(t, Secret("pass\nword"), s)
131+
})
132+
133+
t.Run("file not found", func(t *testing.T) {
134+
t.Parallel()
135+
136+
var s Secret
137+
err := s.UnmarshalFlag("@/nonexistent/path/to/file")
138+
require.Error(t, err)
139+
require.Contains(t, err.Error(), "secret file not found")
140+
require.Contains(t, err.Error(), "/nonexistent/path/to/file")
141+
})
142+
143+
t.Run("at symbol only", func(t *testing.T) {
144+
t.Parallel()
145+
146+
// Just "@" means read from empty path, which should fail.
147+
var s Secret
148+
err := s.UnmarshalFlag("@")
149+
require.Error(t, err)
150+
})
151+
152+
t.Run("value starting with at but not file ref", func(t *testing.T) {
153+
t.Parallel()
154+
155+
// A value like "@myemail" would try to read file "myemail".
156+
// This should fail because that file doesn't exist.
157+
var s Secret
158+
err := s.UnmarshalFlag("@myemail")
159+
require.Error(t, err)
160+
require.Contains(t, err.Error(), "secret file not found")
161+
})
162+
}
163+
164+
// TestSecretGoFlagsIntegration tests that Secret works correctly with the
165+
// go-flags parser.
166+
func TestSecretGoFlagsIntegration(t *testing.T) {
167+
t.Parallel()
168+
169+
type Config struct {
170+
Password Secret `long:"password"`
171+
}
172+
173+
t.Run("direct value via flags", func(t *testing.T) {
174+
t.Parallel()
175+
176+
var cfg Config
177+
parser := flags.NewParser(&cfg, flags.Default)
178+
_, err := parser.ParseArgs([]string{"--password=directpass"})
179+
require.NoError(t, err)
180+
require.Equal(t, Secret("directpass"), cfg.Password)
181+
})
182+
183+
t.Run("file reference via flags", func(t *testing.T) {
184+
t.Parallel()
185+
186+
tmpDir := t.TempDir()
187+
passFile := filepath.Join(tmpDir, "password.txt")
188+
err := os.WriteFile(passFile, []byte("filepass\n"), 0600)
189+
require.NoError(t, err)
190+
191+
var cfg Config
192+
parser := flags.NewParser(&cfg, flags.Default)
193+
_, err = parser.ParseArgs([]string{"--password=@" + passFile})
194+
require.NoError(t, err)
195+
require.Equal(t, Secret("filepass"), cfg.Password)
196+
})
197+
}

release_notes.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ This file tracks release notes for the loop client.
1616

1717
#### New Features
1818

19+
* [Support reading database password from file](https://github.com/lightninglabs/loop/issues/1088).
20+
The `--postgres.password` flag now accepts a `@/path/to/file` syntax to read
21+
the password from a file instead of passing it directly. This avoids exposing
22+
secrets in process listings and shell history.
23+
1924
#### Breaking Changes
2025

2126
#### Bug Fixes

0 commit comments

Comments
 (0)