Skip to content

Commit d4a35d0

Browse files
VJ-yadavVijay Yadav
andcommitted
fix: URL-encode special characters in connection string passwords
Wire the existing (but uncalled) sanitizeConnectionString() into normalizeConfig() so all drivers using connection_string get automatic URL-encoding of special characters in passwords. Changes: - Fix regex to split on last @ (not first) so passwords containing @ are handled correctly - Add try-catch around decodeURIComponent for malformed percent sequences - Wire sanitizeConnectionString into normalizeConfig after alias resolution - Re-export sanitizeConnectionString from drivers package - 19 tests covering @, #, :, /, ? in passwords, already-encoded values, malformed URIs, mongodb schemes, and normalizeConfig integration Fixes #589 Co-Authored-By: Vijay Yadav <vijay@studentsucceed.com>
1 parent 99270e5 commit d4a35d0

3 files changed

Lines changed: 202 additions & 2 deletions

File tree

packages/drivers/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
export type { Connector, ConnectorResult, SchemaColumn, ConnectionConfig } from "./types"
33

44
// Re-export config normalization
5-
export { normalizeConfig } from "./normalize"
5+
export { normalizeConfig, sanitizeConnectionString } from "./normalize"
66

77
// Re-export driver connect functions
88
export { connect as connectPostgres } from "./postgres"

packages/drivers/src/normalize.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,67 @@ const DRIVER_ALIASES: Record<string, AliasMap> = {
111111
// duckdb and sqlite have simple configs — no aliases needed
112112
}
113113

