Skip to content

Commit c183c4b

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. Fixes Issue #1088 Signed-off-by: kaldun-tech <tsmereka@protonmail.com>
1 parent 787519e commit c183c4b

5 files changed

Lines changed: 234 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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 newlines. Trim both \r and \n to handle
35+
// files created on Windows (CRLF) or Unix (LF).
36+
*s = Secret(strings.TrimRight(string(content), "\r\n"))
37+
38+
return nil
39+
}
40+
*s = Secret(value)
41+
42+
return nil
43+
}

loopdb/secret_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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("empty file", 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(""), 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(""), s)
89+
})
90+
91+
t.Run("file with only newlines", 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("\n\n\n"), 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 newline in middle", 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("pass\nword\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("pass\nword"), s)
117+
})
118+
119+
t.Run("file not found", func(t *testing.T) {
120+
t.Parallel()
121+
122+
var s Secret
123+
err := s.UnmarshalFlag("@/nonexistent/path/to/file")
124+
require.Error(t, err)
125+
require.Contains(t, err.Error(), "secret file not found")
126+
require.Contains(t, err.Error(), "/nonexistent/path/to/file")
127+
})
128+
129+
t.Run("at symbol only", func(t *testing.T) {
130+
t.Parallel()
131+
132+
// Just "@" means read from empty path, which should fail.
133+
var s Secret
134+
err := s.UnmarshalFlag("@")
135+
require.Error(t, err)
136+
})
137+
138+
t.Run("value starting with at but not file ref", func(t *testing.T) {
139+
t.Parallel()
140+
141+
// A value like "@myemail" would try to read file "myemail".
142+
// This should fail because that file doesn't exist.
143+
var s Secret
144+
err := s.UnmarshalFlag("@myemail")
145+
require.Error(t, err)
146+
require.Contains(t, err.Error(), "secret file not found")
147+
})
148+
}
149+
150+
// TestSecretGoFlagsIntegration tests that Secret works correctly with the
151+
// go-flags parser.
152+
func TestSecretGoFlagsIntegration(t *testing.T) {
153+
t.Parallel()
154+
155+
type Config struct {
156+
Password Secret `long:"password"`
157+
}
158+
159+
t.Run("direct value via flags", func(t *testing.T) {
160+
t.Parallel()
161+
162+
var cfg Config
163+
parser := flags.NewParser(&cfg, flags.Default)
164+
_, err := parser.ParseArgs([]string{"--password=directpass"})
165+
require.NoError(t, err)
166+
require.Equal(t, Secret("directpass"), cfg.Password)
167+
})
168+
169+
t.Run("file reference via flags", func(t *testing.T) {
170+
t.Parallel()
171+
172+
tmpDir := t.TempDir()
173+
passFile := filepath.Join(tmpDir, "password.txt")
174+
err := os.WriteFile(passFile, []byte("filepass\n"), 0600)
175+
require.NoError(t, err)
176+
177+
var cfg Config
178+
parser := flags.NewParser(&cfg, flags.Default)
179+
_, err = parser.ParseArgs([]string{"--password=@" + passFile})
180+
require.NoError(t, err)
181+
require.Equal(t, Secret("filepass"), cfg.Password)
182+
})
183+
}

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)