Skip to content

fix(sdk-ruby): verify the TLS peer on WebSocket dials - #249

Merged
MDzaja merged 3 commits into
mainfrom
fix/sdk-ruby-ws-tls-verification
Sep 9, 2026
Merged

MDzaja merged 3 commits into
mainfrom
fix/sdk-ruby-ws-tls-verification

Conversation

@aprojic

@aprojic aprojic commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

What

The Ruby SDK's WebSocket dials did not verify the peer certificate. websocket-client-simple
builds its own SSLContext and never calls SSLContext#set_params, so Ruby's own defaults
(verify_mode: VERIFY_PEER, verify_hostname: true) never apply. It honours verify_mode only
when passed, and has no option for hostname verification at all.

Passing verify_mode is not sufficient on its own. Without a hostname check, a peer holding any
valid certificate can terminate the connection and read the request headers. The context is frozen
by SSLSocket.new and the handshake is written before connect returns, so there is no
caller-side hook to correct this — the dial itself has to be owned.

Daytona::Common::WebSocketDialer now owns it, and the five dials in process.rb and
code_interpreter.rb route through it. common/socketio_client.rb is unchanged: it already
verifies, and sends its token after verification rather than in the handshake.

The dials send the same headers they sent before, now over a verified connection. An earlier
revision of this PR also reduced them to an allowlist; that is deferred to its own change, since
the proxy attempts the bearer token before the preview token and removing it would make the
preview-token branch the only path. Worth settling on its own terms.

Tests

The existing specs stub the dial, so they cannot observe TLS at all. The new spec stands up a
local TLS listener and asserts on the bytes the peer received:

  • a self-signed peer is refused, and nothing is written
  • a CA-signed peer presenting the wrong hostname is refused, and nothing is written — this is the
    case a verify_mode-only change passes
  • a valid peer still completes the upgrade (positive control; without it the two refusals could
    pass merely because the CA is untrusted)
  • a send/close round trip, which fails if the reimplemented connect stops satisfying the
    inherited client's contract
  • a structural guard, which fails if a dial appears outside the shared dialer

No network egress, no fixture files, no new dev dependencies.

Notes for review

#connect is reimplemented rather than extended because upstream builds the socket, performs the
handshake and writes the request in one pass, and returns early when the socket is already set.
Everything after TLS setup mirrors upstream so framing, the reader thread and event semantics stay
identical. That couples to the instance variables the inherited #send, #close, #open? and
#closed? read, so websocket-client-simple is pinned to ~> 0.9.0~> 0.9 would admit 0.10,
where a 0.x rename could land. The round-trip spec is what catches that contract breaking.

System trust roots are seeded only into a store the dialer creates itself, so a caller-supplied
:cert_store stays authoritative.

Coverage gap, stated rather than implied: get_session_command_logs_async and
get_entrypoint_logs_async have no e2e coverage. Their handshake construction is identical to the
PTY dials that do, so confidence transfers by inspection, not by test.

Consumer impact

Connections to a preview endpoint whose certificate does not verify now raise
OpenSSL::SSL::SSLError instead of silently connecting. Daytona-hosted regions are unaffected. A
custom region presenting a privately issued certificate needs its CA in the system trust store —
the dialer reads it. There is deliberately no option to disable verification, matching the Go,
Node, Python and Java SDKs.

Coordination

#191 adds ProcessHandle#attach_terminal, a further dial. It targets the toolbox API host rather
than a preview link and carries no preview token. Flagging it so whichever lands second routes the
dial through the shared dialer.

…edential

The five credential-bearing `wss://` dials in `process.rb` and
`code_interpreter.rb` did not verify the peer certificate.
`websocket-client-simple` builds its own `SSLContext` and never calls
`SSLContext#set_params`, so Ruby's own defaults do not apply; it honours
`verify_mode` only when passed, and cannot enable hostname verification at
all. Since the context is frozen by `SSLSocket.new` and the handshake is
written before `connect` returns, there is no caller-side hook, so route the
dials through a shared client that owns the TLS setup.

Chain verification alone is insufficient: without a hostname check a peer
holding any valid certificate can terminate the connection and read the
request headers.

Also stop sending the organization credential on these dials. They
authenticate on `X-Daytona-Preview-Token`, and the proxy strips
`Authorization` before forwarding, so it was only ever exposed on the hop
being fixed. Headers are reduced to an allowlist inside the dialer rather
than at each call site, so a dial added later cannot opt out and a credential
added to `default_headers` in future is excluded by default.

The existing specs stub the dial and cannot observe TLS. The new spec stands
up a local TLS listener and asserts on the bytes the peer received, covering
the wrong-hostname case a `verify_mode`-only change would miss, that the
organization credential is absent from the wire even when a caller supplies
one, and a send/close round trip that fails if the reimplemented connect
stops satisfying the inherited client's contract. A structural guard fails if
a dial appears outside the shared client.

