Uh oh!
There was an error while loading. Please reload this page.
fix(security): a bound credentialsRef reaches the postgres SERVER on the DSN branch, not just the knex config (#8873) - #9090
Merged
os-project-manager merged 2 commits intoAug 16, 2026
Conversation
…the DSN branch (#8873) The postgres arm emitted `{ connectionString: url, password: spec.secret }`, which is correct at the knex-config layer and discarded one layer lower: `pg` merges `parse(config.connectionString)` OVER the explicit config, so the injected credential never reached the handshake. Measured on pg 8.22.0 + knex 3.3.0, a credential-free DSN with a secret bound resolved to password `null`; a stored pre-#8082 url embedding a password resolved to that url's own password instead of the bound one. Two independent mechanisms destroyed it: `parse()` emits a `password` key for every url (`''` when there is no userinfo password) which `Object.assign` copies over the injected value, and knex's `setHiddenProperty` has already made `password` non-enumerable, which `Object.assign` does not copy at all. On the DSN branch, and only when a secret is bound, `connectionString` is dropped: the arm hands `pg` its own parse of the url (`pg-connection-string`, the client's parser, so no second `postgresql://` dialect lives in this repo) with the credential applied after it, where nothing re-parses over it. A datasource that binds no secret is byte-for-byte unchanged. Not fixed by symmetry with either sibling arm: `mysql2` lets the explicit key win and mongodb rides in `options.auth`, while `pg` lets the DSN win. The competing userinfo-splice remedy was measured and rejected — a stored `?password=` beats userinfo in `pg-connection-string`, and it would put the cleartext credential into a string knex does not hide. The pin asserts on what the `pg` client resolves, never on the connection object the factory built: that assertion passed throughout this defect's life. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
…icted then measured 8 failed / 3 passed with the pre-fix branch restored, case for case as predicted: the five credential cases red on the resolved password (null for the credential-free urls, someone else's credential for the two stored rows), the equivalence sweep red on its separate injected-value assertion, the connectionString-absence mechanism pin red, and the unparseable-DSN refusal red by a different route — the old branch never parses, so it never throws. Green throughout: the no-secret passthrough and both discrete-branch controls, which is the branch-local shape this file needs to be measuring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
Contributor
📓 Docs Drift CheckNo hand-written docs reference the 1 changed package(s). ✅ |
This was referenced Aug 16, 2026
os-project-manager
marked this pull request as ready for review
August 16, 2026 11:40
Uh oh!
There was an error while loading. Please reload this page.
os-project-manager
deleted the
claude/issue-8873-pg-dsn-secret-override
branch
August 16, 2026 11:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#8873
A postgres datasource whose
config.urlis a DSN and whose credential is bound throughexternal.credentialsRefopened its connection with no password at all. Severity as ratified at triage: a broken binding, not a disclosure — the operator sees a bound credential and a datasource reporting connected, while the handshake carried nothing.The measurement the card asked for
The card named two candidate remedies and deliberately declined to choose, asking for the measurement first. Here it is, driven against
pg8.22.0's ownConnectionParametersand knex 3.3.0 (no connection opened anywhere).Why the arm looked correct
{ connectionString, password }loses the credential twice over, by two independent mechanisms, either sufficient alone:parse()emits apasswordkey for every url — measured""for a credential-free DSN, not absent — andObject.assigncopies it over the injected value.val('password', …)then reads"", falls through toPGPASSWORDand the defaults, and resolvesnull.setHiddenPropertymakespassworda non-enumerable own property ofconnectionSettings, andObject.assigncopies only enumerable ones — so it never reaches the merge at all. (This one is not in the card; it is what makes the real knex→pg path lose the secret even before the parse.)Measured before the fix:
Since #8082 refuses a
user:password@userinfo at the publish door, the credential-free DSN is the only authorable URL shape — so this is the shape the connection form produces.The DSN shapes the card asked to be measured, before and after
config.urlpostgresql://app@db.internal:5432/app(credential-free — the only #8082-authorable shape)nullpostgresql://app:embedded-legacy@db.internal:5432/app(stored pre-#8082 row)'embedded-legacy'postgresql://db.internal:5432/app(names no user)nullpostgresql://app@db.internal:5432/app?password=…(stored pre-#8337 row)'from-query-param'Candidate B — re-serialise the userinfo — was measured and rejected
Two counts, both measured:
It does not even fix the defect.
pg-connection-stringhonours a?password=query parameter over userinfo (the reason turso:?authToken=in an authoredconfig.url/config.syncUrlquery string is credential material the #8082 userinfo refusal does not cover #8337 refuses that spelling at authoring). A stored pre-turso:?authToken=in an authoredconfig.url/config.syncUrlquery string is credential material the #8082 userinfo refusal does not cover #8337 row with the secret spliced into the userinfo still resolves tofrom-query-param— the bound credential still loses.It would materialise the cleartext credential into a string nothing hides. Measured on knex 3.3.0,
JSON.stringify(client.connectionSettings):That is also the shape [Decision] URL-embedded credentials (
user:password@hostin driverconfig.url) remain a live cleartext door after #7990 — refuse at publish, or accept as residual risk? #8082 refuses to let anyone author andredactUrlPasswordexists to scrub — synthesising it at connect time pushes the platform's hardest-to-redact credential spelling back into circulation.The fix
On the DSN branch, and only when a secret is bound,
connectionStringis dropped: the arm handspgpg's own parse of the url (pg-connection-string— the client's parser, so no second dialect ofpostgresql://…lives in this repo to drift out of agreement) with the credential applied after it, where nothing re-parses over it.⛔ Not reached by symmetry with either sibling, which is the card's central point:
mysql2lets the explicit key win ({ uri, password }, #8875), mongodb rides inoptions.authbeside an untouched url (#9042),pglets the DSN win. Three clients, three shapes, each measured against its own client. The two stale ⛔ comments the sibling fixes left at the mysql and mongo arms — which described this arm as a live defect — are updated to say how it was fixed.Equivalence, verified key-by-key. Dropping
connectionStringmeanspgno longer reads the url itself, so every non-credential key it contributed has to arrive by the new route unchanged. Compared across the sslmode, unix-socket,?options=,?application_name=, credential-free, embedded-password and no-userinfo forms, onuser/database/port/host/ssl/application_name/statement_timeout/options/connect_timeout/client_encoding: identical in every case. That sweep is pinned in the test file, not just done once here.Two deliberate decisions worth review:
MongoClientcannot carry a password without a username and{username:''}would turn a working anonymous connection into a guaranteed failure, whereaspgsends a password only when the server asks for one — so injecting cannot break a datasource that connects today, and refusing would silently drop a credential the operator bound. Making that contradictory pair loud belongs at the authoring door, Nothing refuses the contradictory pair "external.credentialsRefbound + aconfig.urlnaming no user" — the binding is a silent no-op at connect #9041, which this card lands before.Invalid URLon first query. Measured:parseandnew ConnectionParametersboth throwERR_INVALID_URLon a multi-host DSN, so such a datasource has never been able to connect — the failure moves earlier and gets named, it is not invented. Bothcreate()call sites already turn a throw into a located datasource failure rather than a boot crash. The url is deliberately not echoed in the message: it may itself embed a credential, which is whypgredacts it in its own error.The pin asserts at the client-resolution layer
Inherited from #9042's review and the single most important constraint on this card: every credential assertion reads what the
pgclient resolved — via thepgmodule knex itself will use (knex.client.driver.Client) fed the exactconnectionSettingsknex will hand it. Nothing asserts on the connection object the factory built, because that assertion passed throughout this defect's entire life. The one config-layer assertion in the file is about the mechanism instead (connectionStringis absent once a secret is bound), and its comment says so.new Client(config)resolvesconnectionParametersin its constructor and does no I/O, which is what makes the seam assertable without a server.Reverse verification — predicted in writing, then measured
Predicted before running, with the pre-fix branch restored over the tests at their fixed state: 8 failed / 3 passed, and specifically which. Measured exactly that set, case for case:
The two stored-row failures are the sharper direction: the credential is not merely missing, it is someone else's. The three green are the three that must not move — the defect is branch-local, and an arm-wide regression would mean the file measures something else. The ablation was restored from the commit and proven byte-identical (
git hash-objecton the restored file equalsgit rev-parseof the same path at HEAD).New dependency
pg-connection-string(^2.14.0, MIT, zero runtime dependencies) joins@objectstack/service-datasource'sdependencies. It is pg's own parser, and using it rather than modelling it is the whole safety argument: the fields handed topgare the fieldspgwould have derived, by construction. It also carries the parts a partial parse would silently drop — that package copies every query parameter into the config, which is how?sslmode=,?application_name=,?options=and?connect_timeout=reach the client at all. Confirmed externalized rather than bundled in both build outputs. Three sibling services already carry third-party dependencies (ioredis,croner,@noble/ciphers), so this is not a new kind of thing forpackages/services.Verification
All at
e36872f42, the final commit, with the tree clean.Gates — the two the dispatch named, plus everything
node scripts/pm/dispatch-gates.mjsadded for the actual changed paths (five changeset-driven families the path list could not see, and five convention-triggered families that adding a test file moves). All green:Not run locally:
check:type-check-debt --re-measure. It needs the whole workspace closure built and re-runstscper ledger entry —service-datasourcecarries no entry (it declares a realtypecheckscript and passes it), so nothing in this diff can move that ratchet. Its structural half is green above. CI runs it either way.Scope note: #8874 is not addressed here — the mysql arm of this same file stays queued and unassigned, per the dispatch. Nothing in this diff touches
buildMysqlConnectionbeyond correcting the stale ⛔ comment that called this arm a live defect.Generated by Claude Code