Skip to content

server: authorize saved vault cards under restrict_hosts#73

Merged
dolonet merged 1 commit into
mainfrom
fix/vault-connect-restrict-hosts
May 21, 2026
Merged

server: authorize saved vault cards under restrict_hosts#73
dolonet merged 1 commit into
mainfrom
fix/vault-connect-restrict-hosts

Conversation

@dolonet
Copy link
Copy Markdown
Owner

@dolonet dolonet commented May 20, 2026

The bug

On a deployment with restrict_hosts: true, connecting via a saved
vault card
is rejected — the connect never reaches SSH and the client
shows the generic "username … is not authorized" popup. Connecting to
the same host through the named connection works.

Root cause

The connect handler resolves credentials three ways — named connection
(connection), saved vault card (vault_id/conn_id), or free-form
manual body — then gates the request:

if not conn_name and not is_host_allowed(host, port, username):
    → 403 "connections to this host are not allowed"

A vault card carries no connection name (it is keyed by
vault_id/conn_id), so conn_name is empty and the request falls
into the gate. Under restrict_hosts, is_host_allowed() returns
False unconditionally, so the vault connect is refused as if it were
a forbidden free-form manual POST. The client's mapConnectError
matches the /not allowed/ message and renders the policy_deny
popup, which reads as a username problem even though the rejection is
about connection routing.

A vault card is not a free-form connect — its target is a stored
record. Under restrict_hosts it is exactly as legitimate as the
named prompt connection whose host:port it matches.

The fix (server-side)

  • find_prompt_connection_by_host(host, port) — the named prompt
    connection matching a target, or None. ready (fixed-credential)
    connections are intentionally not matched: they connect with
    operator-stored credentials, not a user's saved card.
  • authorize_target(host, port, username, is_saved) — one decision
    for connects that carry no connection name. A saved card under
    restrict_hosts is authorized against the matching prompt
    connection — a fixed-username connection pins the user; an open one
    runs check_prompt_user, so allowed_users / denied_users still
    apply. A manual POST stays rejected. With restrict_hosts off, both
    fall through to the existing deny-list gate.
  • The connect-handler gate now calls authorize_target.
    is_host_allowed, the named-connection path, the manual path, and
    the deny-list / scan-pattern behaviour are unchanged.

A vault card whose host:port matches no configured prompt connection
is still rejected — restrict_hosts still pins connections to
configured destinations.

Test plan

  • python3 test_server.py427 passed (was 420; +7). Frontend
    tests untouched (server-only change); CI runs both.
  • New authorize_target cases pin the gate: manual rejected under
    restrict_hosts; saved card allowed when it matches a prompt
    connection; rejected when the host is unconfigured; honoring
    allowed_users / denied_users / a fixed username on the matched
    connection. Verified red→green — each case was watched failing
    against both an under-permissive and an over-permissive
    implementation.

A saved vault card connects via `vault_id`/`conn_id` and carries no
`connection` name, so the connect handler's gate —
`if not conn_name and not is_host_allowed(...)` — treated it as a
free-form manual POST. Under `restrict_hosts: true` that gate rejects
unconditionally, so vault-card connects were refused outright with
"connections to this host are not allowed" (surfaced by the client as
the generic "username not authorized" popup) and never reached SSH.

A vault card is not a free-form connect — its target is a stored
record, and under restrict_hosts it is as legitimate as the named
prompt connection whose host:port it matches.

- Add `find_prompt_connection_by_host()` — the named `prompt`
  connection matching a target, or None.
- Add `authorize_target()` — the gate decision for connects with no
  `connection` name. A saved card under restrict_hosts is authorized
  against the matching prompt connection (fixed-username connections
  pin the user; open ones run `check_prompt_user`); a manual POST
  stays rejected. restrict_hosts off → both fall through to the
  existing deny-list gate (`is_host_allowed`, unchanged).
- The connect handler gates via `authorize_target`. Named-connection,
  manual, and deny-list / scan-pattern behaviour is unchanged.
- test_server.py: +7 cases pinning the gate (manual rejected; saved
  card allowed on a matching prompt connection, rejected on an
  unconfigured host; allowed_users / denied_users / fixed-username
  honored). 427 pass.
@dolonet
Copy link
Copy Markdown
Owner Author

dolonet commented May 21, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Reviewed:

  • authorize_target / find_prompt_connection_by_host correctness under restrict_hosts on/off × is_saved on/off × host deny-listed/not/username-rejected
  • Scan-pattern accumulation guard (if not cfg["restrict_hosts"]) is preserved correctly across all new rejection paths — deny_blocked is emitted for vault-card username rejections under restrict_hosts, but the guard correctly prevents those from feeding _record_deny_for_scan
  • Port comparison in find_prompt_connection_by_host uses clamp(c.get("port"), MIN_PORT, MAX_PORT, 22) consistently with the value set from the vault record
  • kind field is always set by load_config() before caching, so the kind == "prompt" filter is reliable
  • No authorization bypass when both vault_id and connection are present (pre-existing behavior, unchanged)
  • Named-connection path that skips authorize_target is unchanged and still correct

@dolonet dolonet merged commit 15de5c6 into main May 21, 2026
5 checks passed
@dolonet dolonet deleted the fix/vault-connect-restrict-hosts branch May 21, 2026 08:47
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