From b9be84e9e59499f12d2538e089c6eaa1330af19b Mon Sep 17 00:00:00 2001 From: tahmid-23 <60953955+tahmid-23@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:40:24 -0400 Subject: [PATCH] Percent-encode credentials in Hyperdrive connectionString Hyperdrive.connectionString is built by splicing the stored (decoded) user/password/database into a URL with no percent-encoding, so a structural character in a component produces an invalid or misparsed connection string. The common trigger is a '/' in a base64-derived password: consumers that parse the string (node-postgres / Prisma via pg-connection-string -> new URL()) throw "Invalid URL string.". The discrete .user/.password/.database getters are unaffected; only .connectionString is malformed. Encode each component with kj::encodeUriComponent, the RFC-3986 component encoder that Postgres and MySQL connection URIs percent-decode against (and the exact inverse of decodeURIComponent, so a literal '%' round-trips too). Adds hyperdrive-connection-string-test covering '/', '?', '#', '@' and '%' in the password plus '/' in the user and '?' in the database. Co-Authored-By: Claude Opus 4.8 --- src/workerd/api/hyperdrive.c++ | 8 +++- src/workerd/api/tests/BUILD.bazel | 6 +++ .../hyperdrive-connection-string-test.js | 48 +++++++++++++++++++ .../hyperdrive-connection-string-test.wd-test | 45 +++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/workerd/api/tests/hyperdrive-connection-string-test.js create mode 100644 src/workerd/api/tests/hyperdrive-connection-string-test.wd-test diff --git a/src/workerd/api/hyperdrive.c++ b/src/workerd/api/hyperdrive.c++ index 7792faadb3f..f91bab04309 100644 --- a/src/workerd/api/hyperdrive.c++ +++ b/src/workerd/api/hyperdrive.c++ @@ -94,8 +94,12 @@ kj::String Hyperdrive::getConnectionString() { // MySQL: `?ssl-mode=disabled` // PostgreSQL: `?sslmode=disable` auto sslParameter = scheme == "mysql" ? "?ssl-mode=disabled" : "?sslmode=disable"; - return kj::str(getScheme(), "://", getUser(), ":", getPassword(), "@", getHost(), ":", getPort(), - "/", getDatabase(), sslParameter); + // Components are stored decoded; percent-encode so a '/' or '%' in e.g. a + // base64 password can't corrupt the URI. encodeUriComponent (not + // encodeUriUserInfo, which leaves '%' unencoded). + return kj::str(getScheme(), "://", kj::encodeUriComponent(getUser()), ":", + kj::encodeUriComponent(getPassword()), "@", getHost(), ":", getPort(), "/", + kj::encodeUriComponent(getDatabase()), sslParameter); } kj::Promise> Hyperdrive::connectToDb() { diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index 8a2ee6dfb03..922b269cec8 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -141,6 +141,12 @@ wd_test( data = ["analytics-engine-test.js"], ) +wd_test( + src = "hyperdrive-connection-string-test.wd-test", + args = ["--experimental"], + data = ["hyperdrive-connection-string-test.js"], +) + wd_test( src = "http-standard-test.wd-test", args = ["--experimental"], diff --git a/src/workerd/api/tests/hyperdrive-connection-string-test.js b/src/workerd/api/tests/hyperdrive-connection-string-test.js new file mode 100644 index 00000000000..794c18d7619 --- /dev/null +++ b/src/workerd/api/tests/hyperdrive-connection-string-test.js @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { strictEqual, doesNotThrow } from 'node:assert'; + +// Hyperdrive's connectionString must percent-encode the credentials and +// database so a structural character ('/', '?', '#', '%') in a component can't +// corrupt the URI. +function roundTrip(bindingName, user, password, database) { + return { + test(_ctrl, env) { + const hd = env[bindingName]; + + strictEqual(hd.user, user, 'discrete .user'); + strictEqual(hd.password, password, 'discrete .password'); + strictEqual(hd.database, database, 'discrete .database'); + + const cs = hd.connectionString; + doesNotThrow(() => new URL(cs), `not a valid URL: ${cs}`); + const u = new URL(cs); + strictEqual(decodeURIComponent(u.username), user, `username from ${cs}`); + strictEqual(decodeURIComponent(u.password), password, `password from ${cs}`); + strictEqual( + decodeURIComponent(u.pathname.replace(/^\//, '')), + database, + `database from ${cs}` + ); + }, + }; +} + +const APP = 'app_user'; +const PW = 'plainpassword123'; +const DB = 'neondb'; + +export const control = roundTrip('PLAIN', APP, PW, DB); + +export const passwordSlash = roundTrip('PW_SLASH', APP, 'pa/ss', DB); +export const passwordQuestion = roundTrip('PW_QUESTION', APP, 'pa?ss', DB); +export const passwordHash = roundTrip('PW_HASH', APP, 'pa#ss', DB); +export const passwordAt = roundTrip('PW_AT', APP, 'pa@ss', DB); + +// A literal '%XX' must be encoded as %25..., else `decodeURIComponent` reads it +// as an escape (e.g. '%2F' -> '/'). +export const passwordPercent = roundTrip('PW_PERCENT', APP, 'pa%2Fss', DB); + +export const userSlash = roundTrip('USER_SLASH', 'ap/p', PW, DB); +export const databaseQuestion = roundTrip('DB_QUESTION', APP, PW, 'ne?on'); diff --git a/src/workerd/api/tests/hyperdrive-connection-string-test.wd-test b/src/workerd/api/tests/hyperdrive-connection-string-test.wd-test new file mode 100644 index 00000000000..94031e79857 --- /dev/null +++ b/src/workerd/api/tests/hyperdrive-connection-string-test.wd-test @@ -0,0 +1,45 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# Origin the bindings' `designator` requires; the test only reads binding +# properties (never connect()), so it is never contacted. +const dbWorker :Workerd.Worker = ( + compatibilityDate = "2024-09-23", + modules = [ + (name = "worker", esModule = + `export default { async fetch() { return new Response("db"); } }; + ), + ], +); + +const mainWorker :Workerd.Worker = ( + modules = [ + (name = "worker", esModule = embed "hyperdrive-connection-string-test.js"), + ], + compatibilityDate = "2024-09-23", + compatibilityFlags = ["nodejs_compat"], + bindings = [ + ( name = "PLAIN", hyperdrive = ( designator = "db", scheme = "postgresql", + user = "app_user", password = "plainpassword123", database = "neondb" ) ), + ( name = "PW_SLASH", hyperdrive = ( designator = "db", scheme = "postgresql", + user = "app_user", password = "pa/ss", database = "neondb" ) ), + ( name = "PW_QUESTION", hyperdrive = ( designator = "db", scheme = "postgresql", + user = "app_user", password = "pa?ss", database = "neondb" ) ), + ( name = "PW_HASH", hyperdrive = ( designator = "db", scheme = "postgresql", + user = "app_user", password = "pa#ss", database = "neondb" ) ), + ( name = "PW_AT", hyperdrive = ( designator = "db", scheme = "postgresql", + user = "app_user", password = "pa@ss", database = "neondb" ) ), + ( name = "PW_PERCENT", hyperdrive = ( designator = "db", scheme = "postgresql", + user = "app_user", password = "pa%2Fss", database = "neondb" ) ), + ( name = "USER_SLASH", hyperdrive = ( designator = "db", scheme = "postgresql", + user = "ap/p", password = "plainpassword123", database = "neondb" ) ), + ( name = "DB_QUESTION", hyperdrive = ( designator = "db", scheme = "postgresql", + user = "app_user", password = "plainpassword123", database = "ne?on" ) ), + ], +); + +const unitTests :Workerd.Config = ( + services = [ + (name = "main", worker = .mainWorker), + (name = "db", worker = .dbWorker), + ], +);