114+
// ---------------------------------------------------------------------------
115+
// Connection string password encoding
116+
// ---------------------------------------------------------------------------
117+
118+
/**
119+
* URI-style connection strings (postgres://, mongodb://, etc.) embed
120+
* credentials in the userinfo section: `scheme://user:password@host/db`.
121+
* If the password contains special characters (@, #, :, /, ?, etc.) and
122+
* they are NOT percent-encoded, drivers will mis-parse the URI and fail
123+
* with cryptic auth errors.
124+
*
125+
* This function detects an unencoded password in the userinfo portion and
126+
* re-encodes it so the connection string is valid. Already-encoded
127+
* passwords (containing %XX sequences) are left untouched.
128+
*/
129+
export function sanitizeConnectionString(connectionString: string): string {
130+
// Match scheme://userinfo@host... — we only touch the userinfo part.
131+
// Schemes: postgresql, postgres, mongodb, mongodb+srv, clickhouse, http, https, redshift
132+
//
133+
// IMPORTANT: The password itself may contain '@' characters, so we must
134+
// split on the LAST '@' before the host portion. The regex below uses a
135+
// greedy (.+) for userinfo so it consumes everything up to the final '@'.
136+
const uriMatch = connectionString.match(
137+
/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)(.+)@([^@]+)$/,
138+
)
139+
if (!uriMatch) return connectionString // No userinfo section — nothing to fix
140+
141+
const [, scheme, userinfo, rest] = uriMatch
142+
143+
// Split userinfo into user:password on the FIRST colon only
144+
const colonIdx = userinfo.indexOf(":")
145+
if (colonIdx < 0) return connectionString // No password in userinfo
146+
147+
const user = userinfo.slice(0, colonIdx)
148+
const password = userinfo.slice(colonIdx + 1)
149+
150+
// If the password already contains percent-encoded sequences, assume
151+
// the caller encoded it properly and leave it alone.
152+
if (/%[0-9A-Fa-f]{2}/.test(password)) return connectionString
153+
154+
// Check if the password contains characters that need encoding.
155+
// RFC 3986 unreserved: A-Z a-z 0-9 - . _ ~
156+
// We also allow ! * ' ( ) which are sub-delimiters often safe in passwords.
157+
// Everything else (especially @ : / ? # [ ]) MUST be encoded.
158+
const needsEncoding = /[@:#/?[\]%]/.test(password)
159+
if (!needsEncoding) return connectionString
160+
161+
// Re-encode both user and password to be safe.
162+
// decodeURIComponent can throw on malformed percent sequences — fall back to
163+
// encoding the raw value if that happens.
164+
let encodedUser: string
165+
try {
166+
encodedUser = encodeURIComponent(decodeURIComponent(user))
167+
} catch {
168+
encodedUser = encodeURIComponent(user)
169+
}
170+
const encodedPassword = encodeURIComponent(password)
171+
172+
return `${scheme}${encodedUser}:${encodedPassword}@${rest}`
173+
}
174+
114175
// ---------------------------------------------------------------------------
115176
// Core logic
116177
// ---------------------------------------------------------------------------
@@ -178,6 +239,15 @@ export function normalizeConfig(config: ConnectionConfig): ConnectionConfig {
178239
const aliases = DRIVER_ALIASES[type]
179240
let result = aliases ? applyAliases(config, aliases) : { ...config }
180241

242+
// Sanitize connection_string: if the password contains special characters
243+
// (@, #, :, /, etc.) that are not percent-encoded, URI-based drivers will
244+
// mis-parse the string and fail with cryptic auth errors. This is the
245+
// single integration point — every caller of normalizeConfig() gets the
246+
// fix automatically.
247+
if (typeof result.connection_string === "string") {
248+
result.connection_string = sanitizeConnectionString(result.connection_string)
249+
}
250+
181251
// Type-specific post-processing
182252
// Note: MySQL SSL fields (ssl_ca, ssl_cert, ssl_key) are NOT constructed
183253
// into an ssl object here. They stay as top-level fields so the credential

packages/opencode/test/altimate/driver-normalize.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test"
2-
import { normalizeConfig } from "@altimateai/drivers"
2+
import { normalizeConfig, sanitizeConnectionString } from "@altimateai/drivers"
33
import { isSensitiveField } from "../../src/altimate/native/connections/credential-store"
44

55
// ---------------------------------------------------------------------------
@@ -947,3 +947,133 @@ describe("normalizeConfig — ClickHouse", () => {
947947
expect(result.ssl_key).toBeUndefined()
948948
})
949949
})
950+
951+
// ---------------------------------------------------------------------------
952+
// sanitizeConnectionString — special character encoding
953+
// ---------------------------------------------------------------------------
954+
955+
describe("sanitizeConnectionString", () => {
956+
test("encodes @ in password", () => {
957+
const input = "postgresql://testuser:t@st@localhost:5432/testdb"
958+
const result = sanitizeConnectionString(input)
959+
expect(result).toBe("postgresql://testuser:t%40st@localhost:5432/testdb")
960+
})
961+
962+
test("encodes # in password", () => {
963+
const input = "postgresql://testuser:test#val@localhost:5432/testdb"
964+
const result = sanitizeConnectionString(input)
965+
expect(result).toBe("postgresql://testuser:test%23val@localhost:5432/testdb")
966+
})
967+
968+
test("encodes : in password", () => {
969+
const input = "postgresql://testuser:test:val@localhost:5432/testdb"
970+
const result = sanitizeConnectionString(input)
971+
expect(result).toBe("postgresql://testuser:test%3Aval@localhost:5432/testdb")
972+
})
973+
974+
test("encodes multiple special characters", () => {
975+
const input = "postgresql://testuser:t@st#v:al@localhost:5432/testdb"
976+
const result = sanitizeConnectionString(input)
977+
expect(result).toBe("postgresql://testuser:t%40st%23v%3Aal@localhost:5432/testdb")
978+
})
979+
980+
test("encodes / in password", () => {
981+
const input = "postgresql://testuser:test/val@localhost:5432/testdb"
982+
const result = sanitizeConnectionString(input)
983+
expect(result).toBe("postgresql://testuser:test%2Fval@localhost:5432/testdb")
984+
})
985+
986+
test("encodes ? in password", () => {
987+
const input = "postgresql://testuser:test?val@localhost:5432/testdb"
988+
const result = sanitizeConnectionString(input)
989+
expect(result).toBe("postgresql://testuser:test%3Fval@localhost:5432/testdb")
990+
})
991+
992+
test("handles malformed percent sequence in username gracefully", () => {
993+
const input = "postgresql://bad%ZZuser:t@st@localhost:5432/testdb"
994+
const result = sanitizeConnectionString(input)
995+
// Should not throw — falls back to encoding the raw username
996+
expect(result).toContain("@localhost:5432/testdb")
997+
})
998+
999+
test("leaves already-encoded passwords untouched", () => {
1000+
const input = "postgresql://testuser:t%40st%23val@localhost:5432/testdb"
1001+
expect(sanitizeConnectionString(input)).toBe(input)
1002+
})
1003+
1004+
test("leaves passwords without special characters untouched", () => {
1005+
const input = "postgresql://testuser:simpletestval@localhost:5432/testdb"
1006+
expect(sanitizeConnectionString(input)).toBe(input)
1007+
})
1008+
1009+
test("leaves non-URI strings untouched", () => {
1010+
const input = "host=localhost dbname=mydb"
1011+
expect(sanitizeConnectionString(input)).toBe(input)
1012+
})
1013+
1014+
test("handles mongodb scheme", () => {
1015+
const input = "mongodb://testuser:t@st@localhost:27017/testdb"
1016+
const result = sanitizeConnectionString(input)
1017+
expect(result).toBe("mongodb://testuser:t%40st@localhost:27017/testdb")
1018+
})
1019+
1020+
test("handles mongodb+srv scheme", () => {
1021+
const input = "mongodb+srv://testuser:t@st@cluster.example.com/testdb"
1022+
const result = sanitizeConnectionString(input)
1023+
expect(result).toBe("mongodb+srv://testuser:t%40st@cluster.example.com/testdb")
1024+
})
1025+
1026+
test("leaves URIs without password untouched", () => {
1027+
const input = "postgresql://testuser@localhost:5432/testdb"
1028+
expect(sanitizeConnectionString(input)).toBe(input)
1029+
})
1030+
})
1031+
1032+
// ---------------------------------------------------------------------------
1033+
// normalizeConfig — connection_string sanitization integration
1034+
// ---------------------------------------------------------------------------
1035+
1036+
describe("normalizeConfig — connection_string sanitization", () => {
1037+
test("sanitizes connection_string with special chars in password", () => {
1038+
const result = normalizeConfig({
1039+
type: "postgres",
1040+
connection_string: "postgresql://testuser:t@st#val@localhost:5432/testdb",
1041+
})
1042+
expect(result.connection_string).toBe(
1043+
"postgresql://testuser:t%40st%23val@localhost:5432/testdb",
1044+
)
1045+
})
1046+
1047+
test("sanitizes connectionString alias with special chars", () => {
1048+
const result = normalizeConfig({
1049+
type: "postgres",
1050+
connectionString: "postgresql://testuser:t@st@localhost:5432/testdb",
1051+
})
1052+
// alias resolved to connection_string, then sanitized
1053+
expect(result.connection_string).toBe(
1054+
"postgresql://testuser:t%40st@localhost:5432/testdb",
1055+
)
1056+
expect(result.connectionString).toBeUndefined()
1057+
})
1058+
1059+
test("does not alter connection_string without special chars", () => {
1060+
const result = normalizeConfig({
1061+
type: "redshift",
1062+
connection_string: "postgresql://testuser:testval@localhost:5439/testdb",
1063+
})
1064+
expect(result.connection_string).toBe(
1065+
"postgresql://testuser:testval@localhost:5439/testdb",
1066+
)
1067+
})
1068+
1069+
test("does not alter config without connection_string", () => {
1070+
const result = normalizeConfig({
1071+
type: "postgres",
1072+
host: "localhost",
1073+
password: "t@st#val",
1074+
})
1075+
// Individual fields are NOT URI-encoded — drivers handle them natively
1076+
expect(result.password).toBe("t@st#val")
1077+
expect(result.connection_string).toBeUndefined()
1078+
})
1079+
})

0 commit comments

Comments
 (0)