fix(providers): reject the dashboard password as a connection API key (#9572)

* fix(providers): refuse to store the dashboard password as a connection API key

A browser autofilled the management password into a connection's API-key field.
The resulting credential authenticates against nothing, so every request routed
through that connection came back 401, and because the field looks like any
other password input the same autofill fired again while the connection was
being repaired by hand.

The refusal belongs on the write path rather than in the form. Twenty routes
create or update connections and all of them funnel through
createProviderConnection and updateProviderConnection, so one check there covers
every entry point including a future one. The two other places that write
api_key are left alone on purpose: one re-encrypts rows that already exist and
the other is the one-time db.json import, and neither takes a value an operator
just typed.

Update checks the incoming value, never the merged one. A connection that
already holds the password has to stay editable or the operator cannot repair
the exact state this prevents, and re-checking the merged value would spend a
bcrypt round on every unrelated field edit.

Only a real match blocks the write. An unreadable settings row or a throwing
bcrypt call logs and allows, because a guard against one specific mistake must
not turn into a way to lock out every connection write.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* fix(providers): compare the untrimmed credential, and cover the guard's branches

The guard trimmed the incoming value before comparing it, which catches a paste
carrying whitespace the password does not have. It missed the mirror case:
neither the login route nor the set-password route trims, so a dashboard
password may itself begin or end with a space, and an autofill reproducing it
exactly was trimmed into a value that no longer matched the stored hash. The
write then went through, which is the state this guard exists to prevent. Both
forms are compared now, the second only when the first fails on a string that
differs, so an ordinary key still costs a single bcrypt round.

Two branches carried no coverage and both are load-bearing. The catch that logs
and allows is the only path that lets a write through; a stored hash bcrypt
cannot parse reaches it without needing a mock, since the shape check accepts an
impossible cost factor that the comparison then rejects. The early return is
what keeps a token renewal -- a write carrying tokens but no apiKey -- from
paying for a settings read and a bcrypt round every time it fires, and the same
unparseable hash makes that path observable, so an absent warning is proof the
return happened.

The narrower scope is deliberate and now says so in the code: the OAuth tokens
arrive from a provider's token endpoint rather than from a form, so extending
the comparison to them would charge every renewal for a field no autofill can
reach.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
This commit is contained in:
Bob.Hou
2026-08-11 06:27:27 -04:00
committed by GitHub
parent c61cdc30aa
commit c3dc0352ac

Diff Content Not Available