Skip to content

Commit ab0e63c

Browse files
authored
fix: sanitize go-migrate error containing credentials (#487)
* fix: sanitize go-migrate error containing credentials * chore: gofmt * fix: lint * chore: remove unused func * fix: regex * chore: accept auth-less urls
1 parent ecf1770 commit ab0e63c

File tree

4 files changed

+739
-2
lines changed

4 files changed

+739
-2
lines changed

internal/migrator/migrator.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ func New(opts MigrationOpts) (*Migrator, error) {
3232
return nil, fmt.Errorf("failed to get migration driver: %w", err)
3333
}
3434

35-
m, err := migrate.NewWithSourceInstance("iofs", d, opts.databaseURL())
35+
dbURL := opts.databaseURL()
36+
m, err := migrate.NewWithSourceInstance("iofs", d, dbURL)
3637
if err != nil {
37-
return nil, fmt.Errorf("migrate.New: %w", err)
38+
// Sanitize the error to prevent credential exposure in logs
39+
// The original error from golang-migrate may contain the full database URL
40+
// with credentials, which would be exposed if this error is logged.
41+
return nil, sanitizeConnectionError(err, dbURL)
3842
}
3943

4044
return &Migrator{

internal/migrator/migrator_test.go

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
package migrator
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/hookdeck/outpost/internal/util/testutil"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestMigrator_CredentialExposure_Integration is an INTEGRATION TEST
14+
//
15+
// PURPOSE: Verify that database connection errors don't expose credentials in logs.
16+
//
17+
// WHY THIS TEST EXISTS:
18+
// The migrator.New() function calls migrate.NewWithSourceInstance() with a database URL
19+
// that contains credentials. When connection fails, the golang-migrate library includes
20+
// the full URL in its error message, potentially exposing passwords in logs.
21+
//
22+
// POTENTIAL EXPOSURE SCENARIOS IN New():
23+
// 1. Network errors: "dial tcp: connect: connection refused" with full URL
24+
// 2. Auth failures: "authentication failed for user X" with password in URL
25+
// 3. Invalid URL format: "parse postgres://user:pass@host" errors
26+
// 4. DNS failures: "no such host" with credentials in URL
27+
// 5. TLS/SSL errors: Certificate validation failures with full connection string
28+
//
29+
// NOTE: Methods like Up(), Down(), Force() won't expose credentials because they
30+
// operate on an already-established connection and don't reference the URL anymore.
31+
//
32+
// Run with: go test ./internal/migrator
33+
// Skip with: go test -short ./internal/migrator
34+
func TestMigrator_CredentialExposure_Integration(t *testing.T) {
35+
testutil.Integration(t) // Skip if running with -short flag
36+
37+
tests := []struct {
38+
name string
39+
opts MigrationOpts
40+
shouldFailValidation bool
41+
checkError func(t *testing.T, err error)
42+
}{
43+
{
44+
name: "PostgreSQL - network connection failure",
45+
opts: MigrationOpts{
46+
PG: MigrationOptsPG{
47+
// Using port 54321 - very unlikely to have a real DB here
48+
// This simulates network-level connection failure
49+
URL: "postgres://dbuser:SuperSecret123!@localhost:54321/testdb?sslmode=disable",
50+
},
51+
},
52+
shouldFailValidation: false,
53+
checkError: func(t *testing.T, err error) {
54+
// We expect an error since the DB port is likely closed
55+
require.Error(t, err, "Should fail to connect to non-existent database")
56+
57+
// The error should NOT contain the actual password
58+
assert.NotContains(t, err.Error(), "SuperSecret123!",
59+
"Error message exposed PostgreSQL password")
60+
// It should also not contain the credentials portion
61+
assert.NotContains(t, err.Error(), "dbuser:SuperSecret123!",
62+
"Error message exposed PostgreSQL credentials")
63+
64+
// The error SHOULD provide useful context about the failure type
65+
// (e.g., "connection refused", "network error", etc.)
66+
assert.NotEqual(t, "migrate.New: failed to initialize database connection", err.Error(),
67+
"Error should provide more context than generic message")
68+
},
69+
},
70+
{
71+
name: "ClickHouse - connection timeout scenario",
72+
opts: MigrationOpts{
73+
CH: MigrationOptsCH{
74+
// Using non-routable IP to simulate timeout
75+
// 192.0.2.0/24 is reserved for documentation (RFC 5737)
76+
Addr: "192.0.2.1:9000",
77+
Username: "admin",
78+
Password: "VerySecretPass456$",
79+
Database: "analytics_db",
80+
},
81+
},
82+
shouldFailValidation: false,
83+
checkError: func(t *testing.T, err error) {
84+
// We expect an error due to timeout/unreachable host
85+
require.Error(t, err, "Should fail to connect to unreachable host")
86+
87+
// The error should NOT contain the actual password
88+
assert.NotContains(t, err.Error(), "VerySecretPass456$",
89+
"Error message exposed ClickHouse password")
90+
// It should also not contain the formatted credentials
91+
assert.NotContains(t, err.Error(), "admin:VerySecretPass456$",
92+
"Error message exposed ClickHouse credentials")
93+
},
94+
},
95+
{
96+
name: "PostgreSQL - malformed URL with special characters",
97+
opts: MigrationOpts{
98+
PG: MigrationOptsPG{
99+
// Invalid URL format to test parse errors
100+
// The ":invalid:port" will cause a parse error
101+
URL: "postgres://user:P@ssw0rd!#$%@:invalid:port/dbname",
102+
},
103+
},
104+
shouldFailValidation: false,
105+
checkError: func(t *testing.T, err error) {
106+
require.Error(t, err, "Should fail with invalid URL format")
107+
108+
// Even parse errors should not expose the password
109+
assert.NotContains(t, err.Error(), "P@ssw0rd!#$%",
110+
"Error message exposed password with special characters")
111+
assert.NotContains(t, err.Error(), "user:P@ssw0rd",
112+
"Error message exposed credentials in parse error")
113+
},
114+
},
115+
}
116+
117+
for _, tt := range tests {
118+
t.Run(tt.name, func(t *testing.T) {
119+
// First, verify the databaseURL() method creates URLs with credentials
120+
// This confirms we're actually testing something that could leak
121+
dbURL := tt.opts.databaseURL()
122+
if tt.opts.PG.URL != "" {
123+
assert.Contains(t, dbURL, "postgres://",
124+
"Expected PostgreSQL URL")
125+
if strings.Contains(tt.opts.PG.URL, ":") && strings.Contains(tt.opts.PG.URL, "@") {
126+
// If the URL has credentials, verify they're present in the generated URL
127+
parts := strings.Split(tt.opts.PG.URL, "@")
128+
if len(parts) > 1 && strings.Contains(parts[0], ":") {
129+
credParts := strings.Split(parts[0], ":")
130+
if len(credParts) >= 3 {
131+
password := credParts[2]
132+
password = strings.TrimPrefix(password, "//")
133+
assert.Contains(t, dbURL, password,
134+
"Test setup: password should be in the database URL")
135+
}
136+
}
137+
}
138+
} else if tt.opts.CH.Addr != "" {
139+
assert.Contains(t, dbURL, "clickhouse://",
140+
"Expected ClickHouse URL")
141+
assert.Contains(t, dbURL, tt.opts.CH.Password,
142+
"Test setup: password should be in the database URL")
143+
}
144+
145+
// Now test that New() doesn't expose credentials in errors
146+
// THIS WILL ACTUALLY TRY TO CONNECT TO THE DATABASE
147+
migrator, err := New(tt.opts)
148+
149+
if tt.shouldFailValidation {
150+
require.Error(t, err, "Expected validation error")
151+
assert.Nil(t, migrator, "Migrator should be nil on validation error")
152+
}
153+
154+
// Check that any error doesn't contain credentials
155+
tt.checkError(t, err)
156+
157+
// Clean up if migrator was created successfully
158+
if migrator != nil {
159+
migrator.Close(context.Background())
160+
}
161+
})
162+
}
163+
}
164+
165+
// TestMigrator_DatabaseURLGeneration is a UNIT TEST
166+
// It tests the databaseURL() method without any external dependencies.
167+
// This verifies that the method correctly builds URLs with credentials,
168+
// which confirms we have something that needs protection.
169+
func TestMigrator_DatabaseURLGeneration(t *testing.T) {
170+
tests := []struct {
171+
name string
172+
opts MigrationOpts
173+
expectedURL string
174+
hasPassword bool
175+
}{
176+
{
177+
name: "PostgreSQL URL is passed through as-is",
178+
opts: MigrationOpts{
179+
PG: MigrationOptsPG{
180+
URL: "postgres://user:password123@localhost:5432/mydb?sslmode=disable",
181+
},
182+
},
183+
expectedURL: "postgres://user:password123@localhost:5432/mydb?sslmode=disable",
184+
hasPassword: true,
185+
},
186+
{
187+
name: "ClickHouse URL is constructed with credentials",
188+
opts: MigrationOpts{
189+
CH: MigrationOptsCH{
190+
Addr: "localhost:9000",
191+
Username: "admin",
192+
Password: "secret123",
193+
Database: "outpost",
194+
},
195+
},
196+
expectedURL: "clickhouse://admin:secret123@localhost:9000/outpost?x-multi-statement=true",
197+
hasPassword: true,
198+
},
199+
{
200+
name: "Empty options return empty string",
201+
opts: MigrationOpts{},
202+
expectedURL: "",
203+
hasPassword: false,
204+
},
205+
}
206+
207+
for _, tt := range tests {
208+
t.Run(tt.name, func(t *testing.T) {
209+
result := tt.opts.databaseURL()
210+
assert.Equal(t, tt.expectedURL, result)
211+
212+
if tt.hasPassword {
213+
// Verify that passwords are indeed present in the URL
214+
// This confirms the security risk we're trying to address
215+
if tt.opts.PG.URL != "" && strings.Contains(tt.opts.PG.URL, "password") {
216+
assert.Contains(t, result, "password",
217+
"Password should be in the database URL (this is the problem we need to fix)")
218+
}
219+
if tt.opts.CH.Password != "" {
220+
assert.Contains(t, result, tt.opts.CH.Password,
221+
"Password should be in the database URL (this is the problem we need to fix)")
222+
}
223+
}
224+
})
225+
}
226+
}
227+
228+
// TestMigrator_URLSanitization is a UNIT TEST
229+
// It demonstrates how URLs should be sanitized to remove credentials.
230+
// This is a blueprint for the actual implementation.
231+
func TestMigrator_URLSanitization(t *testing.T) {
232+
testCases := []struct {
233+
name string
234+
url string
235+
expected []string // Things that should NOT appear in sanitized version
236+
allowed []string // Things that should still appear
237+
}{
238+
{
239+
name: "PostgreSQL with password",
240+
url: "postgres://user:password123@localhost:5432/mydb?sslmode=disable",
241+
expected: []string{"password123", ":password123@"},
242+
allowed: []string{"localhost", "5432", "mydb"},
243+
},
244+
{
245+
name: "PostgreSQL with special characters in password",
246+
url: "postgres://user:p@ss!word%20123@localhost:5432/mydb",
247+
expected: []string{"p@ss!word%20123", ":p@ss!word%20123@"},
248+
allowed: []string{"localhost", "5432", "mydb"},
249+
},
250+
{
251+
name: "ClickHouse URL format",
252+
url: "clickhouse://admin:[email protected]/outpost?x-multi-statement=true",
253+
expected: []string{"secret123", ":secret123@", "admin:secret123"},
254+
allowed: []string{"clickhouse.example.com", "outpost", "x-multi-statement=true"},
255+
},
256+
{
257+
name: "URL with no password",
258+
url: "postgres://user@localhost:5432/mydb",
259+
expected: []string{}, // Nothing to redact
260+
allowed: []string{"user", "localhost", "5432", "mydb"},
261+
},
262+
}
263+
264+
for _, tc := range testCases {
265+
t.Run(tc.name, func(t *testing.T) {
266+
// Simulate what a sanitization function should do
267+
sanitized := sanitizeURLForTesting(tc.url)
268+
269+
// Check that sensitive data is not present
270+
for _, sensitive := range tc.expected {
271+
assert.NotContains(t, sanitized, sensitive,
272+
"Sanitized URL should not contain: %s", sensitive)
273+
}
274+
275+
// Check that non-sensitive data is preserved
276+
for _, expected := range tc.allowed {
277+
assert.Contains(t, sanitized, expected,
278+
"Sanitized URL should still contain: %s", expected)
279+
}
280+
})
281+
}
282+
}
283+
284+
// Helper function to demonstrate how URL sanitization should work
285+
// This is what the actual implementation should do
286+
func sanitizeURLForTesting(dbURL string) string {
287+
// This is a simplified version - the actual implementation
288+
// would be more robust
289+
if strings.Contains(dbURL, "postgres://") || strings.Contains(dbURL, "clickhouse://") {
290+
// Find and replace password patterns
291+
if idx := strings.Index(dbURL, "://"); idx >= 0 {
292+
afterScheme := dbURL[idx+3:]
293+
if atIdx := strings.Index(afterScheme, "@"); atIdx >= 0 {
294+
userInfo := afterScheme[:atIdx]
295+
if colonIdx := strings.Index(userInfo, ":"); colonIdx >= 0 {
296+
// Has password - replace it
297+
user := userInfo[:colonIdx]
298+
return dbURL[:idx+3] + user + ":[REDACTED]" + dbURL[idx+3+atIdx:]
299+
}
300+
}
301+
}
302+
}
303+
return dbURL
304+
}

0 commit comments

Comments
 (0)