Files
DodoSSH/docs/adr/0004-relay-authorization.md
T
jaap-jan ce43f397a6 Add ADRs 0001-0006 and README (M0)
Records the decisions the milestone plan already made, with their costs stated rather
than only their benefits:

- 0001 e2ee-trust-model: key hierarchy, the AAD-to-row binding that stops the server
  moving ciphertext between rows, and the four-layer public-key trust story. States
  plainly that revocation is not retroactive, that Connect cannot be a security
  boundary, and that the IdP becomes a key-distribution trust root.
- 0002 minimal-apis: feature modules with explicit registration; capability negotiation
  instead of Asp.Versioning, since client and server upgrade independently when
  self-hosted.
- 0003 sync-protocol: single write path, revision cursors, and the bigserial
  pre-commit sequence gap that silently corrupts sync — plus the per-vault advisory
  lock that fixes it and the test that must prove it.
- 0004 relay-authorization: relay forwards bytes rather than terminating SSH, so
  zero-knowledge survives; server-resolved target IPs in the ticket to defeat DNS
  rebinding; why host addresses must be plaintext when relay is enabled.
- 0005 no-application-layer: why the usual Application/mediator layer earns nothing
  here, with the trigger that would make us revisit it.
- 0006 observability-stack: OTel plus built-in ILogger; liveness excludes dependencies
  so a database blip cannot restart the container and kill live SSH sessions.

Also adds a README covering layout, build, enforced conventions and milestones.
2026-07-28 12:28:44 +02:00

102 lines
5.6 KiB
Markdown

# ADR 0004 — Relay as a raw TCP tunnel, and how its targets are authorized
- Status: accepted
- Date: 2026-07-28
## Context
Some hosts are not reachable from a user's laptop. The obvious answer is a server-side jump
host — but that conflicts directly with [ADR 0001](0001-e2ee-trust-model.md): a server that
terminates SSH needs plaintext credentials for the target.
A server that dials arbitrary addresses on an authenticated user's behalf is also a textbook
SSRF and lateral-movement primitive, aimed at the operator's own network.
## Decision
### The relay forwards bytes, it does not terminate SSH
The client opens a WebSocket to the backend; the backend pipes raw bytes to `host:port`. The
SSH handshake still terminates on the client, so the relay sees only SSH ciphertext.
Zero-knowledge survives. Subprotocol `dodossh.relay.v1`; no framing of our own, because SSH is
already a byte stream.
### Two steps, so the WebSocket carries no API authority
1. `POST /relay/tickets` — full bearer JWT, full ACL context. Takes **`{ hostId }`, never a
client-supplied address.**
2. `GET /relay/connect` — WebSocket upgrade with the ticket only, zero API access.
Tickets are `IDataProtectionProvider` payloads (key management and rotation for free) carrying
`jti, userId, deviceId, hostId, targetIps[], targetPort, exp ≤ 30s`, single-use via a unique
insert into `relay_ticket_use`. The ticket travels in
`Sec-WebSocket-Protocol: dodossh.relay.v1, ticket.<token>`, since constrained WebSocket clients
cannot set arbitrary headers; a `?ticket=` fallback exists and **requires query scrubbing in
access logs and OTel spans**.
### Anti-SSRF, in order of importance
1. **The server resolves the target from `host_id`.** `relay_enabled = true` and non-null
`hostname`/`port` are enforced by a database CHECK constraint. Arbitrary targets are not
supported in v1 at all.
2. **DNS resolves at ticket-issue time and the resolved IPs go into the ticket; the relay
dials the IP, never the name.** This is what defeats DNS rebinding, which otherwise breaks
naive allow-listing.
3. **A non-overridable deny list with no configuration escape:** loopback, link-local,
`0.0.0.0/8`, multicast, cloud metadata (`169.254.169.254`, `100.100.100.200`,
`fd00:ec2::254`), and **IPv4-mapped IPv6 normalised and then re-checked** — a classic
bypass.
4. **A configurable layer:** `Relay:AllowPrivateNetworks` defaults **true**, because RFC1918 is
the primary use case for a self-hosted SSH tool, plus allow/deny CIDRs. Ports allow
everything except SMTP. Database ports stay open, because forwarding to 5432 is a headline
feature — **the real control is the ACL**: you can only dial hosts in a vault you hold
`Connect` on.
5. **Limits:** 10 concurrent sessions per user, 200 per node, 12 h maximum, 10 min idle, 5 s
connect.
### Why host addresses are plaintext
Encrypting hostnames sounds strictly better and is not. The relay must resolve its target
server-side or point 1 above collapses and the relay becomes an authenticated open TCP proxy —
a worse risk than the metadata it would protect. Plaintext address and port are therefore
stored **only when the user opts that host into relay**, enforced by the CHECK constraint.
Everything else about a host — username, notes, jump chain, options — is always ciphertext, and
there is no plaintext host label at all, because ACL administration runs on the client, which
can decrypt names.
Searchability is explicitly *not* the argument: vaults hold thousands of items, not millions,
so the client syncs everything and searches in memory. The relay and audit arguments are the
real ones.
## Consequences
- **No session recording or command auditing is possible in relay mode.** The relay sees
ciphertext. Do not promise otherwise. Recording would need a separate, explicitly
non-zero-knowledge "recorded bastion" mode opted into per host.
- Useful audit remains: `target_host:port`, duration, byte counts, close reason, client IP.
- Backpressure comes from `System.IO.Pipelines` with `pauseWriterThreshold: 1 MiB`. When the
WebSocket peer is slow, `FlushAsync` stops completing, which stops reading the socket, which
lets TCP's own receive window throttle the origin end-to-end. No custom flow control, ~2 MiB
bounded per session — which is what makes 200 sessions per node viable.
- **The relay must not hold a `DbContext` for the session lifetime.** Insert the row, dispose
the scope, relay, then open a fresh scope to write the close row. Otherwise the connection
pool dies around 100 concurrent sessions.
- Tickets are stateless-verifiable and `relay_ticket_use` is the only shared state, so any node
accepts any ticket: plain round-robin, no session affinity. That table is also the extraction
seam — a standalone relay process needs the Data Protection key ring and one table, no ACL
code. Extraction is worthwhile eventually because long-lived connections and short requests
have opposite scaling and rollout profiles.
- Graceful shutdown sends close 1001 and drains for 30 s, so a `docker compose up -d` does not
guillotine live shells.
- On the client, SSH.NET cannot be handed a pre-connected stream, so the relay is reached via a
loopback TCP bridge. The same bridge provides ProxyJump via a SOCKS5 dynamic forward — one
mechanism, two features.
### Rejected
- **Server terminates SSH (a true bastion).** Enables recording and central credential
control; destroys zero-knowledge. Out of scope, possibly a separate product line.
- **Client-supplied target address.** One line of convenience, and the relay becomes an open
proxy into the operator's network.
- **Allow-listing by hostname.** Defeated by DNS rebinding.