fix(rpc): bind io_stream sessions to creator to prevent terminal/fm hijack

createTerminal and createFM correctly check server ownership before
issuing a stream UUID, but terminalStream and fmStream only verified
that the UUID existed. Any authenticated user holding a valid stream
UUID could attach to it, gaining the original creator's live shell or
file-manager session — and the UUID is exposed via URL path (referer
leaks, access logs, browser history, frontend error reporters).

Bind the creator user ID into ioStreamContext at CreateStream time,
expose StreamOwnership and IsStreamAuthorizedForUser, and check
ownership in terminalStream/fmStream before the WebSocket upgrade so a
rejected attempt does not tear down the legitimate stream via defer.

NAT streams are also routed through CreateStream(_, 0); they are not
reachable from /ws/terminal or /ws/file so a sentinel user ID is fine.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
naiba
2026-05-18 15:16:40 +00:00
co-authored by naiba/CloudCode
parent ea7ad67f03
commit 36297699f5
6 changed files with 238 additions and 5 deletions
+33 -1
View File
@@ -11,6 +11,7 @@ import (
)
type ioStreamContext struct {
creatorUserID uint64
userIo io.ReadWriteCloser
agentIo io.ReadWriteCloser
userIoConnectCh chan struct{}
@@ -31,16 +32,47 @@ var bufPool = sync.Pool{
},
}
func (s *NezhaHandler) CreateStream(streamId string) {
func (s *NezhaHandler) CreateStream(streamId string, creatorUserID uint64) {
s.ioStreamMutex.Lock()
defer s.ioStreamMutex.Unlock()
s.ioStreams[streamId] = &ioStreamContext{
creatorUserID: creatorUserID,
userIoConnectCh: make(chan struct{}),
agentIoConnectCh: make(chan struct{}),
}
}
// IsStreamAuthorizedForUser checks whether the requesting user may attach to
// the stream. A stream is reachable only by its creator or by an admin; any
// other authenticated user must be rejected. Unknown streams are always
// rejected.
func (s *NezhaHandler) IsStreamAuthorizedForUser(streamId string, userID uint64, isAdmin bool) bool {
creator, found := s.StreamOwnership(streamId)
if !found {
return false
}
if isAdmin {
return true
}
return creator == userID
}
// StreamOwnership returns the user ID that created the stream and whether the
// stream is still tracked. Callers must compare the returned creator against
// the requesting user before attaching to the stream — without this the
// channel becomes a session-hijack primitive (terminal/file manager RCE).
func (s *NezhaHandler) StreamOwnership(streamId string) (uint64, bool) {
s.ioStreamMutex.RLock()
defer s.ioStreamMutex.RUnlock()
ctx, ok := s.ioStreams[streamId]
if !ok {
return 0, false
}
return ctx.creatorUserID, true
}
func (s *NezhaHandler) GetStream(streamId string) (*ioStreamContext, error) {
s.ioStreamMutex.RLock()
defer s.ioStreamMutex.RUnlock()
+69 -1
View File
@@ -12,7 +12,7 @@ func TestIOStream(t *testing.T) {
const testStreamID = "ffffffff-ffff-ffff-ffff-ffffffffffff"
handler.CreateStream(testStreamID)
handler.CreateStream(testStreamID, 0)
userIo, agentIo := newPipeReadWriter(), newPipeReadWriter()
defer func() {
userIo.Close()
@@ -105,3 +105,71 @@ func newPipeReadWriter() io.ReadWriteCloser {
io.WriteCloser
}{r, w}
}
func TestStreamOwnershipReturnsCreatorUserID(t *testing.T) {
h := NewNezhaHandler()
h.CreateStream("alice-stream", 100)
creator, found := h.StreamOwnership("alice-stream")
if !found {
t.Fatalf("expected stream to be found after CreateStream")
}
if creator != 100 {
t.Fatalf("expected creator user ID 100, got %d", creator)
}
}
func TestStreamOwnershipReturnsNotFoundForUnknownID(t *testing.T) {
h := NewNezhaHandler()
if _, found := h.StreamOwnership("nonexistent"); found {
t.Fatalf("expected unknown stream id to report not-found")
}
}
func TestStreamOwnershipPreservesPerStreamCreator(t *testing.T) {
h := NewNezhaHandler()
h.CreateStream("alice-stream", 100)
h.CreateStream("bob-stream", 200)
aliceCreator, _ := h.StreamOwnership("alice-stream")
bobCreator, _ := h.StreamOwnership("bob-stream")
if aliceCreator != 100 || bobCreator != 200 {
t.Fatalf("expected per-stream creator IDs alice=100 bob=200, got alice=%d bob=%d",
aliceCreator, bobCreator)
}
}
func TestIsStreamAuthorizedForUserAllowsCreator(t *testing.T) {
h := NewNezhaHandler()
h.CreateStream("alice-stream", 100)
if !h.IsStreamAuthorizedForUser("alice-stream", 100, false) {
t.Fatalf("creator must be authorized to attach to their own stream")
}
}
func TestIsStreamAuthorizedForUserDeniesForeignMember(t *testing.T) {
h := NewNezhaHandler()
h.CreateStream("alice-stream", 100)
if h.IsStreamAuthorizedForUser("alice-stream", 200, false) {
t.Fatalf("foreign member must not be authorized — session hijack would be possible")
}
}
func TestIsStreamAuthorizedForUserAllowsAdmin(t *testing.T) {
h := NewNezhaHandler()
h.CreateStream("alice-stream", 100)
if !h.IsStreamAuthorizedForUser("alice-stream", 999, true) {
t.Fatalf("admin must be authorized to attach regardless of creator")
}
}
func TestIsStreamAuthorizedForUserDeniesUnknownStream(t *testing.T) {
h := NewNezhaHandler()
if h.IsStreamAuthorizedForUser("nonexistent", 100, true) {
t.Fatalf("unknown stream id must not authorize even admin")
}
}