Skip to content

Commit 82e64b1

Browse files
Copilotintel352
andauthored
Fix DSN parsing error for special characters in passwords (#20)
* Initial plan * Fix DSN parsing error for special characters in passwords Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * Fix linter formatting errors in aws_iam_auth.go Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * Fix gofmt formatting issues in dsn_special_chars_test.go Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
1 parent ccdefa7 commit 82e64b1

3 files changed

Lines changed: 223 additions & 2 deletions

File tree

modules/database/aws_iam_auth.go

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,13 @@ func extractEndpointFromDSN(dsn string) (string, error) {
177177
// Handle different DSN formats
178178
if strings.Contains(dsn, "://") {
179179
// URL-style DSN (e.g., postgres://user:password@host:port/database)
180-
u, err := url.Parse(dsn)
180+
// Handle potential special characters in password by preprocessing
181+
preprocessedDSN, err := preprocessDSNForParsing(dsn)
182+
if err != nil {
183+
return "", fmt.Errorf("failed to preprocess DSN: %w", err)
184+
}
185+
186+
u, err := url.Parse(preprocessedDSN)
181187
if err != nil {
182188
return "", fmt.Errorf("failed to parse DSN URL: %w", err)
183189
}
@@ -203,11 +209,71 @@ func extractEndpointFromDSN(dsn string) (string, error) {
203209
return "", ErrExtractEndpointFailed
204210
}
205211

212+
// preprocessDSNForParsing handles special characters in passwords by URL-encoding them
213+
func preprocessDSNForParsing(dsn string) (string, error) {
214+
// Find the pattern: ://username:password@host
215+
protocolEnd := strings.Index(dsn, "://")
216+
if protocolEnd == -1 {
217+
return dsn, nil // Not a URL-style DSN
218+
}
219+
220+
// Find the start of credentials (after ://)
221+
credentialsStart := protocolEnd + 3
222+
223+
// Find the end of credentials (before @host)
224+
// We need to find the last @ that separates credentials from host
225+
// Look for the pattern @host:port or @host/path
226+
remainingDSN := dsn[credentialsStart:]
227+
228+
// Find all @ characters
229+
atIndices := []int{}
230+
for i := 0; i < len(remainingDSN); i++ {
231+
if remainingDSN[i] == '@' {
232+
atIndices = append(atIndices, i)
233+
}
234+
}
235+
236+
if len(atIndices) == 0 {
237+
return dsn, nil // No credentials
238+
}
239+
240+
// Use the last @ as the separator between credentials and host
241+
atIndex := atIndices[len(atIndices)-1]
242+
243+
// Extract the credentials part
244+
credentialsEnd := credentialsStart + atIndex
245+
credentials := dsn[credentialsStart:credentialsEnd]
246+
247+
// Find the colon that separates username from password
248+
colonIndex := strings.Index(credentials, ":")
249+
if colonIndex == -1 {
250+
return dsn, nil // No password
251+
}
252+
253+
// Extract username and password
254+
username := credentials[:colonIndex]
255+
password := credentials[colonIndex+1:]
256+
257+
// URL-encode the password
258+
encodedPassword := url.QueryEscape(password)
259+
260+
// Reconstruct the DSN with encoded password
261+
encodedDSN := dsn[:credentialsStart] + username + ":" + encodedPassword + dsn[credentialsEnd:]
262+
263+
return encodedDSN, nil
264+
}
265+
206266
// replaceDSNPassword replaces the password in a DSN with the provided token
207267
func replaceDSNPassword(dsn, token string) (string, error) {
208268
if strings.Contains(dsn, "://") {
209269
// URL-style DSN
210-
u, err := url.Parse(dsn)
270+
// Handle potential special characters in password by preprocessing
271+
preprocessedDSN, err := preprocessDSNForParsing(dsn)
272+
if err != nil {
273+
return "", fmt.Errorf("failed to preprocess DSN: %w", err)
274+
}
275+
276+
u, err := url.Parse(preprocessedDSN)
211277
if err != nil {
212278
return "", fmt.Errorf("failed to parse DSN URL: %w", err)
213279
}

modules/database/aws_iam_auth_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,24 @@ func TestExtractEndpointFromDSN(t *testing.T) {
114114
expected: "mydb.cluster-xyz.us-east-1.rds.amazonaws.com:5432",
115115
wantErr: false,
116116
},
117+
{
118+
name: "postgres URL style with special characters in password",
119+
dsn: "postgresql://someuser:8jKwouNHdI!u6a?kx(UuQ-Bgm34P@some-dev-backend.cluster.us-east-1.rds.amazonaws.com/some_backend",
120+
expected: "some-dev-backend.cluster.us-east-1.rds.amazonaws.com",
121+
wantErr: false,
122+
},
123+
{
124+
name: "postgres URL style with URL-encoded special characters in password",
125+
dsn: "postgresql://someuser:8jKwouNHdI%21u6a%3Fkx%28UuQ-Bgm34P@some-dev-backend.cluster.us-east-1.rds.amazonaws.com/some_backend",
126+
expected: "some-dev-backend.cluster.us-east-1.rds.amazonaws.com",
127+
wantErr: false,
128+
},
129+
{
130+
name: "postgres URL style with complex special characters in password",
131+
dsn: "postgres://user:p@ssw0rd!#$^&*()_+-=[]{}|;':\",./<>@host.example.com:5432/db",
132+
expected: "host.example.com:5432",
133+
wantErr: false,
134+
},
117135
{
118136
name: "invalid DSN",
119137
dsn: "invalid-dsn",
@@ -167,6 +185,18 @@ func TestReplaceDSNPassword(t *testing.T) {
167185
expected: "host=localhost port=5432 user=postgres dbname=mydb password=test-iam-token",
168186
wantErr: false,
169187
},
188+
{
189+
name: "postgres URL style with special characters in password",
190+
dsn: "postgresql://someuser:8jKwouNHdI!u6a?kx(UuQ-Bgm34P@some-dev-backend.cluster.us-east-1.rds.amazonaws.com/some_backend",
191+
expected: "postgresql://someuser:test-iam-token@some-dev-backend.cluster.us-east-1.rds.amazonaws.com/some_backend",
192+
wantErr: false,
193+
},
194+
{
195+
name: "postgres URL style with complex special characters in password",
196+
dsn: "postgres://user:p@ssw0rd!#$^&*()_+-=[]{}|;':\",./<>@host.example.com:5432/db",
197+
expected: "postgres://user:test-iam-token@host.example.com:5432/db",
198+
wantErr: false,
199+
},
170200
{
171201
name: "URL style without user info",
172202
dsn: "postgres://host:5432/mydb",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package database
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
// TestSpecialCharacterPasswordDSNParsing tests the specific issue from the GitHub issue #19
10+
func TestSpecialCharacterPasswordDSNParsing(t *testing.T) {
11+
// This is the exact DSN from the GitHub issue
12+
issueExampleDSN := "postgresql://someuser:8jKwouNHdI!u6a?kx(UuQ-Bgm34P@some-dev-backend.cluster.us-east-1.rds.amazonaws.com/some_backend"
13+
14+
// Test that endpoint extraction works
15+
endpoint, err := extractEndpointFromDSN(issueExampleDSN)
16+
require.NoError(t, err)
17+
require.Equal(t, "some-dev-backend.cluster.us-east-1.rds.amazonaws.com", endpoint)
18+
19+
// Test that password replacement works
20+
token := "test-iam-token"
21+
newDSN, err := replaceDSNPassword(issueExampleDSN, token)
22+
require.NoError(t, err)
23+
require.Contains(t, newDSN, "postgresql://someuser:test-iam-token@some-dev-backend.cluster.us-east-1.rds.amazonaws.com/some_backend")
24+
25+
// Test that we can create a database service with this DSN (without actually connecting)
26+
config := ConnectionConfig{
27+
Driver: "postgres",
28+
DSN: issueExampleDSN,
29+
}
30+
31+
service, err := NewDatabaseService(config)
32+
require.NoError(t, err)
33+
require.NotNil(t, service)
34+
35+
// Clean up
36+
err = service.Close()
37+
require.NoError(t, err)
38+
}
39+
40+
// TestSpecialCharacterPasswordDSNParsingWithAWSIAM tests the issue with AWS IAM auth
41+
func TestSpecialCharacterPasswordDSNParsingWithAWSIAM(t *testing.T) {
42+
// This is the exact DSN from the GitHub issue
43+
issueExampleDSN := "postgresql://someuser:8jKwouNHdI!u6a?kx(UuQ-Bgm34P@some-dev-backend.cluster.us-east-1.rds.amazonaws.com/some_backend"
44+
45+
// Test that we can create a database service with AWS IAM auth enabled
46+
config := ConnectionConfig{
47+
Driver: "postgres",
48+
DSN: issueExampleDSN,
49+
AWSIAMAuth: &AWSIAMAuthConfig{
50+
Enabled: true,
51+
Region: "us-east-1",
52+
DBUser: "someuser",
53+
TokenRefreshInterval: 300,
54+
},
55+
}
56+
57+
// Skip this test if AWS credentials are not available
58+
service, err := NewDatabaseService(config)
59+
if err != nil {
60+
// If AWS config loading fails, skip this test
61+
if err.Error() == "failed to create AWS IAM token provider: failed to load AWS config: no EC2 IMDS role found, operation error ec2imds: GetMetadata, canceled, context canceled" {
62+
t.Skip("AWS credentials not available, skipping test")
63+
}
64+
t.Fatalf("Failed to create service: %v", err)
65+
}
66+
require.NotNil(t, service)
67+
68+
// Clean up
69+
err = service.Close()
70+
require.NoError(t, err)
71+
}
72+
73+
// TestEdgeCaseSpecialCharacterPasswords tests various edge cases
74+
func TestEdgeCaseSpecialCharacterPasswords(t *testing.T) {
75+
testCases := []struct {
76+
name string
77+
dsn string
78+
expectedHost string
79+
}{
80+
{
81+
name: "password with @ symbol",
82+
dsn: "postgres://user:pass@word@host.com:5432/db",
83+
expectedHost: "host.com:5432",
84+
},
85+
{
86+
name: "password with multiple @ symbols",
87+
dsn: "postgres://user:p@ss@w@rd@host.com:5432/db",
88+
expectedHost: "host.com:5432",
89+
},
90+
{
91+
name: "password with query-like characters",
92+
dsn: "postgres://user:pass?key=value&other=test@host.com:5432/db",
93+
expectedHost: "host.com:5432",
94+
},
95+
{
96+
name: "password with URL-like structure",
97+
dsn: "postgres://user:http://example.com/path?query=value@host.com:5432/db",
98+
expectedHost: "host.com:5432",
99+
},
100+
{
101+
name: "password with colon",
102+
dsn: "postgres://user:pass:word@host.com:5432/db",
103+
expectedHost: "host.com:5432",
104+
},
105+
}
106+
107+
for _, tc := range testCases {
108+
t.Run(tc.name, func(t *testing.T) {
109+
endpoint, err := extractEndpointFromDSN(tc.dsn)
110+
require.NoError(t, err)
111+
require.Equal(t, tc.expectedHost, endpoint)
112+
113+
// Test password replacement
114+
token := "test-token"
115+
newDSN, err := replaceDSNPassword(tc.dsn, token)
116+
require.NoError(t, err)
117+
require.Contains(t, newDSN, token)
118+
119+
// Verify we can parse the new DSN
120+
newEndpoint, err := extractEndpointFromDSN(newDSN)
121+
require.NoError(t, err)
122+
require.Equal(t, tc.expectedHost, newEndpoint)
123+
})
124+
}
125+
}

0 commit comments

Comments
 (0)