fix(server,sdk): stop returning connection credentials from the API - #1071
fix(server,sdk): stop returning connection credentials from the API#1071mlennie wants to merge 8 commits into
Conversation
Sha-Bang traced this while reviewing #1047, and the chain there is the one fixed here: processConfigValue deep-walks the config so a ${MALLOY_X_PASSWORD} reference becomes the real secret, and the status builder then spreads each connection into the response while stripping exactly one key, attributes. A connection config holds warehouse credentials, and every response that carried a connection was built by spreading one, so a Postgres password, a BigQuery service-account key and a Snowflake private key were readable over unauthenticated REST. Eight endpoints returned them: GET /status, the five /environments reads and writes that answer with a serialized environment, and both connection reads. The same spread put an entire connection config into a log line in config.ts when a config entry was missing its name. Responses now carry an allowlist of fields per connection type, applied at the three places that build one: Environment.serialize and the two connection controller reads. The internal view keeps its credentials, because compiling and connecting need them. The status builder's own spread is deliberately left alone. getStatus reads environments through listEnvironments, which is serialize(), so by the time it strips attributes it is spreading an already-projected connection. That is a data-flow argument rather than a local one, and those stop being true when somebody refactors the builder, so an integration test pins the behaviour at the endpoint rather than trusting the reasoning. That test boots a real server with real credentials and asserts none of them appears in any response body from any endpoint, or in the server log. Written by behaviour rather than by location on purpose: the unit specs pin the three call sites that exist today and say nothing about a fourth added later. The credential reaches it as a ${VAR} reference in the config, the way the docs recommend storing one, so the substitution step is covered too. It also asserts through withheldFields that all three credentials really did load, because a config that failed to parse would otherwise make every leak assertion pass while proving nothing. An allowlist rather than a denylist of secret-looking names, because the failure modes are asymmetric: an unlisted field is simply absent, so a credential added to a future connection type is invisible until someone publishes it deliberately, while the cost of forgetting a non-secret field is only that the UI does not show it yet. The contract cannot supply the list on its own: api-doc.yaml declares password as a property of PostgresConnection because one schema serves both the write path and the read path. connection_public_view.spec.ts holds the list against that contract, including the schemas DucklakeConnection declares inline, so a property added to any of them fails the suite until someone classifies it. It also walks $refs out from Connection and requires every schema it reaches to be registered, because the parity checks iterate a hand-maintained map and would otherwise say nothing about the next connection type somebody forgets to add to it. Credentials are omitted, not masked. A placeholder would be sent back as the real credential by any client that resubmits what it read. That makes the rest of this change a prerequisite rather than an extra, because a caller can no longer echo back a credential it never received. Both write paths now carry forward what a response withholds, derived from the same shape as the projection so the two cannot drift. The per-connection PATCH merged with a shallow top-level spread, so sending postgresConnection without password replaced the sub-object and destroyed the stored password. The environment PATCH was worse: it replaces the whole connections list, which is what the connections UI sends for every add, edit and delete, so one edit stripped the credentials of every connection in the environment and answered 200. Both were already true before this change; the editor hid them by reading credentials back out of the response, which is the leak. Verified against a running server both ways. Supplying a credential for one method drops the stored credential of the alternative, rather than accumulating both. Reinstating a remnant would silently win, because the connect path picks by presence: a stale connectionString beats new host/port fields, a stale privateKey routes to key-pair auth, peakaKey short circuits before password is read, stored S3 beats new GCS, and the Databricks driver prefers OAuth over a token. Those are the five such slots in the connect path, enumerated from it rather than sampled, and all five are covered. A slot is selected only by a CREDENTIAL the caller actually supplied, non-empty. Both halves of that are load-bearing and both were learned by getting it wrong first. An empty string is an untouched form box rather than a choice, and reading one as a choice destroyed the only credential of any connection described solely by a field the form cannot display, answering 200. And a non-secret field is echoed back by any client that resubmits what it read, so it says nothing about intent: oauthClientId came back on every Databricks write and kept the OAuth slot selected forever. The rules match by field name at every object level, and several of those names recur across schemas, so there are tests that an ssh-proxy private key and a mysql or trino password are NOT dropped when a patch edits a host or a sibling field. An explicit null clears a sub-object; an empty object keeps what is hidden inside it, since erring toward keeping a credential beats erring toward destroying one. Responses also name what they withheld. withheldFields carries the dotted paths of the stored credentials, names only, because a client otherwise cannot tell a credential that is set from one that was never configured, and so cannot tell whether an empty box keeps something or leaves the connection with nothing. The connection editor needs exactly that to know a Postgres connection is described by a connection string it cannot read. Shape lookups are own-property reads. A stored config carrying a __proto__ key otherwise resolved to Object.prototype instead of "unlisted", and one level deeper to null, which threw; that runs on the read path, so a single POST could have made every connection read for the environment 500. The editor no longer checks for credentials it cannot read, and says a blank box keeps the stored value. Presence is validated server-side against the merged config, which already rejects a connection with no usable credential. Scope checked rather than assumed: a multi-line scan of every logger call in packages/server/src found one spreading a connection object, the one fixed here. All six connection-object spreads in the server were reviewed and five are left unmodified, because each needs the credentials: config load, the database-backed restore, the persistence layer, the internal clone, and the update merge. Only getStatus's is on a response path, and it is now fed already-projected data. Renaming a DuckDB attached database still requires re-entering its secret, since entries are matched by name; that and the fact that write access to a connection reaches its credential are both documented in docs/connections.md. Left alone as pre-existing and separate: deleting a connection through the environment PATCH drops it from memory without pruning its stored row, so it returns on a restart. That predates this change and wants its own fix. Signed-off-by: Monty Lennie <montylennie@gmail.com>
…er the legacy aliases Two gaps in the sweep this branch added, both found by review rather than by it failing. It never checked the status code, so a path that 404s returns a body with no secret in it and the sweep reports that route clean without ever reaching it. That also means it would have kept reporting clean if a route were renamed, which is the case it most needs to catch. Each swept path now has to answer 200. It also only swept the /environments paths, while the legacy /projects aliases are registered on the same app and answer from the same serializer. They were already covered by the fix, since they call the same controller and serializer, but nothing was pinning that. Counting the surface from the route table rather than from the paths I happened to think of: fifteen registrations can return a connection, seven under /environments, seven legacy /projects aliases, and /status. Signed-off-by: Monty Lennie <montylennie@gmail.com>
Sha-Bang
left a comment
There was a problem hiding this comment.
the redaction is coherent — one projection, one allowlist, three call sites, and hiddenFields/reinstate derived from the same shape so what the API hides is exactly what an update preserves.
I stood it up rather than only reading it: built the branch, booted a server with ${VAR}-referenced postgres and snowflake credentials, and drove the connections UI in a browser. Gate here — typecheck and lint clean, unit 3252 pass, integration 313 pass with one failure (concurrent_package, GCS seed) that reproduces identically on origin/main, so not yours. Two counterfactuals hold: reverting the serialize() projection turns the sweep red, allowlisting password turns 11 specs red. A third does not, and that's blocking finding 1. Also, the body says 389 integration tests; the suite collects 314 here.
two blocking, both in the half of this change that is a prerequisite rather than an extra:
- Add the environment
PATCHto the credential sweep. The merge atenvironment.ts:431— the path the app writes through, and the worst bug this PR fixes — is covered by nothing. I reverted it and the whole suite stayed green. - Gate the editor's "stored value" text on
withheldFields. Right now a snowflake connection that stores only a password tells the operator a private key and a passphrase are stored too. The field this PR added to make that distinguishable is read in exactly one place, and it isn't this one.
three non-blocking inline. Separately, one pre-existing bug I tripped over that isn't this PR's: deleting a connection in the app does nothing. Connections.tsx:212 reads if (!conn.resource) deleteConnection.mutateAsync(payload) — inverted, and every API connection carries resource, so the branch never fires and the user gets "Cannot delete this connection". Identical on main. It only touches this PR because resource is in your allowlist; keeping it there keeps the behaviour as-is. Worth its own fix.
…xes from withheldFields Sha-Bang's review of #1071. Two blocking items and three inline ones. The environment PATCH merge was covered by nothing. It is the path the app writes through for every add, edit and delete, and the worst defect this branch fixes, but reverting the merge left both the unit suite and the new credential sweep green: the unit spec exercises mergeConnectionUpdate against literals, and the sweep only reached the per-connection PATCH. So the claim that the sweep is behaviour-based, and would therefore catch a fourth call site, was not true of the second call site it already had. The sweep now GETs the connection list, PATCHes it back to the environment with one connection appended, and asserts every original connection still reports its withheldFields. It goes red on that revert and on nothing else. The editor did not read the field added for it. helperText and the placeholder keyed off the field NAME being credential-shaped, so a snowflake connection storing only a password labelled Private Key and Private Key Passphrase "Stored value is not shown. Leave empty to keep it." as well, and postgres said it of Connection String. That is the confusion withheldFields exists to remove, and api-doc.yaml already says an editor should read it to label the boxes that are filled. There is now one reader of withheldFields in that file, a Set keyed by dotted path, used by both the render and the postgres connection-string check; a box with nothing behind it says "No value stored." The DuckLake storage secrets had the same unconditional promise and are fixed with the same set. The storage slot selected on s3Connection / gcsConnection / azureConnection, but those sub-objects come back on every read carrying region, endpoint and accessKeyId, so a client that round-tripped a read and added a GCS block selected two slots, exclusionsFor bailed on the ambiguity, and the stale secretAccessKey was reinstated. hasS3 beats GCS at connect time, so the connection kept authenticating against the old store. That is the same failure the selects docstring says the design avoids, so the docstring was true of four slots out of five. Selection now names the nested credential and isSupplied walks one dotted level. A caller that also echoes back the old sub-object's non-secret fields now gets a loud attach error rather than the wrong store, which is the right direction for a contradictory request. withheldFields was documented as the connection's credentials. hiddenFields returns any unlisted field whole, so a stored field the allowlist does not know about appears there too, and the editor change turns that into UI. Reworded to what it actually reports. reinstate returned the stored object when the patch supplied a scalar where the stored value was an object, which dropped the fields the patch did send and made the "a value the patch supplies always wins" rule directly above it false. The patch wins; validation downstream rejects the malformed shape. Signed-off-by: Monty Lennie <montylennie@gmail.com>
…agged items MUST-FIX. The leak clause was asserted flat while the load_errors clause one clause earlier in the same sentence was correctly hedged. #1071 redacts at the single serializer every connection-returning route builds from, so "a running Publisher serves connection config ... from several unauthenticated REST endpoints" becomes false the moment it lands, and whichever of the two merges second leaves the other telling users their password is served by an API that has stopped serving it. Fixed in all three places that carried it: the .env.example header, the generated AGENTS.md, and the skill. The advice never rested on the leak, so it is now stated as what the indirection buys (not in the config file, not in shell history, and that is the whole of it) plus a version-hedged note that a running Publisher may serve the substituted value. That is true whichever way #1071 goes. Fixing an instance and missing its sibling one clause later is exactly what the prose guard exists to catch and did not, so it now also asserts that leak claims are hedged. Its earlier assertions were rewritten too: three successive versions asserted particular vocabulary ("status", then "unauthenticated") and each went red against a correct rewrite. They now assert properties of the claim rather than its wording. The comment in connection.ts already knew this, describing the serializer in the past tense while the string it guarded did not. A version-aware comment guarding a version-blind string is its own small warning. BOTH RE-FLAGGED ITEMS ARE NOW DONE rather than deferred a second time. ScaffoldResult's six sibling connection* fields are one optional nested object, so "there is no connection" is a property of the result rather than a convention, and the two sides of scaffold() now say the same thing the same way: ScaffoldOptions already took a single optional object, which made the asymmetry more visible rather than less. The compiler enumerated all 28 call sites, which is what made this safe. ConnectionEntry's index signature is closed, as { name; type } & Partial<Record<PayloadKey, Record<string, unknown>>>, and the dialect table's payloadKey is typed PayloadKey rather than string, since that is where the key actually comes from. Verified by introducing "postgressConnection" and watching it fail to compile with a did-you-mean. A typo'd payload key previously typechecked and produced a config Publisher silently ignores, which is the failure class this file is otherwise built to prevent. Generated output is unchanged by the refactor, confirmed on a real scaffold. Signed-off-by: Monty Lennie <montylennie@gmail.com>
|
Thanks for standing it up rather than reading it, and for running the counterfactuals rather than taking mine on faith. All five items are addressed, three of them as you prescribed. The tip is Blocking 1: the environment PATCH had no coverageFixed, and your framing of why it matters is the part I want to keep. The PR body says the sweep is behaviour-based so that a fourth call site would be covered. It was not true of the second call site it already had, which makes it a false completeness claim rather than a gap in coverage, and those are worse because they tell the next reviewer to look elsewhere. Your counterfactual is now the test. It GETs Worth noting for anyone reading later why the unit spec did not catch this: Blocking 2: the editor did not read the field added for itFixed as you described. There is now one reader of You flagged the main field loop. The DuckLake storage secrets had the same unconditional promise, at the Your point that it lands harder because the "Either password or private key is required" check came out in this same diff is fair, and it is the reason this was blocking rather than cosmetic: the form stopped pushing back at the same moment it started making a promise it had not checked. Non-blocking 1: selecting on the sub-objectAccepted and fixed. You are right that it is the Reproduced your case before changing anything, and the merged object matched yours. Selection now names the nested credential ( One honest residual, since you will see it if you re-run your case: the stale Non-blocking 2: what
|
Sha-Bang
left a comment
There was a problem hiding this comment.
Re-reviewed at f3dae8f7. All five findings from 5025989993 are closed, and I mutation-tested the two that were about missing coverage rather than taking the new tests at face value.
- The environment PATCH is covered. The new case does the counterfactual I asked for — GET the list, PATCH the whole thing back with one appended, then assert every original connection still reports its
withheldFields— with a>= 3guard so it cannot pass vacuously. I killed it two ways: reverting the argument topayloadConnections(fails, 500 on the PATCH), and no-op'ingreinstateinside the merge (fails on thewithheldFieldsassertion, alongside 10 unit failures). The new test is named in both. - The editor reads the field. One
withheldFieldsSet, the second reader at the old line 310 folded into it,isStoredoff the dotted path, and bothplaceholderandhelperTextreading it. It goes further than I asked and gates the two ducklake storage placeholders too. - The slots select on the credential,
suppliedAtwalks the dotted level, and the new test drivestoPublicConnectionrather than a hand-built literal, so it reproduces the round-trip instead of approximating it. Revertingselectsto the sub-object kills exactly that test and nothing else. - The malformed-scalar case returns
patch, and thewithheldFieldsdoc now says what it actually returns rather than "which is to say its credentials".
One blocking, and it is new. The object-store fix traded the API-only bug I filed for a UI-reachable one: switching DuckLake storage from S3 to GCS while leaving the stored secret box blank now carries the old S3 credential forward, and hasS3 beats GCS on the connect path, so the connection keeps authenticating against S3 while the operator believes they moved. It is a regression against 56fd02e1, not a leftover — I ran the same probe against both heads. Details and a fix that keeps both of the lessons in your selects docstring are inline.
What I ran
- 3254 pass / 3 skip / 0 fail on the server unit suite; 5/5 on
tests/integration/connection_secrets. - Four mutations, each restored afterwards: two against the environment PATCH, one against the
selectschange, and one againstreinstate. Every one of them killed the test it was supposed to. - The store-switch probe below, against
f3dae8f7and56fd02e1in turn.
🤖 Reviewed with Claude Code
Regression I introduced in the previous commit, found by Sha-Bang and confirmed by running his probe against both heads rather than reading the code: switch ducklake storage s3 -> gcs 56fd02e f3dae8f gcs block WITH a secret clean clean gcs block WITHOUT a secret clean S3-SECRET reinstated Selecting a slot by its nested credential fixed the round-tripped-read case and broke the plainer one. A patch carrying `gcsConnection: {keyId}` and no secret supplies no credential in any slot, so nothing is selected, the "selecting none is a plain omission" branch excludes nothing, and the stale `s3Connection.secretAccessKey` is grafted back beside the new `gs://` bucketUrl. `hasS3` is checked before `hasGCS` at connect time, so the connection authenticates against the store the operator just moved off. Worse than the finding it replaced, which was API-clients-only: the editor emits `gcsConnection` with only `keyId` when the secret box is blank, and its own comment says a blank secret means keep the stored value. So this is the documented UI flow for switching stores, not a hypothetical shape. Fixed with the discriminator this group has and the flat ones do not: which sub-object the patch actually sent. Exactly one declared selects it; a round-tripped read declares both and falls through to the credential check, which is the case that made presence-selection wrong to begin with. Flat groups are untouched because their fields are scalars, so nothing is declared. A selected slot never drops its own fields, so a blank box still cannot destroy a stored credential. Two tests, each pinning one case and each failing only for its own mutation: removing the discriminator fails the blank-secret switch, and reverting selection to the sub-object fails the round-tripped read. Signed-off-by: Monty Lennie <montylennie@gmail.com>
|
@Sha-Bang, you are right, and it is a regression I introduced rather than something you missed the first time. I ran your probe against both heads before touching anything, and it reproduces exactly as you have it:
So the fix for your original finding traded an API-clients-only bug for a UI-reachable one, which is a worse trade than the one it undid. Fixed at Why I took it rather than argued itI checked the two claims that decide it rather than reasoning from the diff. The UI flow is as you describe. And the precedence is what makes it dangerous rather than merely wrong: Your discriminator is the right one because it is the thing this group has and the flat groups do not. Exactly one declared sub-object selects that slot; a round-tripped read declares both, which is the case that made presence-selection wrong in the first place, so it falls through to the credential check. Flat groups are untouched, since their fields are scalars and nothing is ever declared. And a selected slot never drops its own fields, so the rule that an empty box must not destroy a stored credential still holds, which was my worry about widening Two tests, and each fails only for its own mutationBeside the case you asked for I added its converse, because the two failure directions are what the design has to hold apart:
Mutation-tested both ways as you asked. Removing the discriminator fails only the blank-secret switch. Reverting selection to the sub-object fails only the round-tripped read. Neither mutation fails both, so the two tests are pinning different things rather than one thing twice. One residual, unchanged from last round and still disclosed rather than hidden: a caller that echoes back the old sub-object's non-secret fields still leaves a stump, because the merge does not delete what a caller sent. That now ends in a loud attach error rather than the wrong store. The Windows checkI have not guessed at it, and I cannot settle it from here, so here is what I did establish. My diff contains nothing in the class that bit the sibling branch: no The new integration spec is the obvious suspect since it is the newest thing running there, and the Windows job does run What it does add is one more real server boot to that job, with a 120s readiness poll. If the failure is a timeout rather than an assertion, that is the first thing I would look at, and the fix would be to gate the spec to Linux the way GateMerged Unit 3288 across 153 files, integration 320 across 38 files, both zero failures. Lint and typecheck clean. Thanks for mutation-testing the previous round's tests rather than reading them. Killing the environment-PATCH test two separate ways is what turned "there is a test now" into evidence, and it is the check I should have run on my own test before claiming the sweep was behaviour-based. |
The 22 unit failures on CI were the contract-parity specs, and the contract was fine. Every extractor in them anchors on "\n", so a CRLF checkout, which is what the Windows leg gets, leaves each anchor unmatched and fails all twenty-odd cases at once over a newline convention. Reproduced by converting api-doc.yaml to CRLF locally: 21 failures in connection_public_view.spec.ts plus the re-anchored one in db_utils.spec.ts, which is the 22 CI reported, and the same test names. Both readers now normalise line endings, since these extractors are about the contract's content and the newline convention is noise to them. Checked that the normalisation did not neuter the gate rather than assuming it: with CRLF still in place, injecting an unclassified property into MysqlConnection still fails the parity case for that schema. This also retracts the hypothesis in the previous PR comment. I guessed the earlier Windows failure was a timeout from the credential sweep's extra server boot. It was almost certainly this instead, and the guess was worth less than running the suite the way CI runs it: `test:unit --serial` on a CRLF tree. The underlying fragility is the text extraction itself, chosen so the spec would not depend on a YAML parser this package does not declare. It bit exactly where that trade predicted, and it is worth knowing that a structural reformat of api-doc.yaml would break these specs the same way. Signed-off-by: Monty Lennie <montylennie@gmail.com>
|
Correction, and it retracts the Windows paragraph in my previous comment. The 22 unit failures were the contract-parity specs, and the contract was fine. Every extractor in them anchors on So please disregard my timeout hypothesis for the earlier Windows failure. It was almost certainly this, and the guess was worth less than the two cheap things I had not done: running the suite the way CI runs it, Worth naming rather than filing under "the gate worked": this was a false positive. It failed on a newline convention rather than on the contract, and the next person to see 22 red parity tests would reasonably assume the contract had drifted and look in the wrong place. The underlying fragility is that those specs extract from Gate on the current tip, run as CI runs it: |
…is missing
`getProcessedPublisherConfig` skips an environment whose `name` is missing or
not a string, and logged `{ environment }` alongside the warning. An
`Environment` carries `connections` and `storageDestinations`, so that line
put every credential in the environment into the log: Postgres passwords,
BigQuery service-account keys, Snowflake private keys, and the rest.
The values are real by the time the warning fires. `getProcessedPublisherConfig`
reads through `getPublisherConfig`, which returns `processConfigValue(rawConfig)`,
and that deep-walks the config substituting `${VAR}` references, so a
`${MALLOY_X_PASSWORD}` has already become the password. Nothing downstream
redacts it either: `redactSensitive` is called at the request/response
middleware and the axios-error path, not registered in the winston format
chain, so logger metadata reaches the transport verbatim.
Log the entry's index instead. The name is precisely what is missing, so
position is the only safe way to point an operator at the offending entry.
The regression test asserts on the whole logged payload rather than on the
absence of an `environment` key, so re-introducing the config under a
different key still fails it. Three mutations were run against it and each
turns it red: restoring `{ environment }`, deleting the warning, and dropping
the `continue` so the entry is no longer skipped.
This is the same class as the connection-config leak at `connection.ts:1129`
caught on #736. The sibling site in this file, the missing-`name` connection
warning in `convertConnectionsToApiConnections`, is deliberately untouched:
it is already fixed on #1071, and duplicating it here would collide.
Scope, by method rather than by sample. Every `logger.*` and `console.*` call
under `packages/` was parsed as a balanced-paren span, then classified two
ways: a shorthand, spread or `key: ident` inside an object-literal argument,
and a bare identifier passed as a non-first argument. The second classifier
matters, and an earlier pass that lacked it missed 63 sites. At this commit
that is 884 call sites across 606 files, 549 of them spanning more than one
line; 302 put a whole variable into the log; exactly one of those variables
is config-shaped, and it is the `convertConnectionsToApiConnections` site
that #1071 fixes. Before this change there were two.
Of the remaining whole-variable sites, `execute_query_tool.ts` logs the MCP
tool's `params` at info. It carries no credentials, so it is not this class,
but it does log `query` and `givens`, and `givens` can hold row-level-access
identity values. Left for a follow-up issue rather than folded in.
Signed-off-by: Monty Lennie <montylennie@gmail.com>
Connection configs hold warehouse credentials, and the API returned them. A Postgres password, a BigQuery service-account key, a Snowflake private key, S3/GCS/Azure keys, a Databricks token and an SSH bastion private key were all readable by anyone who could reach the port, with no authentication and no prior knowledge of the deployment.
Sha-Bang traced this while reviewing #1047 and offered the redaction as one way to close its must-fix 2. This is that fix. The chain he identified holds:
processConfigValuedeep-walks the config, so a${MALLOY_X_PASSWORD}reference is substituted into the real secret before any response is built, and the response then carried it.What was exposed, and through what
The boundary this crossed is the config file rather than the network posture.
publisher.config.jsonis an operator's file on an operator's disk. The API republished its contents to any reader, including the secret that a${VAR}reference exists specifically to keep out of the file.The mechanism is worth stating precisely, because it determines whether a fix is complete or a spot patch.
Environment.serialize()returnedlistApiConnections()directly, which is the live connections array rather than a copy. Every route that serializes an environment therefore served the credential-bearing objects themselves. This is not a single leaking endpoint with a single missing redaction: it is one accessor feeding every read.I counted the surface from the route table rather than by probing, because probing understates it. Taking every
app.<verb>registration inserver.tsandserver-old.tsand keeping those whose handler reachesserialize(),listEnvironments(),listConnections(),getConnection()orgetStatus(): fifteen registrations can return a connection. Roughly half are the legacy/projectsaliases, which is exactly what hand-probing misses, and it missed them for me too before I enumerated. I am deliberately not listing the fifteen paths here; the count and the method are what a reviewer needs, and all of them build from the same serializer, so the count is a consequence rather than a boundary and it moves whenever a route is added.Two surfaces are not affected, both verified rather than assumed.
POST /connections/testanswers with aConnectionStatus, whose only properties arestatusanderrorMessage, and it never merges stored config. The MCP surface maps connections to{name, type}explicitly before returning them.Separately,
config.tsspread an entire connection config into a log line when a config entry was missing itsname. That is the pattern caught in #736 living on in a second place. A multi-line scan of everylogger.*call inpackages/server/srcfound exactly one such site, the one fixed here.What the fix changes structurally
Responses are built from an allowlist of fields per connection type, applied at the three places that construct one:
Environment.serialize()and the two connection controller reads. The internal view keeps its credentials, because compiling and connecting need them, so the split is between what the server holds and what it publishes rather than between one endpoint and another.An allowlist rather than a denylist of secret-looking names, because the failure modes are asymmetric. An unlisted field is absent, so a credential added to a future connection type is invisible until someone publishes it deliberately; the cost of forgetting a non-secret field is that the UI does not show it yet. The contract cannot supply the list on its own, since
api-doc.yamldeclarespasswordas a property ofPostgresConnectionbecause one schema serves both the write path and the read path.Credentials are omitted rather than masked. A placeholder is resubmitted as the real credential by any client that sends back what it read, which is what the connection editor does.
withheldFieldsnames what a response withheld, as dotted paths, names only. Without it a client cannot tell a credential that is set from one that was never configured, which is the difference between an empty box that keeps something and an empty box that leaves the connection with nothing.The half that is a prerequisite rather than an extra
Once responses omit credentials, a caller cannot echo one back, so both write paths had to stop reading omission as deletion. This is the part that makes the change larger than a redaction, and it is not optional: shipping the redaction alone converts the leak into silent credential loss.
The per-connection
PATCHmerged with a shallow top-level spread, so sendingpostgresConnectionwithoutpasswordreplaced the sub-object and destroyed the stored password. The environmentPATCHwas worse, because it replaces the whole connections list and that is what the connections UI sends for every add, edit and delete: one edit stripped the credentials of every connection in the environment and answered 200. Snowflake and MotherDuck instead failed validation, so the app would have refused any connection change at all. Both behaviours predate this PR; the editor concealed them by reading credentials back out of the response, which is the leak.Supplying a credential for one method now drops the stored credential of the alternative rather than accumulating both, because the connect path picks by presence: a stale
connectionStringbeats new host/port fields, a staleprivateKeyroutes to key-pair auth,peakaKeyshort-circuits beforepasswordis read, stored S3 beats new GCS, and the Databricks driver prefers OAuth over a token. Those are the five such slots, enumerated from the connect path rather than sampled.A slot is selected only by a credential the caller actually supplied, non-empty. Both halves are load-bearing and both were learned by getting it wrong. An empty string is an untouched form box rather than a choice, and reading one as a choice destroyed the only credential of a connection described solely by a field the form cannot display. A non-secret field is echoed back by any client resubmitting what it read, so it says nothing about intent:
oauthClientIdcame back on every Databricks write and would have kept the OAuth slot selected permanently.An explicit
nullclears a sub-object; an empty object keeps what is hidden inside it, on the principle that erring toward keeping a credential beats erring toward destroying one.What pins it, and the holes found in the pinning
A contract-parity spec holds the allowlist against
api-doc.yaml, including the objectsDucklakeConnectiondeclares inline, and walks$refs out fromConnectionto require every reachable schema to be registered. It catches a property added to any connection schema, and a new connection type whose schema nobody registers. It is not hypothetical: rebasing onto #1064 pulled inimpersonateServiceAccountonBigqueryConnectionand the spec failed until it was classified. It is public (an account email, not a credential).An integration spec boots a real server with real credentials and asserts none appears in any response body from any endpoint, or in the server log. It is written by behaviour rather than location, because the unit specs pin the three call sites that exist today and say nothing about a fourth added later. The credential reaches it as a
${VAR}reference, so the substitution step is inside the covered path.Both of those had holes that review found rather than the tests failing, which is worth stating because it bears on how much weight to put on them:
/environments. The legacy/projectsaliases were already covered by the fix, same controller and serializer, but nothing pinned that. Now swept.[A-Za-z]+, which does not match a name containing a digit, soGCSConnectionsilently ran into the next schema's properties.$refwalk closed.Every fix here was mutation-tested: reverted in turn to confirm the guarding test goes red, plus injected fields and an injected unregistered connection type to confirm the parity gate fails.
Verification beyond the suites: reproduced on a running server before and after, reading the bytes on the wire; the update paths checked against the stored config in the database rather than against a response, since reading the credential is the thing being prevented; and three counterfactual runs with each fix disabled, each of which destroyed a credential while answering 200.
Gate: typecheck, lint, unit (3255 pass), integration (389 pass), and the
environment-connectionsPlaywright suite.Review coverage, stated plainly
Most of the review here was mine, and mine missed things that others caught. Two rounds of adversarial review found ten defects, and the second round's most serious finding was a data-loss bug created by the first round's own fix. The single most damaging defect, that the connections UI writes through the environment
PATCHrather than the endpoint the fix was first written against, was found by review and not by my manual testing, because I had tested the path I had just written. Two holes in my own credential sweep were found by someone else pushing on it rather than by me. Sha-Bang's independent trace is what started this.So this has had no independent human review yet. Given what independent passes found on #1047 and #1057 that four self-review rounds had not, that is worth weighing.
Follow-ups, deliberately not in this PR
Deleting a connection through the environment
PATCHdrops it from memory without pruning its stored row, so it returns with its credentials on the next restart. That predates this change and wants its own fix.Renaming a DuckDB attached database requires re-entering its secret, since entries are matched by name. Documented rather than worked around.
Write access to a connection reaches its credential: anyone who can
PATCHone can point it at a host they control and have the stored credential sent there. Publisher authenticates neither operation, so this is not a new boundary, but it is now written down indocs/connections.mdbecause a deployment that gates reads and writes separately should know it.For operators on an unpatched build
Upgrading is the fix. If you are hardening instead, authenticate the API rather than any particular path: every read builds from the same serializer, so protecting the endpoints you happen to know about leaves the rest.