@@ -111,6 +111,95 @@ 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+ // Only touch scheme://... URIs.
131+ const schemeMatch = connectionString . match ( / ^ [ a - z A - Z ] [ a - z A - Z 0 - 9 + . - ] * : \/ \/ / )
132+ if ( ! schemeMatch ) return connectionString
133+
134+ const scheme = schemeMatch [ 0 ]
135+ const afterScheme = connectionString . slice ( scheme . length )
136+
137+ // Find the userinfo/host separator. A password can legitimately contain
138+ // '@', '#', '/', '?', or ':' characters, so the URI spec is ambiguous
139+ // when those are unencoded. We use the LAST '@' as the separator because
140+ // that correctly handles the common case of passwords with special
141+ // characters (the stated purpose of this function — see issue #589).
142+ //
143+ // The known trade-off: if the query string or path also contains an
144+ // unencoded '@' (e.g. `?email=alice@example.com`), the rightmost '@'
145+ // is NOT the userinfo separator. We detect this ambiguous case below
146+ // with a guard and bail to avoid corrupting the URI.
147+ const lastAt = afterScheme . lastIndexOf ( "@" )
148+ if ( lastAt < 0 ) return connectionString // No userinfo — nothing to fix
149+
150+ const beforeAt = afterScheme . slice ( 0 , lastAt )
151+ const afterAt = afterScheme . slice ( lastAt + 1 )
152+
153+ // Ambiguity guard: if the content AFTER the '@' has no path/query/
154+ // fragment delimiter ('/', '?', or '#') but the content BEFORE the
155+ // '@' does, then the '@' is almost certainly inside a path, query,
156+ // or fragment — not the userinfo separator. Bail and leave the URI
157+ // untouched so the caller can pre-encode explicitly.
158+ //
159+ // Examples that trigger the guard (correctly left alone):
160+ // postgresql://u:p@host /db?email=alice@example.com ('@' in query)
161+ // postgresql://u:p@host :5432/db@archive ('@' in path)
162+ // postgresql://u:p@host /db#at@frag ('@' in fragment)
163+ //
164+ // Examples that pass the guard (correctly encoded):
165+ // postgresql://u:p@ss @host /db (last '@' has '/' after)
166+ // postgresql://u:f@ke #PLACEHOLDER@host/db (last '@' has '/' after)
167+ const afterAtHasDelim = / [ / ? # ] / . test ( afterAt )
168+ const beforeAtHasDelim = / [ / ? # ] / . test ( beforeAt )
169+ if ( ! afterAtHasDelim && beforeAtHasDelim ) {
170+ return connectionString
171+ }
172+
173+ // Idempotent re-encoding: decode any existing percent-escapes and
174+ // re-encode. Already-encoded values round-trip unchanged; raw values
175+ // with special characters get encoded. Malformed percent sequences
176+ // fall back to encoding the raw input.
177+ const encodeIdempotent = ( v : string ) : string => {
178+ if ( v . length === 0 ) return v
179+ try {
180+ return encodeURIComponent ( decodeURIComponent ( v ) )
181+ } catch {
182+ return encodeURIComponent ( v )
183+ }
184+ }
185+
186+ // Split userinfo on the FIRST ':' only (password may contain ':').
187+ const colonIdx = beforeAt . indexOf ( ":" )
188+ let encodedUserinfo : string
189+ if ( colonIdx < 0 ) {
190+ // Username-only userinfo: still encode if it contains special chars
191+ // (e.g. email addresses used as usernames).
192+ encodedUserinfo = encodeIdempotent ( beforeAt )
193+ } else {
194+ const user = beforeAt . slice ( 0 , colonIdx )
195+ const password = beforeAt . slice ( colonIdx + 1 )
196+ encodedUserinfo = `${ encodeIdempotent ( user ) } :${ encodeIdempotent ( password ) } `
197+ }
198+
199+ const rebuilt = `${ scheme } ${ encodedUserinfo } @${ afterAt } `
200+ return rebuilt === connectionString ? connectionString : rebuilt
201+ }
202+
114203// ---------------------------------------------------------------------------
115204// Core logic
116205// ---------------------------------------------------------------------------
@@ -178,6 +267,15 @@ export function normalizeConfig(config: ConnectionConfig): ConnectionConfig {
178267 const aliases = DRIVER_ALIASES [ type ]
179268 let result = aliases ? applyAliases ( config , aliases ) : { ...config }
180269
270+ // Sanitize connection_string: if the password contains special characters
271+ // (@, #, :, /, etc.) that are not percent-encoded, URI-based drivers will
272+ // mis-parse the string and fail with cryptic auth errors. This is the
273+ // single integration point — every caller of normalizeConfig() gets the
274+ // fix automatically.
275+ if ( typeof result . connection_string === "string" ) {
276+ result . connection_string = sanitizeConnectionString ( result . connection_string )
277+ }
278+
181279 // Type-specific post-processing
182280 // Note: MySQL SSL fields (ssl_ca, ssl_cert, ssl_key) are NOT constructed
183281 // into an ssl object here. They stay as top-level fields so the credential
0 commit comments