Pin `websocket-client-simple` to `~> 0.9.0`: the reimplementation couples to
instance variables the inherited methods read, and a 0.x minor bump could
rename them.

Signed-off-by: Ante Projić <aprojic@daytona.io>
@vidoc-agent

vidoc-agent Bot commented Sep 9, 2026

Copy link
Copy Markdown

Vidoc security review

Tip

Good to merge — no security issues found. Reviewed 9 changed files.

💬 Have questions? Tag @vidoc in a comment and I'll answer.

@aprojic
aprojic marked this pull request as ready for review September 9, 2026 08:42

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 9 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread sdk-ruby/lib/daytona/common/websocket_dialer.rb Outdated
Comment thread sdk-ruby/spec/daytona/websocket_dialer_spec.rb Outdated
Comment thread sdk-ruby/spec/daytona/websocket_dialer_spec.rb Outdated
…down

Only seed the system trust roots into a store the dialer creates itself.
Adding them to a store the caller supplied would silently widen their trust
policy rather than honour it.

In the spec, close the client each dial opens: the reader loop survives the
peer going away and would otherwise spin re-raising EOFError for the rest of
the run. Also narrow the recording listener's rescue so that reading frames
until the peer disconnects is no longer recorded as a handshake failure,
which made the captured log misreport why a connection ended.

Signed-off-by: Ante Projić <aprojic@daytona.io>
@MDzaja

MDzaja commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

On dropping Authorization from the handshake — the proxy tries it first, so this removes the primary auth path rather than a redundant one.

apps/proxy/pkg/proxy/auth.go:

bearerToken := p.getBearerToken(ctx)
if bearerToken != "" {
    isValid, err := p.getSandboxBearerTokenValid(ctx, sandboxIdOrSignedToken, bearerToken)
    ...
    // If authentication successful, remove the Authorization header to prevent it from being forwarded to the sandbox
    ctx.Request.Header.Del("Authorization")
}
authKey := ctx.Request.Header.Get(SANDBOX_AUTH_KEY_HEADER)

Bearer is attempted first and only falls through to X-Daytona-Preview-Token further down. So both statements in the description hold — the header really is stripped before forwarding, and the bearer path really did succeed. That makes the 429 note a concrete risk rather than a hedge: today these five dials most likely authenticate via bearer and never reach the preview-token branch at all, and after this change it becomes the only path.

Two asks:

  1. Can we confirm the preview-token validation's rate limits and error behaviour before it becomes the sole auth path for these dials? As written we'd be moving them onto a branch that may be comparatively unexercised in production, with no fallback.

  2. Worth reconsidering whether the header allowlist belongs in this PR at all. The reason to withhold the organization credential was that the peer was unverified; with VERIFY_PEER + verify_hostname in place, sending it over TLS to a verified peer is what every other call in the SDK already does. The TLS change addresses the exposure on its own — the allowlist then costs an auth path in exchange for a least-privilege benefit.

Least privilege is a fair argument on its own terms and I'm not against it. But it's now a separable choice with a cost, so I'd suggest landing the TLS fix now (urgent, no downside) and giving the allowlist its own PR where the preview-token reliability question can be settled first.

The rest looks good to me — the cert_store fix in the latest commit is right, and the spec design (real listener, asserting on the bytes the peer received, including the CA-signed/wrong-hostname case plus a positive control) is exactly what I'd want on this change.

…hange

Withholding the organization credential from these handshakes was motivated
by the peer being unverified. With the certificate and hostname now checked
before anything is written, sending it to a verified peer is what the rest of
the SDK already does, so the TLS change addresses the exposure on its own.

Dropping the credential is a least-privilege improvement rather than part of
that fix, and it is not free: the proxy attempts the bearer token before the
preview token, so today these dials most likely authenticate on the bearer
and never reach the preview-token branch. Removing it makes that branch the
only path, with no fallback. Whether it behaves acceptably as the sole path -
in particular its rate-limiting - should be settled on its own terms rather
than as a side effect of a TLS fix.

Leaves the dials sending the headers they sent before, over a verified
connection.

Signed-off-by: Ante Projić <aprojic@daytona.io>
@aprojic
aprojic marked this pull request as draft September 9, 2026 09:13
@aprojic aprojic changed the title fix(sdk-ruby): verify TLS peer on WebSocket dials and drop the org credential fix(sdk-ruby): verify the TLS peer on WebSocket dials Sep 9, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread sdk-ruby/lib/daytona/common/websocket_dialer.rb
@aprojic
aprojic marked this pull request as ready for review September 9, 2026 09:16
@MDzaja
MDzaja merged commit 7f00db1 into main Sep 9, 2026
27 of 28 checks passed
@MDzaja
MDzaja deleted the fix/sdk-ruby-ws-tls-verification branch September 9, 2026 09:19
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants