Skip to content

fix: percent-encode userinfo in database URL construction - #363

Merged
mikebronner merged 3 commits into
mainfrom
fix/362-percent-encode-userinfo
Aug 29, 2026
Merged

mikebronner merged 3 commits into
mainfrom
fix/362-percent-encode-userinfo

Conversation

@mikebronner

@mikebronner mikebronner commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #362. database::userinfo spliced the .env username and password into a driver://… connection URL raw. sqlx parses every connection string with Url::parse, which ends the authority at the first /, ? or # whether or not that character was meant as a delimiter, so a credential holding one escaped its own slot.

Both failure modes were reproduced against sqlx 0.9 before the fix:

password result today
p/ss Err(Configuration(InvalidPort)) — connection fails, no useful diagnostic
12/34 Ok(host "sail", port 12, user "root", no password)
1234/56, 012345/aG9zdG5hbWU=, 12?34, 1234?56, 12#34, 1234#56 same silent misparse

The second row is the dangerous one: nothing errors. The connection goes to a host and port assembled out of the password, the username falls back to root, and any redactor that locates the credential span by parsing the URL is told there is no password to mask. openssl rand -base64 output mixes digits and / routinely, so this is an ordinary password shape.

Changes

  • encode_userinfo_component (new, database.rs) — percent-encodes one userinfo component. Passes through exactly RFC 3986 unreserved (ALPHA DIGIT - . _ ~) and sub-delims (! $ & ' ( ) * + , ; =); every other byte becomes %XX, including :, /, ?, #, [, ], @, %, space, control bytes, and each byte of a non-ASCII UTF-8 sequence. No new dependency: the crate set needed is not any percent-encoding preset (NON_ALPHANUMERIC would encode the sub-delims and move URLs that work today), so a custom set was required either way.
  • userinfo now routes both components through it. The username is encoded symmetrically with the password, : included — userinfo overloads : as its own separator, so an unencoded one there mis-splits exactly as an unencoded / mis-splits the authority.
  • Its docblock, which said escaping "is the caller's concern … would risk double-encoding", now records why encoding is correct here: it is applied once at the single point of construction, on a value that arrives raw from .env, and % self-encodes.
  • Postgres unix-socket authority (separate commit 99ce9ac) — see below.
  • 20 tests, every one settled through the driver's own parser.

All four call sites that build a URL through userinfo — build_mysql_candidates' socket and TCP branches, build_postgres_candidates' socket and TCP branches — route through the one function; there is no fifth credential splice in the tree (sqlite: carries no credentials, and SQL Server goes through tiberius::AuthMethod::sql_server, which takes the fields structurally). DB_URL is passed through verbatim and never re-encoded.

The extra commit: the Postgres socket candidate never connected

Writing the AC's per-call-site test surfaced a defect independent of this one. build_postgres_candidates emitted postgres://user@/db?host=/path, and the WHATWG URL rules sqlx's Url::parse implements reject an empty host that follows a userinfo component:

postgres://u:p@/testdb?host=/tmp/.s.PGSQL.5432  => Err(Configuration(EmptyHost))
postgres://u@/testdb?host=/tmp/.s.PGSQL.5432    => Err(Configuration(EmptyHost))
postgres://u:p@localhost/testdb?host=/tmp/...   => Ok(host "localhost", socket "/tmp/...", user "u")

That candidate could never connect, for any username, credential-independent. The existing coverage matched ?host= in the URL string, which an unparseable URL satisfies just as well. The AC requires this branch to parse back correctly, so the fix ships here: splice the same inert localhost placeholder the MySQL socket branch already uses. It is committed separately (99ce9ac) so it can be read on its own.

Deliberately out of scope: config.database and the socket path are still spliced raw. Neither is a credential, both are a different class from the one this issue names, and encoding them would move URLs for every user. Flagging rather than folding in.

Acceptance Criteria

  • database::userinfo() percent-encodes every byte outside unreserved/sub-delims, identically for username and password, : in the username included.
  • All four call sites receive the encoded value. No caller splices a raw credential.
  • A password containing /, ?, # or @ now parses via MySqlConnectOptions/PgConnectOptions::from_str, yielding the intended username and password — no parse error, no misread host/port.
  • Credentials built only from legal characters produce a byte-for-byte identical URL (userinfo_leaves_legal_characters_byte_for_byte pins both the component and the assembled URL). The empty-password no-trailing-colon behavior is preserved.
  • A literal % always encodes to %25; sec%3Dret round-trips to the literal sec%3Dret, never sec=ret.
  • The DB_URL passthrough is untouched, both drivers (db_url_passthrough_is_never_re_encoded).
  • Tests cover (a) /, ?, #, @ in a password; (b) all three issue digit-run shapes plus the ? and # equivalents; (c) reserved characters in the username; (d) one test per production call site building a real ConnCandidate, asserting the credential and the trailing socket=/host= value both parse back.

Test Plan

  • cargo test — 3,491 pass, 0 fail.
  • cargo clippy --all-targets — no warnings.
  • cargo fmt --check — clean.
  • Password assertions use a differential technique, since neither options type exposes a password accessor: to_url_lossy() of the parsed options against to_url_lossy() of the same options (a clone, so every other field is identical) with the expected password set. Equality holds exactly when the parsed password is the expected one.
  • Mutation-verified. Each of these reddens at least one new test, and the source was restored and re-run green after every round:
mutation killed by
revert userinfo to the raw splice 11 tests
encode the password only, username raw 4 tests
let % through the pass-through set userinfo_encodes_percent_so_hex_pairs_do_not_decode
let : through 3 tests
let / through 8 tests
over-encode the legal sub-delim + userinfo_leaves_legal_characters_byte_for_byte
revert the Postgres socket authority postgres_socket_candidate_round_trips_credentials_and_socket
emit a trailing colon for an empty password 4 tests
substitute a wrong expected password in both helpers all 7 helper-using tests

Fixes #362

`build_postgres_candidates` emitted `postgres://user@/db?host=/path` for the
unix-socket candidate. The WHATWG URL rules that sqlx's `Url::parse`
implements reject an empty host following a userinfo component, so
`PgConnectOptions::from_str` returned `Configuration(EmptyHost)` and the
candidate never connected — for every username, credential-independent.

Splice the same inert `localhost` placeholder the MySQL socket branch already
uses. sqlx reads `host=` after the authority and stores it as the socket, so
the parsed options carry socket `/tmp/.s.PGSQL.5432` with host `localhost`.

The existing coverage matched `?host=…` in the URL *string*, which an
unparseable URL satisfies just as well; the new test hands the candidate to
the real parser. Verified by mutation: reverting the authority reddens the new
test and leaves the old one green.

Refs #362
`userinfo` spliced the `.env` username and password into a `driver://…` URL
raw. sqlx routes every connection string through `Url::parse`, which ends the
authority at the first `/`, `?` or `#` regardless of intent, so a credential
holding one escaped its own slot two ways:

- `p/ss` produced `mysql://user:p/ss@host:3306/db`, whose authority is
  `user:p` — the connection failed with *invalid port number*, no useful
  diagnostic.
- Where the run before the delimiter was all digits and fit a `u16`, the
  authority parsed instead. Every shape from the issue resolved to host
  `sail`, the digits as the port, the default `root` user, and no password at
  all: the connection silently went elsewhere, and a redactor that locates the
  credential span by parsing the URL is told there is nothing to mask. Base64
  passwords mix digits and `/` routinely, so this is an ordinary shape.

`encode_userinfo_component` passes through exactly RFC 3986's `unreserved` and
`sub-delims` sets and percent-encodes every other byte, so credentials made of
legal characters produce byte-identical URLs to before. `:` is encoded in the
username too, where the grammar permits it, because `userinfo` overloads `:`
as its own separator. `%` encodes to `%25`, so `sec%3Dret` round-trips
literally rather than decoding to `sec=ret`. All four call sites — both
MySQL and both Postgres branches — route through the one function; `DB_URL` is
passed through verbatim and never re-encoded.

Every new test settles its claim through `MySqlConnectOptions` /
`PgConnectOptions::from_str` rather than string comparison. Neither type
exposes a password accessor, so the password is pinned differentially against
`to_url_lossy()` of the same options with the expected password set.

Mutation-verified: reverting the encoding, encoding only the password,
admitting `%`, `:` or `/` into the pass-through set, over-encoding a legal
sub-delim, and dropping the empty-password shape each redden at least one
test, as does substituting a wrong expected password in the helpers.

Fixes #362
@mikebronner
mikebronner marked this pull request as ready for review August 29, 2026 15:02

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved

Review Summary

userinfo now routes both components through encode_userinfo_component, closing the class where a .env credential holding a delimiter escaped its own slot. All seven acceptance criteria are met. CI green on all seven checks; 111 database::tests pass, including the 14 new ones.

The encode set is exactly right. database.rs:479-490 passes through precisely RFC 3986 unreserved + sub-delims and nothing else. : is correctly excluded from pass-through in both components — the AC's reasoning holds, since userinfo overloads : as its own separator, so an unencoded one in a username mis-splits exactly as an unencoded / mis-splits the authority. Iteration is over raw.bytes(), so non-ASCII UTF-8 encodes per byte, and byte as char is safe because the pass-through arm only ever matches single ASCII bytes.

The invariant class is swept, not spot-fixed. This is the part I checked hardest, because it is this repo's most-repeated defect — the guard landing at one call site and not the sibling beside it (#294 path_within_root, #348 rounds 1 and 2 on masking, #353 escalated at round 4). I enumerated it independently rather than trusting the summary: six ConnCandidate construction sites, four route through userinfo (:2271, :2290, :2333, :2346), two are the AC-excluded DB_URL passthroughs. The only other credential use in the tree is AuthMethod::sql_server (:2582), which takes fields structurally and never parses a URL; sqlite: carries no credentials. There is no fifth splice. The class is closed.

The tests are honest. The differential password assertion deserved scrutiny — if to_url_lossy() redacted the password, both sides would compare equal for any value and twenty tests would prove nothing. It doesn't: build_url() does utf8_percent_encode(self.password, NON_ALPHANUMERIC) and embeds the real password, and that encoding is injective, so two distinct passwords cannot collide. Neither options type exposes a password getter, so the technique is necessary rather than gratuitous. Every parse test checks real fields; nothing asserts bare is_ok(); the call-site tests build real ConnCandidates through the production functions instead of re-implementing URL assembly. The mutation table in the PR description is corroborated by the tests actually present.

Noted divergence — adjudicated, not waived

Commit 99ce9ac splices a localhost placeholder into the Postgres unix-socket authority, which no AC bullet names literally. This is AC-mandated, not scope creep. Criterion 7(d) requires a test per production call site — both branches of build_postgres_candidates — confirming the credential and the trailing host= value parse back correctly. That branch emitted postgres://user@/db?host=…, which WHATWG rules reject as EmptyHost for every username, credential-independent. The criterion was unsatisfiable without the fix. Shipping it as a separate reviewable commit was the right call, and host= is read after the authority so the placeholder is inert.

📋 Non-blocking follow-ups

  • The DB_URL passthrough (config.url) still reaches mask_url_credentials unencoded, so a user-typed raw @ in that value can leave its password tail visible at database.rs:1191 — Noted, not tracked. Three reasons it stays a note: the AC explicitly requires config.url be passed through verbatim, so it is excluded by contract; the behavior is documented as a deliberate best-effort tradeoff at completion_display.rs:105-109; and it is already tracked by #355 with PR #358 in flight against it. Opening an anchor here would duplicate active work, and #355 is In Progress — expanding it now would move the goalposts mid-build.
  • config.database and the socket path are still spliced raw. Correctly flagged rather than folded in: neither is a credential, and encoding them would move URLs for every existing user.

Ready for @mikebronner to merge.

@mikebronner
mikebronner merged commit 6998616 into main Aug 29, 2026
7 checks passed
@mikebronner
mikebronner deleted the fix/362-percent-encode-userinfo branch August 29, 2026 16:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Percent-encode userinfo in database::userinfo — raw /, ?, # in a password break connections and defeat redaction

1 participant