Files
privatae-bastion/client-go/lane_bounds.go
T
Privatae 4012da0b75
ci / personal-data (push) Successful in 10s
ci / test (push) Successful in 5m30s
ci / fuzz (push) Successful in 3m16s
bastion 0.56.0
2026-10-28 02:24:07 +00:00

123 lines
4.7 KiB
Go

package main
// What a paired host sends down a worker lane is read at a size this machine
// chose, never at one the host chose: the host picks a frame's length, and a
// frame of any length must not be held whole in this machine's memory. The
// egress lane (egress_relay.go) and the context socket (contextsock.go) follow
// the same rule.
//
// - A job's first frame (the H spec on a harness worker, the S spec on a
// session worker) is read up to its lane's bound and refused past it, by
// name, before any of it is parsed.
// - What a lane carries after that is streamed: terminal input into the pty
// (session.go), the replica's bytes into its socket (mirror.go). Frames that
// nothing reads (a harness run and a root command, which read nothing after
// their spec) are dropped as they arrive.
import (
"errors"
"fmt"
"io"
"net/http"
"github.com/gorilla/websocket"
)
const (
// harnessSpecMaxBytes bounds the H frame. Its bulk is the worktree tar
// (worktree_b64). The largest tree this lane hands back is
// maxReturnedTreeBytes (9 MiB, so 12 MiB in base64) inside one message of
// uvicornWSMaxSize (16 MiB), the most the host takes in (harness.go). A host
// that sends that tree into its next run needs those 12 MiB, plus the
// prompt and the run's credentials. Twice the lane's message size holds
// that with 20 MiB to spare.
harnessSpecMaxBytes = 2 * uvicornWSMaxSize
// sessionSpecMaxBytes bounds the S frame: a signed grant (op ids and the
// person's own words), ids, a public key, and for session:root:exec the
// command, itself held to rootExecCommandMax (64 KiB), which is at most
// 384 KiB even when JSON escapes every byte as \u00XX. 1 MiB is the bound
// Go's own HTTP server puts on a request head.
sessionSpecMaxBytes = http.DefaultMaxHeaderBytes
// sessionInputMaxBytes bounds one frame on an open terminal. The host relays
// what the person's browser sent it, one message each, and its own server
// takes a message up to uvicornWSMaxSize; the tag is one byte more. Input is
// streamed into the pty, so this caps a frame's length, not memory.
sessionInputMaxBytes = uvicornWSMaxSize + 1
// sessionResizeMaxBytes bounds the body of an R frame, {"cols":N,"rows":N}.
sessionResizeMaxBytes = 4 << 10
// heartbeatReplyMaxBytes bounds the host's answer to a heartbeat. It
// carries directives and the lists of grants this box should hold, each
// entry an id and a signature of about 130 bytes, so 4 MiB is tens of
// thousands of grants. Nothing in it is bulk: an update names a version,
// and the binary comes from the release manifest, not from this reply.
heartbeatReplyMaxBytes = 4 << 20
)
// errLaneNotJob is a first frame that is not a binary frame with a tag.
var errLaneNotJob = errors.New("the first frame is not a job")
// readLaneJob reads a worker's first frame: its tag, and its body up to
// maxFor(tag) bytes. A body past its bound is never kept: tooBig reports it,
// and body is nil. A tag the caller does not take should get a bound of 0;
// its body is then not read at all.
func readLaneJob(ws *websocket.Conn, maxFor func(tag byte) int64) (tag byte, body []byte, tooBig bool, err error) {
mt, r, err := ws.NextReader()
if err != nil {
return 0, nil, false, err
}
if mt != websocket.BinaryMessage {
return 0, nil, false, errLaneNotJob
}
var t [1]byte
if _, err := io.ReadFull(r, t[:]); err != nil {
return 0, nil, false, errLaneNotJob // an empty frame
}
max := maxFor(t[0])
if max <= 0 {
return t[0], nil, false, nil
}
body, err = io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return t[0], nil, false, err
}
if int64(len(body)) > max {
return t[0], nil, true, nil
}
return t[0], body, false, nil
}
// drainLane reads and drops every frame until the socket fails, holding none
// of them, and returns that failure. A lane that reads nothing after its job
// still has to read, to answer pings and to notice the host going away.
func drainLane(ws *websocket.Conn) error {
for {
_, r, err := ws.NextReader()
if err != nil {
return err
}
if _, err := io.Copy(io.Discard, r); err != nil {
return err
}
}
}
// readHeartbeatReply reads the host's answer to a heartbeat up to
// heartbeatReplyMaxBytes. ok is false for a reply past the bound or one that
// failed to arrive: such a beat is treated as unanswered, so none of its
// directives runs and it does not count as the host answering.
func readHeartbeatReply(resp *http.Response) (body []byte, ok bool) {
body, err := io.ReadAll(io.LimitReader(resp.Body, heartbeatReplyMaxBytes+1))
if err != nil {
return nil, false
}
if len(body) > heartbeatReplyMaxBytes {
fmt.Printf("heartbeat: the host's reply is more than %d MiB; ignored\n", heartbeatReplyMaxBytes>>20)
return nil, false
}
return body, true
}