diff --git a/CHANGELOG.md b/CHANGELOG.md index ea25e5919..a65e2dbc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ ### Fixed +- Prevented revoked lease managers from retaining mediated-egress bridges after lease sharing was removed or downgraded. Thanks @coygeek. - Prevented the Code portal proxy from forwarding coordinator authentication context to lease-controlled code-server requests. Thanks @coygeek. - Rejected GitHub login callback origins that differ from the selected broker unless explicitly allowlisted as a trusted alias, preventing OAuth callbacks from silently redirecting stored credentials. Thanks @TurboTheTurtle. - Rejected WebVNC, Code, and egress bridge tickets in URL query strings by default while retaining an explicit temporary legacy opt-in. Thanks @TurboTheTurtle. diff --git a/worker/src/fleet.ts b/worker/src/fleet.ts index e185312fb..fbaf08a56 100644 --- a/worker/src/fleet.ts +++ b/worker/src/fleet.ts @@ -367,6 +367,7 @@ interface EgressTicketRecord { leaseID: string; owner: string; org: string; + admin?: boolean; role: EgressRole; sessionID: string; profile?: string; @@ -670,8 +671,22 @@ type BridgeAttachment = admin?: boolean; portalSessionHash?: string; } - | { kind: "egress-host"; leaseID: string; sessionID: string } - | { kind: "egress-client"; leaseID: string; sessionID: string } + | { + kind: "egress-host"; + leaseID: string; + sessionID: string; + owner?: string; + org?: string; + admin?: boolean; + } + | { + kind: "egress-client"; + leaseID: string; + sessionID: string; + owner?: string; + org?: string; + admin?: boolean; + } | { kind: "runtime-adapter-agent"; adapterID: string; @@ -1076,6 +1091,7 @@ export class FleetCoordinator { const now = Date.now(); const revoked = new Map(); const revokedViewers = new Map(); + const revokedEgressSessions = new Map(); const codeViewersToCheck: Array<{ socket: WebSocket; portalSessionHash?: string }> = []; for (const socket of this.restoredLeaseBridgeSockets) { const attachment = this.bridgeAttachment(socket); @@ -1098,6 +1114,16 @@ export class FleetCoordinator { revokedViewers.set(socket, "lease access revoked"); continue; } + if ( + (attachment.kind === "egress-host" || attachment.kind === "egress-client") && + !this.leaseManagerAuthorized(lease, attachment) + ) { + revokedEgressSessions.set(egressSocketKey(lease.id, attachment.sessionID), { + leaseID: lease.id, + sessionID: attachment.sessionID, + }); + continue; + } if (attachment.kind === "code-viewer" && attachment.auth === "github") { codeViewersToCheck.push({ socket, @@ -1123,6 +1149,9 @@ export class FleetCoordinator { for (const [leaseID, reason] of revoked) { this.closeLeaseBridges(leaseID, 1008, reason); } + for (const { leaseID, sessionID } of revokedEgressSessions.values()) { + this.clearEgressSession(leaseID, sessionID, 1008, "lease access revoked"); + } for (const socket of this.restoredLeaseBridgeSockets) { const attachment = this.bridgeAttachment(socket); const reason = @@ -1306,7 +1335,7 @@ export class FleetCoordinator { return; } if (previous && previous.sessionID !== sessionID) { - this.clearEgressSessionSockets( + this.clearEgressSession( leaseID, previous.sessionID, 1012, @@ -4772,7 +4801,7 @@ export class FleetCoordinator { const previousShare = normalizedLeaseShare(lease.share); lease.share = sanitizeLeaseShare(input, requestOwner(request)); lease.updatedAt = new Date().toISOString(); - await this.putLeaseShareAndRevokeViewers(lease, previousShare); + await this.putLeaseShareAndRevokeBridges(lease, previousShare); return json({ leaseID: lease.id, share: normalizedLeaseShare(lease.share) }); } if (method === "DELETE") { @@ -4792,25 +4821,25 @@ export class FleetCoordinator { lease.share = sanitizeLeaseShare(share, requestOwner(request)); } lease.updatedAt = new Date().toISOString(); - await this.putLeaseShareAndRevokeViewers(lease, previousShare); + await this.putLeaseShareAndRevokeBridges(lease, previousShare); return json({ leaseID: lease.id, share: normalizedLeaseShare(lease.share) }); } return json({ error: "not_found" }, { status: 404 }); } - private async putLeaseShareAndRevokeViewers( + private async putLeaseShareAndRevokeBridges( lease: LeaseRecord, previousShare: NormalizedLeaseShare, ): Promise { await this.withBridgeTicketLock(async () => { await this.putLease(lease); if (leaseShareAccessShrank(previousShare, normalizedLeaseShare(lease.share))) { - this.revokeUnauthorizedLeaseViewers(lease); + this.revokeUnauthorizedLeaseBridges(lease); } }); } - private revokeUnauthorizedLeaseViewers(lease: LeaseRecord): void { + private revokeUnauthorizedLeaseBridges(lease: LeaseRecord): void { const code = 1008; const reason = "lease access revoked"; for (const viewer of this.openWebVNCViewers(lease.id)) { @@ -4833,6 +4862,32 @@ export class FleetCoordinator { this.clearCodeViewer(lease.id, id, viewer, code, reason); closeSocket(viewer, code, reason); } + const revokedEgressSessions = new Set(); + for (const socket of [...this.egressHosts.values(), ...this.egressClients.values()]) { + const attachment = this.bridgeAttachment(socket); + if ( + (attachment?.kind !== "egress-host" && attachment?.kind !== "egress-client") || + attachment.leaseID !== lease.id || + this.leaseManagerAuthorized(lease, attachment) + ) { + continue; + } + revokedEgressSessions.add(attachment.sessionID); + } + for (const sessionID of revokedEgressSessions) { + this.clearEgressSession(lease.id, sessionID, code, reason); + } + } + + private leaseManagerAuthorized( + lease: LeaseRecord, + principal: { owner?: string; org?: string; admin?: boolean }, + ): boolean { + if (!completeBridgePrincipal(principal)) { + return false; + } + const role = this.leaseAccessRoleForPrincipal(lease, principal); + return role === "owner" || role === "manage"; } private leaseViewerAuthorized( @@ -6012,7 +6067,7 @@ export class FleetCoordinator { const previousShare = normalizedLeaseShare(lease.share); lease.share = sanitizeLeaseShare(input, requestOwner(request)); lease.updatedAt = new Date().toISOString(); - await this.putLeaseShareAndRevokeViewers(lease, previousShare); + await this.putLeaseShareAndRevokeBridges(lease, previousShare); return json({ leaseID: lease.id, slug: lease.slug || lease.id, @@ -6049,7 +6104,7 @@ export class FleetCoordinator { } lease.share = sanitizeLeaseShare(share, requestOwner(request)); lease.updatedAt = new Date().toISOString(); - await this.putLeaseShareAndRevokeViewers(lease, previousShare); + await this.putLeaseShareAndRevokeBridges(lease, previousShare); const embedded = url.searchParams.get("embed") === "1"; return new Response(null, { status: 303, @@ -6172,6 +6227,7 @@ export class FleetCoordinator { leaseID: lease.id, owner: requestOwner(request), org: requestOrg(request, this.env), + admin, role, sessionID, allow: boundedEgressAllowlist(input.allow), @@ -6204,45 +6260,52 @@ export class FleetCoordinator { { status: 426 }, ); } - const consumed = await this.consumeEgressTicket(request, identifier, role); - if (consumed.status === "invalid") { - return json( - { error: "egress_ticket_required", message: "valid egress bridge ticket required" }, - { status: 401 }, + return await this.withBridgeTicketLock(async () => { + const consumed = await this.consumeEgressTicketUnderLock(request, identifier, role); + if (consumed.status === "invalid") { + return json( + { error: "egress_ticket_required", message: "valid egress bridge ticket required" }, + { status: 401 }, + ); + } + if (consumed.status === "not_found") { + return notFound(); + } + const { lease, ticket } = consumed; + if (lease.state !== "active") { + return json( + { error: "egress_unavailable", message: "lease is not active" }, + { status: 409 }, + ); + } + const upgrade = this.state.createWebSocketUpgrade(); + const agent = upgrade.socket; + const principal = egressTicketPrincipal(ticket); + const attachment: BridgeAttachment = { + kind: role === "host" ? "egress-host" : "egress-client", + leaseID: lease.id, + sessionID: ticket.sessionID, + ...principal, + }; + const ticketCreatedAt = new Date(ticket.createdAt); + this.activateEgressSession( + lease.id, + ticket.sessionID, + ticket.profile, + ticket.allow ?? [], + ticketCreatedAt, ); - } - if (consumed.status === "not_found") { - return notFound(); - } - const { lease, ticket } = consumed; - if (lease.state !== "active") { - return json({ error: "egress_unavailable", message: "lease is not active" }, { status: 409 }); - } - const upgrade = this.state.createWebSocketUpgrade(); - const agent = upgrade.socket; - const attachment: BridgeAttachment = { - kind: role === "host" ? "egress-host" : "egress-client", - leaseID: lease.id, - sessionID: ticket.sessionID, - }; - const ticketCreatedAt = new Date(ticket.createdAt); - this.activateEgressSession( - lease.id, - ticket.sessionID, - ticket.profile, - ticket.allow ?? [], - ticketCreatedAt, - ); - const key = egressSocketKey(lease.id, ticket.sessionID); - if (role === "host") { - closeSocket(this.egressHosts.get(key), 1012, "replaced by a newer egress host"); - this.egressHosts.set(key, agent); - } else { - closeSocket(this.egressClients.get(key), 1012, "replaced by a newer egress client"); - this.egressClients.set(key, agent); - } - this.acceptBridgeWebSocket(agent, attachment); - return upgrade.response; + const key = egressSocketKey(lease.id, ticket.sessionID); + if (role === "host") { + closeSocket(this.egressHosts.get(key), 1012, "replaced by a newer egress host"); + this.egressHosts.set(key, agent); + } else { + closeSocket(this.egressClients.get(key), 1012, "replaced by a newer egress client"); + this.egressClients.set(key, agent); + } + this.acceptBridgeWebSocket(agent, attachment); + return upgrade.response; + }); } private async egressStatus(request: Request, identifier: string): Promise { @@ -7965,7 +8028,7 @@ export class FleetCoordinator { this.clearEgressLease(leaseID, code, reason); } - private clearEgressSessionSockets( + private clearEgressSession( leaseID: string, sessionID: string, code: number, @@ -7976,6 +8039,9 @@ export class FleetCoordinator { closeSocket(this.egressClients.get(key), code, reason); this.egressHosts.delete(key); this.egressClients.delete(key); + if (this.egressSessions.get(leaseID)?.sessionID === sessionID) { + this.egressSessions.delete(leaseID); + } } private async webVNCViewer(request: Request, identifier: string): Promise { @@ -8356,31 +8422,43 @@ export class FleetCoordinator { request: Request, identifier: string, role: EgressRole, + ): Promise> { + return this.withBridgeTicketLock(() => + this.consumeEgressTicketUnderLock(request, identifier, role), + ); + } + + private async consumeEgressTicketUnderLock( + request: Request, + identifier: string, + role: EgressRole, ): Promise> { const value = bridgeTicketFromRequest(request, this.env); if (!validEgressTicket(value)) { return { status: "invalid" }; } - return this.withBridgeTicketLock(async () => { - const key = egressTicketKey(value); - const ticket = await this.state.storage.get(key); - if (!ticket || ticket.ticket !== value) { - return { status: "invalid" }; - } - if (Date.parse(ticket.expiresAt) <= Date.now()) { - await this.state.storage.delete(key); - return { status: "invalid" }; - } - if (ticket.role !== role) { - return { status: "invalid" }; - } - const lease = await this.getLease(ticket.leaseID); - if (!lease || !identifierMatchesLease(identifier, lease)) { - return { status: "not_found" }; - } + const key = egressTicketKey(value); + const ticket = await this.state.storage.get(key); + if (!ticket || ticket.ticket !== value) { + return { status: "invalid" }; + } + if (Date.parse(ticket.expiresAt) <= Date.now()) { await this.state.storage.delete(key); - return { status: "accepted", ticket, lease }; - }); + return { status: "invalid" }; + } + if (ticket.role !== role) { + return { status: "invalid" }; + } + const lease = await this.getLease(ticket.leaseID); + if (!lease || !identifierMatchesLease(identifier, lease)) { + return { status: "not_found" }; + } + if (!this.leaseManagerAuthorized(lease, egressTicketPrincipal(ticket))) { + await this.state.storage.delete(key); + return { status: "invalid" }; + } + await this.state.storage.delete(key); + return { status: "accepted", ticket, lease }; } private async cleanupExpiredEgressTickets(): Promise { @@ -13827,7 +13905,9 @@ function bridgeAttachment(value: unknown): BridgeAttachment | undefined { : undefined; case "egress-host": case "egress-client": - return typeof attachment.leaseID === "string" && typeof attachment.sessionID === "string" + return typeof attachment.leaseID === "string" && + typeof attachment.sessionID === "string" && + validOptionalEgressBridgePrincipal(attachment) ? attachment : undefined; case "runtime-adapter-agent": @@ -13880,6 +13960,23 @@ function completeBridgePrincipal(value: { ); } +function validOptionalEgressBridgePrincipal(value: { + owner?: unknown; + org?: unknown; + admin?: unknown; +}): boolean { + const legacy = value.owner === undefined && value.org === undefined && value.admin === undefined; + return legacy || completeBridgePrincipal(value); +} + +function egressTicketPrincipal(ticket: EgressTicketRecord): { + owner: string; + org: string; + admin: boolean; +} { + return { owner: ticket.owner, org: ticket.org, admin: ticket.admin === true }; +} + function bridgeTags(attachment: BridgeAttachment): string[] { if (attachment.kind === "control") { return [`control:${attachment.clientID}`, `owner:${attachment.owner}`, `org:${attachment.org}`]; @@ -14934,10 +15031,19 @@ function leaseShareAccessShrank( previous: NormalizedLeaseShare, current: NormalizedLeaseShare, ): boolean { - if (previous.org && !current.org) { + if (leaseShareRoleRank(current.org) < leaseShareRoleRank(previous.org)) { return true; } - return Object.keys(previous.users).some((user) => current.users[user] === undefined); + return Object.entries(previous.users).some( + ([user, role]) => leaseShareRoleRank(current.users[user]) < leaseShareRoleRank(role), + ); +} + +function leaseShareRoleRank(role: LeaseShareRole | undefined): number { + if (role === "manage") { + return 2; + } + return role === "use" ? 1 : 0; } function sanitizeLeaseShare(input: Partial, updatedBy: string): LeaseShare | undefined { diff --git a/worker/test/coordinator-runtime.test.ts b/worker/test/coordinator-runtime.test.ts index a51dfffb2..d22abdf8d 100644 --- a/worker/test/coordinator-runtime.test.ts +++ b/worker/test/coordinator-runtime.test.ts @@ -11,7 +11,7 @@ import { import { FleetCoordinator } from "../src/fleet"; import { githubAuthRoute } from "../src/oauth"; import { runtimeAdapterRelayFrameLimit } from "../src/runtime-adapter-relay"; -import type { Env } from "../src/types"; +import type { Env, LeaseRecord } from "../src/types"; afterEach(() => { vi.unstubAllGlobals(); @@ -19,8 +19,10 @@ afterEach(() => { class MemoryStorage implements CoordinatorStorage { private readonly values = new Map(); + beforeGet?: (key: string) => Promise; async get(key: string): Promise { + await this.beforeGet?.(key); return this.values.get(key) as T | undefined; } @@ -52,6 +54,7 @@ class MemoryRuntime implements CoordinatorRuntime { upgradeOptions?: CoordinatorWebSocketUpgradeOptions; acceptedTags?: string[]; acceptedAttachment?: unknown; + readonly socketCloses: Array<{ code?: number; reason?: string }> = []; private readonly attachments = new WeakMap(); private exclusiveTail: Promise = Promise.resolve(); @@ -78,7 +81,9 @@ class MemoryRuntime implements CoordinatorRuntime { socket: { readyState: WebSocket.OPEN, send: () => undefined, - close: () => undefined, + close: (code?: number, reason?: string) => { + this.socketCloses.push({ code, reason }); + }, serializeAttachment: () => undefined, } as unknown as WebSocket, response: new Response(null), @@ -159,6 +164,150 @@ describe("coordinator runtimes", () => { }); }); + it("binds egress sockets to their ticket principal and reauthorizes before acceptance", async () => { + const runtime = new MemoryRuntime(); + const coordinator = new FleetCoordinator(runtime, { + CRABBOX_DEFAULT_ORG: "example-org", + } as Env); + const lease: LeaseRecord = { + id: "cbx_000000000001", + slug: "shared-egress", + provider: "external", + lifecycle: "registered", + target: "linux", + cloudID: "external-shared-egress", + owner: "owner@example.com", + org: "example-org", + share: { users: { "manager@example.com": "manage" } }, + profile: "default", + class: "default", + serverType: "external", + serverID: 0, + serverName: "shared-egress", + providerKey: "external-shared-egress", + host: "127.0.0.1", + sshUser: "crabbox", + sshPort: "22", + workRoot: "/work/crabbox", + keep: true, + ttlSeconds: 3600, + estimatedHourlyUSD: 0, + maxEstimatedUSD: 0, + state: "active", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; + await runtime.storage.put(`lease:${lease.id}`, lease); + const managerHeaders = { + "x-crabbox-owner": "manager@example.com", + "x-crabbox-org": "example-org", + }; + const ownerHeaders = { + "x-crabbox-owner": "owner@example.com", + "x-crabbox-org": "example-org", + }; + const createTicket = async (sessionID: string): Promise => { + const response = await coordinator.fetch( + new Request("https://coordinator.test/v1/leases/shared-egress/egress/ticket", { + method: "POST", + headers: { ...managerHeaders, "content-type": "application/json" }, + body: JSON.stringify({ role: "host", sessionID }), + }), + ); + expect(response.status).toBe(200); + return ((await response.json()) as { ticket: string }).ticket; + }; + + const acceptedTicket = await createTicket("egress_accepted"); + const accepted = await coordinator.fetch( + new Request("https://coordinator.test/v1/leases/shared-egress/egress/host", { + headers: { + upgrade: "websocket", + authorization: `Bearer ${acceptedTicket}`, + }, + }), + ); + expect(accepted.status).toBe(200); + expect(runtime.acceptedAttachment).toEqual({ + kind: "egress-host", + leaseID: lease.id, + sessionID: "egress_accepted", + owner: "manager@example.com", + org: "example-org", + admin: false, + }); + + const revokedTicket = await createTicket("egress_revoked"); + const downgraded = await coordinator.fetch( + new Request("https://coordinator.test/v1/leases/shared-egress/share", { + method: "PUT", + headers: { ...ownerHeaders, "content-type": "application/json" }, + body: JSON.stringify({ users: { "manager@example.com": "use" } }), + }), + ); + expect(downgraded.status).toBe(200); + runtime.acceptedAttachment = undefined; + const rejected = await coordinator.fetch( + new Request("https://coordinator.test/v1/leases/shared-egress/egress/host", { + headers: { + upgrade: "websocket", + authorization: `Bearer ${revokedTicket}`, + }, + }), + ); + expect(rejected.status).toBe(401); + expect(runtime.acceptedAttachment).toBeUndefined(); + expect(runtime.storage.value(`egress-ticket:${revokedTicket}`)).toBeUndefined(); + + const restored = await coordinator.fetch( + new Request("https://coordinator.test/v1/leases/shared-egress/share", { + method: "PUT", + headers: { ...ownerHeaders, "content-type": "application/json" }, + body: JSON.stringify({ users: { "manager@example.com": "manage" } }), + }), + ); + expect(restored.status).toBe(200); + const raceTicket = await createTicket("egress_race"); + runtime.socketCloses.length = 0; + let releaseTicketRead!: () => void; + const ticketReadBlocked = new Promise((resolve) => { + releaseTicketRead = resolve; + }); + let signalTicketRead!: () => void; + const ticketReadStarted = new Promise((resolve) => { + signalTicketRead = resolve; + }); + runtime.storage.beforeGet = async (key) => { + if (key === `egress-ticket:${raceTicket}`) { + signalTicketRead(); + await ticketReadBlocked; + } + }; + const acceptance = coordinator.fetch( + new Request("https://coordinator.test/v1/leases/shared-egress/egress/host", { + headers: { + upgrade: "websocket", + authorization: `Bearer ${raceTicket}`, + }, + }), + ); + await ticketReadStarted; + const concurrentDowngrade = coordinator.fetch( + new Request("https://coordinator.test/v1/leases/shared-egress/share", { + method: "PUT", + headers: { ...ownerHeaders, "content-type": "application/json" }, + body: JSON.stringify({ users: { "manager@example.com": "use" } }), + }), + ); + runtime.storage.beforeGet = undefined; + releaseTicketRead(); + const [raceAccepted, raceDowngraded] = await Promise.all([acceptance, concurrentDowngrade]); + expect(raceAccepted.status).toBe(200); + expect(raceDowngraded.status).toBe(200); + expect(runtime.socketCloses).toContainEqual({ code: 1008, reason: "lease access revoked" }); + }); + it("keeps provider-backed portal requests outside the lifecycle queue", () => { for (const [method, path] of [ ["GET", "/portal"], diff --git a/worker/test/fleet.test.ts b/worker/test/fleet.test.ts index 88aac95ca..deb630495 100644 --- a/worker/test/fleet.test.ts +++ b/worker/test/fleet.test.ts @@ -175,11 +175,17 @@ describe("runtime adapter relay", () => { kind: "egress-host", leaseID: lease.id, sessionID: "egress_restart", + owner: "alice@example.com", + org: "example-org", + admin: false, }), new FakeWebSocket({ kind: "egress-client", leaseID: lease.id, sessionID: "egress_restart", + owner: "alice@example.com", + org: "example-org", + admin: false, }), new FakeWebSocket({ kind: "runtime-adapter-agent", @@ -211,6 +217,85 @@ describe("runtime adapter relay", () => { expect(relay.runtimeAdapterAgents.get("example-adapter")).toBe(restored[3]); }); + it("fails closed restored egress sessions without current manage access", async () => { + const storage = new MemoryStorage(); + const revokedLease = testLease({ + id: "cbx_000000000011", + provider: "external", + lifecycle: "registered", + owner: "owner@example.com", + org: "example-org", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + const legacyLease = testLease({ + ...revokedLease, + id: "cbx_000000000012", + }); + const adminLease = testLease({ + ...revokedLease, + id: "cbx_000000000013", + }); + for (const lease of [revokedLease, legacyLease, adminLease]) { + storage.seed(`lease:${lease.id}`, lease); + } + const revoked = ["egress-host", "egress-client"].map( + (kind) => + new FakeWebSocket({ + kind, + leaseID: revokedLease.id, + sessionID: "egress_revoked", + owner: "former-manager@example.com", + org: "example-org", + admin: false, + }), + ); + const legacy = ["egress-host", "egress-client"].map( + (kind) => + new FakeWebSocket({ + kind, + leaseID: legacyLease.id, + sessionID: "egress_legacy", + }), + ); + const admin = ["egress-host", "egress-client"].map( + (kind) => + new FakeWebSocket({ + kind, + leaseID: adminLease.id, + sessionID: "egress_admin", + owner: "admin@example.com", + org: "other-org", + admin: true, + }), + ); + const fleet = new FleetDurableObject( + { + storage, + getWebSockets: () => [...revoked, ...legacy, ...admin] as unknown as WebSocket[], + } as unknown as DurableObjectState, + { CRABBOX_DEFAULT_ORG: "default-org" } as Env, + ); + + expect((await fleet.fetch(request("GET", "/v1/health"))).status).toBe(200); + for (const socket of [...revoked, ...legacy]) { + expect(socket.closeCode).toBe(1008); + expect(socket.closeReason).toBe("lease access revoked"); + } + for (const socket of admin) { + expect(socket.closeCode).toBeUndefined(); + } + const relay = fleet as unknown as { + egressHosts: Map; + egressClients: Map; + egressSessions: Map; + }; + expect(relay.egressHosts.size).toBe(1); + expect(relay.egressClients.size).toBe(1); + expect(relay.egressSessions.has(revokedLease.id)).toBe(false); + expect(relay.egressSessions.has(legacyLease.id)).toBe(false); + expect(relay.egressSessions.get(adminLease.id)?.sessionID).toBe("egress_admin"); + }); + it("rejects hibernated Code viewers whose portal session logged out", async () => { const storage = new MemoryStorage(); const lease = testLease({ @@ -8282,6 +8367,90 @@ describe("fleet lease identity and idle", () => { expect(retainedWebAgent.sentJSON()).toEqual([{ retained: true }]); }); + it("revokes a live egress session when manage access is downgraded", async () => { + const storage = new MemoryStorage(); + const fleet = testFleet(storage); + const leaseID = "cbx_000000000001"; + const ownerHeaders = { + "x-crabbox-owner": "owner@example.com", + "x-crabbox-org": "example-org", + }; + storage.seed( + `lease:${leaseID}`, + testLease({ + id: leaseID, + slug: "shared-live-egress", + owner: "owner@example.com", + org: "example-org", + share: { + users: { + "manager@example.com": "manage", + "other-manager@example.com": "manage", + }, + }, + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + ); + const host = new FakeWebSocket({ + kind: "egress-host", + leaseID, + sessionID: "egress_shared", + owner: "manager@example.com", + org: "example-org", + admin: false, + }); + const client = new FakeWebSocket({ + kind: "egress-client", + leaseID, + sessionID: "egress_shared", + owner: "manager@example.com", + org: "example-org", + admin: false, + }); + const relay = fleet as unknown as { + egressHosts: Map; + egressClients: Map; + egressSessions: Map< + string, + { sessionID: string; allow: string[]; createdAt: string; updatedAt: string } + >; + }; + const key = `${leaseID}\0egress_shared`; + relay.egressHosts.set(key, host as unknown as WebSocket); + relay.egressClients.set(key, client as unknown as WebSocket); + relay.egressSessions.set(leaseID, { + sessionID: "egress_shared", + allow: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const unrelatedRemoval = await fleet.fetch( + request("DELETE", "/v1/leases/shared-live-egress/share", { + headers: ownerHeaders, + body: { user: "other-manager@example.com" }, + }), + ); + expect(unrelatedRemoval.status).toBe(200); + expect(host.closeCode).toBeUndefined(); + expect(client.closeCode).toBeUndefined(); + + const downgraded = await fleet.fetch( + request("PUT", "/v1/leases/shared-live-egress/share", { + headers: ownerHeaders, + body: { users: { "manager@example.com": "use" } }, + }), + ); + expect(downgraded.status).toBe(200); + expect(host.closeCode).toBe(1008); + expect(host.closeReason).toBe("lease access revoked"); + expect(client.closeCode).toBe(1008); + expect(client.closeReason).toBe("lease access revoked"); + expect(relay.egressHosts.has(key)).toBe(false); + expect(relay.egressClients.has(key)).toBe(false); + expect(relay.egressSessions.has(leaseID)).toBe(false); + }); + it("revokes org-only live viewers after a portal share update", async () => { const storage = new MemoryStorage(); const fleet = testFleet(storage);