Skip to content

fix(server): let the SSH server decide the web terminal's public key access - #7049

Open
otavio wants to merge 3 commits into
masterfrom
fix/web-terminal-duplicate-key-authz
Open

fix(server): let the SSH server decide the web terminal's public key access#7049
otavio wants to merge 3 commits into
masterfrom
fix/web-terminal-duplicate-key-authz

Conversation

@otavio

@otavio otavio commented Sep 6, 2026

Copy link
Copy Markdown
Member

What

The web terminal evaluated an offered public key itself and then dialled the SSH server, which
evaluated the same key again. One decision now lives in one place — the SSH server — and the web
terminal only fetches the key material it has to present.

Why

The two copies had drifted. They disagreed on the error sentinel, and more importantly on the
namespace's SSH access mode: the server chooses between publicKeyAuth and ResolveKeyAuth based
on it, while getAuth knew nothing about it and applied the legacy key filters even in an
identity-mode namespace, where the server applies none. A browser session could be refused a key
the server would have admitted.

Not a privilege escalation — the duplicate was stricter than the server, never laxer.

Found by an architecture review of the SSH path; there is no issue for it.

Changes

  • server/ssh/web/session.gogetAuth keeps GetDevice and GetPublicKey, because the
    Signer needs the key material to present and the device supplies the tenant that bounds the
    lookup. The two Evaluate* calls and the refusal go. This is how the browser-held-key branch
    above it already worked: present the key, let the server admit or refuse it.

  • server/ssh/web/session.go — a handshake that fails on a public-key credential now answers
    ErrForbiddenPublicKey instead of ErrAuthentication. Without this the change would have told a
    public-key user their password was wrong and offered a reconnect that cannot help. The console
    has an entry for ErrForbiddenPublicKey — "this public key is not authorized for this device" —
    that nothing has ever been able to trigger, because newSession discards getAuth's error and
    answers ErrGetAuth. That mapping becomes reachable for the first time; no UI change needed.

  • server/ssh/session/auther.go — removes the magic-key bypass, which skipped the lookup and
    both evaluations when the offered key matched magickey.GetReference(). That key is a
    process-local RSA key generated at startup; its public half never leaves the process, so nothing
    can present it. It dates from 2022, when the web terminal signed the loopback dial with it — the
    bypass existed because the web terminal had already evaluated the key. It lost its caller long
    ago while the duplication it excused stayed. Also removes a trailing return err that could only
    ever be nil.

  • server/ssh/session/auther_test.go — new. Offer had no test and decides two things.

Testing

The agent version floor is the thing to check. Agents 0.5.x and earlier do not validate the
public key request and may panic, so the server refuses public-key auth against them unless
SHELLHUB_ALLOW_PUBLIC_KEY_ACCESS_BELLOW_0_6_0 is set. Customers still run those agents. That rule
had no test at all before this PR.

The test commit is deliberately first and touches no production code, so it can be checked against
master:

git checkout $(git rev-list --max-parents=1 origin/master..HEAD | tail -1)
go test ./ssh/session/ -run TestPublicKeyOffer -count=1   # passes on master + test only
git checkout fix/web-terminal-duplicate-key-authz
go test ./ssh/session/ -run TestPublicKeyOffer -count=1   # still passes after the change

Both were verified in a worktree. The floor block in Offer is byte-identical to master.

Two behaviours worth a reviewer's eye:

  • The parse failures are pinned as they behave, not as they arguably should. An agent whose
    version does not parse — including one reporting no version at all — is refused with
    ErrInvalidVersion while the opt-in is off. That means a version-less agent cannot use a public
    key on a default instance. Preserved deliberately; say so if it should change.
  • The refused cases assert through a MockService with no expectations, so they fail if the floor
    ever lets a call reach the store rather than merely returning some error.

Not done here, and deliberately: a nil-guard on Device.Info (DeviceToModel always populates it,
so it is unreachable and the guard would be speculative), and carrying the server's refusal reason
across as an SSH banner. The latter is worth doing — it would give plain SSH clients the explanation
they get nothing of today — but a banner in Offer would fire on every key a client merely queries,
so it belongs in Evaluate and is its own change.

Scope: legacy access mode only

Worth knowing before reading the diff. resolveKeyAuth picks between publicKeyAuth and
ResolveKeyAuth on the namespace's ssh_access_mode, and the column defaults to identity. A
namespace created today therefore never reaches publicKeyAuth.Offer — the function this PR
changes — and the web terminal's registered-key branch is likewise a legacy-mode path.

So the blast radius is narrower than the diff suggests: namespaces still on legacy mode.
Identity-mode namespaces are untouched, which is also why getAuth applying legacy key filters
regardless of mode was wrong rather than merely redundant.

Verified against a running instance

Beyond the unit tests, the change was exercised end-to-end on a live v0.27.0-rc.8 community dev
stack (real agent, real SSH client, real WebSocket bridge), with a namespace switched to legacy
mode. Three registered keys with different filters: unrestricted, username=nobodyxyz,
hostname=no-such-host.

SSH — unrestricted key connects and runs a command; username-filtered and device-filtered keys
are both refused, server logging refused the offered public key error="failed to evaluate the provided public key". That is the evaluation running unconditionally with the magic-key branch
gone.

Web terminal — completing the real signature exchange over /ws/ssh, the unrestricted key
reaches {"kind":5} (session established). The unauthorized key is refused, and the refusal is the
reason the third commit exists. Same fixtures, the fix toggled:

frame the browser receives what the console renders
without the fix failed to authenticate to device "The username or password is incorrect." + reconnect
with the fix failed to use the public key for this action "This public key is not authorized for this device."

A wrong password still returns failed to authenticate to device, so the discrimination is not
over-broad.

Old-agent floor — with the device reporting 0.5.9, the key that connects above is refused,
server logging connections using public keys are not permitted when the agent version is 0.5.x or earlier. The hard constraint holds on a live server, not only in the unit tests.

publicKeyAuth.Offer had no test, and it decides two separate things.

The first is the agent version floor. Agents 0.5.x and earlier do not validate the public key
request and may panic, so the server refuses public key authentication against them unless the
instance sets SHELLHUB_ALLOW_PUBLIC_KEY_ACCESS_BELLOW_0_6_0. Customers still run those agents,
and nothing held the floor in place: AllowPublickeyAccessBelow060, ErrUnsuportedPublicKeyAuth
and ErrInvalidVersion appeared in no test file. Cover it from both sides, along with the opt-in
that lifts it, the "latest" version that skips the comparison, and the v-prefixed spelling
semver accepts.

The two parse failures are pinned as they behave rather than as they arguably should: an agent
whose version does not parse, including one reporting no version at all, is refused with
ErrInvalidVersion while the opt-in is off. That is worth knowing before anyone changes it,
because it means a version-less agent cannot use a public key on a default instance.

The second is whether the key may act as the login on the device, which is the lookup and the
two evaluations. Cover each way it can refuse — either evaluation answering false, either
failing outright, and the lookup missing — because the commits that follow move the only other
copy of that decision, and a decision with no test is a decision that can be moved wrongly.

A refused version case asserts through the service mock rather than the error alone. mockery
fails a MockService built with no expectations if anything calls it, so those cases prove the
floor returned before reaching the store, not merely that some error came back.

The opt-in is a package-level global installed by Configure, so the helper captures and
restores the whole config around each case.
…fers

publicKeyAuth.Offer skipped the public key lookup and both key evaluations whenever the
offered key's fingerprint matched the magic key's. Nothing can present that key.

magickey.GetReference is a process-local RSA key generated once at startup with
sync.OnceValue. Its public half is never persisted, never published and never sent to a
client, so no caller can offer it and the branch has been dead rather than dangerous. It is
removed for what it hides, not for what it risks.

It dates from 692d763 (2022), when the web terminal's GetAuth ran the key evaluations
itself and then dialled localhost:2222 signing with the magic key. The bypass is what let
that loopback connection through without evaluating the key a second time. The web terminal
stopped presenting the magic key long ago — it now signs with the browser's key or with the
user's registered key — so the bypass lost its only caller while the duplicated evaluation
it existed for stayed behind. That duplication is removed next.

With the branch gone the evaluations are unconditional, which is what every caller already
got, and Offer ends in an explicit nil. The trailing `return err` it replaces could only ever
return the already-checked error from building the magic key, so it was nil on every path
that reached it — a value that read like a result and was not one.

The agent version floor above is untouched and still returns before any of this, so agents
0.5.x and earlier are refused exactly as before.
…access

getAuth evaluated the offered key itself — GetPublicKey, EvaluateKeyUsername,
EvaluateKeyFilter and the refusal — before dialling the SSH server over the loopback. The
server then evaluated the same key again on that connection, so every browser session using
a registered key paid for the decision twice and two copies of it had to agree.

They did not. The copies disagreed on the sentinel, and worse, on the access mode. The
server picks between publicKeyAuth and ResolveKeyAuth on the namespace's SSH access mode;
getAuth knew nothing of it and applied the legacy key filters even for an identity-mode
namespace, where the server applies none. The browser could be refused a key the server
would have admitted.

Fetch the key and stop there. The lookup stays because the Signer needs the key material to
present, and the tenant it is bounded by comes from the device — that is a read, not a
decision. Branch one already works this way: a browser-held key is presented with no
evaluation at all, and the server is what admits or refuses it. The registered-key branch
now matches, so there is one decision, in the place that knows the access mode.

Moving the decision moves where its refusal surfaces, and that had to be carried across or
the browser would have been told the wrong thing. newSession discards getAuth's error and
answers ErrGetAuth, so a key the namespace refused already reached the console as "the
selected public key could not be used" rather than as ErrForbiddenPublicKey — which nothing
returned, and which the console has therefore been mapping for nothing. With the decision in
the server the refusal arrives as a failed handshake instead, and answering ErrAuthentication
there would have told a public key user their password was wrong, offering a reconnect that
cannot help. A handshake that fails on a public key credential now answers
ErrForbiddenPublicKey, which is what the console's unused entry was always written for: "this
public key is not authorized for this device".

ErrEvaluatePublicKey goes: nothing returns it once the decision moves, and a sentinel that
cannot be returned reads like a case that can happen. ErrForbiddenPublicKey stays in
exitLogLevel's warn list, where it is now reachable for the first time.
@otavio
otavio requested a review from a team as a code owner September 6, 2026 18:51
@otavio

otavio commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Code Review Complete

The automated review ran but did not post an updated summary — this usually means no new issues were found since the previous review. If you've pushed changes and want a fresh pass, comment /review.

View job

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.

1 participant