Files
Termix/plugins/session-sharing/tests/backend/security.test.ts
T

426 lines
13 KiB
TypeScript

/**
* Guest tokens, participant permissions and expiry. These are what keep a
* share link from becoming a way into someone else's session, so each rule
* the old core code enforced has a test here.
*/
import { afterEach, describe, expect, it } from "vitest";
import { asUser, expireShare, startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
async function createShare(
s: TestServer,
body: Record<string, unknown> = {},
): Promise<{ shareId: string; linkToken: string | null }> {
if (!s.live.sessions.has("sess-1")) s.live.addSession("sess-1", "alice");
const res = await s.request("POST", "/create", {
body: {
hostId: 1,
sessionId: "sess-1",
protocol: "ssh",
shareType: "link",
permissionLevel: "read-only",
...body,
},
});
expect(res.status).toBe(200);
return res.body;
}
async function roomOnStage(s: TestServer) {
const created = await s.request("POST", "/rooms", { body: { name: "R" } });
const roomId = created.body.room.id as string;
await s.request("POST", `/rooms/${roomId}/members`, {
body: { userIds: ["bob"] },
});
s.live.addSession("sess-stage", "alice");
await s.request("POST", `/rooms/${roomId}/present`, {
body: { protocol: "ssh", sessionId: "sess-stage", hostId: 1 },
});
const link = await s.request("POST", `/rooms/${roomId}/guest-link`, {
body: { enabled: true },
});
return { roomId, token: link.body.guestLinkToken as string };
}
describe("share link guests", () => {
it("resolves a live link without any host details", async () => {
server = await startServer();
const { linkToken } = await createShare(server, {
permissionLevel: "read-write",
});
const res = await server.request("GET", `/resolve/${linkToken}`, {
user: null,
});
expect(res.status).toBe(200);
expect(res.body).toEqual({
protocol: "ssh",
permissionLevel: "read-write",
wsPath: `/plugin-ws/ssh-terminal/terminal?shareToken=${linkToken}`,
});
const joined = await server.guests.resolve({
shareToken: linkToken!,
clientIp: "1.1.1.1",
});
expect(joined).toEqual({
ok: true,
share: expect.objectContaining({
sessionId: "sess-1",
permissionLevel: "read-write",
}),
});
});
it("keeps a read-only link read-only", async () => {
server = await startServer();
const { linkToken } = await createShare(server);
const joined = await server.guests.resolve({
shareToken: linkToken!,
clientIp: "1.1.1.1",
});
expect(joined).toMatchObject({ share: { permissionLevel: "read-only" } });
});
it("refuses an unknown or guessed token", async () => {
server = await startServer();
await createShare(server);
for (const token of ["nope", "", "%00"]) {
const res = await server.request("GET", `/resolve/${token || "x"}`, {
user: null,
});
expect(res.status).toBe(404);
expect(
await server.guests.resolve({ shareToken: token, clientIp: "1.1.1.1" }),
).toMatchObject({ ok: false });
}
});
it("refuses a revoked link", async () => {
server = await startServer();
const { shareId, linkToken } = await createShare(server);
await server.request("DELETE", `/${shareId}`);
expect(
(await server.request("GET", `/resolve/${linkToken}`, { user: null }))
.status,
).toBe(404);
expect(
await server.guests.resolve({
shareToken: linkToken!,
clientIp: "1.1.1.1",
}),
).toEqual({ ok: false, reason: "Invalid or expired share link" });
});
it("refuses a link once sharing is switched off", async () => {
server = await startServer();
const { linkToken } = await createShare(server);
await server.mock.ctx.settings.set("globallyEnabled", false);
expect(
(await server.request("GET", `/resolve/${linkToken}`, { user: null }))
.status,
).toBe(404);
expect(
await server.guests.resolve({
shareToken: linkToken!,
clientIp: "1.1.1.1",
}),
).toMatchObject({ ok: false });
});
it("answers 404 once the session is gone", async () => {
server = await startServer();
const { linkToken } = await createShare(server);
server.live.sessions.delete("sess-1");
const res = await server.request("GET", `/resolve/${linkToken}`, {
user: null,
});
expect(res.status).toBe(404);
});
it("rate limits resolves per IP", async () => {
server = await startServer();
let last = 0;
for (let i = 0; i < 31; i++) {
last = (await server.request("GET", "/resolve/guess", { user: null }))
.status;
}
expect(last).toBe(429);
});
it("records a guest join against the share", async () => {
server = await startServer();
const { shareId } = await createShare(server);
await server.guests.recordJoin(shareId);
expect(
server.db.sqlite
.prepare(
"SELECT join_count, (SELECT guest_label FROM p_session_sharing_share_participants) AS label FROM p_session_sharing_shares",
)
.get(),
).toEqual({ join_count: 1, label: "Guest" });
});
});
describe("expired shares", () => {
it("stops resolving a link once it expires", async () => {
server = await startServer();
const { shareId, linkToken } = await createShare(server);
expireShare(server, shareId);
expect(
(await server.request("GET", `/resolve/${linkToken}`, { user: null }))
.status,
).toBe(404);
expect(
await server.guests.resolve({
shareToken: linkToken!,
clientIp: "1.1.1.1",
}),
).toMatchObject({ ok: false });
expect((await server.request("GET", "/host/1/active")).body.shares).toEqual(
[],
);
});
it("refuses a member join on an expired user share", async () => {
server = await startServer();
const { shareId } = await createShare(server, {
shareType: "user",
targetUserId: "bob",
});
expireShare(server, shareId);
expect(
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
).toBeNull();
});
it("clears a room stage whose share expired and stops guests", async () => {
server = await startServer();
const { roomId, token } = await roomOnStage(server);
const shareId = (await server.request("GET", `/rooms/${roomId}`)).body.stage
.shareId;
expireShare(server, shareId);
const guest = await server.request("GET", `/guest/${token}`, {
user: null,
});
expect(guest.body).toEqual({ roomName: "R", stage: null });
expect(
await server.guests.resolve({
roomGuestToken: token,
clientIp: "1.1.1.1",
}),
).toEqual({ ok: false, reason: "Nothing is being presented" });
expect(
(await server.request("GET", `/rooms/${roomId}/stage`)).body.stage,
).toBeNull();
});
});
describe("member joins", () => {
it("lets only the target of a user share join", async () => {
server = await startServer();
const { shareId } = await createShare(server, {
shareType: "user",
targetUserId: "bob",
permissionLevel: "read-write",
});
expect(
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
).toEqual({
share: {
id: shareId,
sessionId: "sess-1",
permissionLevel: "read-write",
},
displayName: "bob",
});
expect(
await asUser(server, "carol", () =>
server!.sharing.authorizeJoin(shareId),
),
).toBeNull();
});
it("refuses the target once they lose access to the host", async () => {
server = await startServer({ accessibleHostIds: [2] });
server.live.addSession("sess-1", "alice", 1);
// Inserted directly: bob could reach host 1 when it was shared with him.
server.db.sqlite
.prepare(
"INSERT INTO p_session_sharing_shares (id, host_id, owner_user_id, protocol, session_id, share_type, target_user_id, expires_at) VALUES ('s', 1, 'alice', 'ssh', 'sess-1', 'user', 'bob', ?)",
)
.run(new Date(Date.now() + 60_000).toISOString());
expect(
await asUser(server, "bob", () => server!.sharing.authorizeJoin("s")),
).toBeNull();
});
it("refuses a link share joined as a member, and no user at all", async () => {
server = await startServer();
const { shareId } = await createShare(server);
expect(
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
).toBeNull();
expect(await server.sharing.authorizeJoin(shareId)).toBeNull();
});
it("lets room members, and only them, join a room stage read-only", async () => {
server = await startServer();
const { roomId } = await roomOnStage(server);
const shareId = (await server.request("GET", `/rooms/${roomId}`)).body.stage
.shareId;
expect(
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
).toMatchObject({ share: { permissionLevel: "read-only" } });
expect(
await asUser(server, "carol", () =>
server!.sharing.authorizeJoin(shareId),
),
).toBeNull();
});
it("subscribes only members to room events", async () => {
server = await startServer();
const { roomId } = await roomOnStage(server);
const socket = { readyState: 1, OPEN: 1, send: () => {} };
expect(
await asUser(server, "carol", () =>
server!.sharing.subscribeRoom(roomId, socket),
),
).toBe(false);
expect(
await asUser(server, "bob", () =>
server!.sharing.subscribeRoom(roomId, socket),
),
).toBe(true);
await server.sharing.unsubscribeRoom(socket);
});
});
describe("room guest links", () => {
it("resolves the stage for a guest without host details", async () => {
server = await startServer();
const { token } = await roomOnStage(server);
const res = await server.request("GET", `/guest/${token}`, { user: null });
expect(res.body).toEqual({
roomName: "R",
stage: {
protocol: "ssh",
shareId: expect.any(String),
wsPath: `/plugin-ws/ssh-terminal/terminal?roomGuestToken=${token}`,
},
});
expect(
await server.guests.resolve({
roomGuestToken: token,
clientIp: "1.1.1.1",
}),
).toMatchObject({ ok: true, share: { permissionLevel: "read-only" } });
});
it("refuses the old token after a rotate and any token after disable", async () => {
server = await startServer();
const { roomId, token } = await roomOnStage(server);
const rotated = await server.request(
"POST",
`/rooms/${roomId}/guest-link`,
{
body: { enabled: true },
},
);
expect(
(await server.request("GET", `/guest/${token}`, { user: null })).status,
).toBe(404);
expect(
await server.guests.resolve({
roomGuestToken: token,
clientIp: "1.1.1.1",
}),
).toMatchObject({ ok: false });
expect(server.live.calls).toContainEqual([
"disconnectParticipants",
"sess-stage",
expect.any(String),
{ userId: null, reason: "The guest link was rotated" },
]);
await server.request("POST", `/rooms/${roomId}/guest-link`, {
body: { enabled: false },
});
expect(
(
await server.request("GET", `/guest/${rotated.body.guestLinkToken}`, {
user: null,
})
).status,
).toBe(404);
});
it("refuses a guest link after the room ends", async () => {
server = await startServer();
const { roomId, token } = await roomOnStage(server);
await server.request("POST", `/rooms/${roomId}/end`);
expect(
(await server.request("GET", `/guest/${token}`, { user: null })).status,
).toBe(404);
});
it("does not accept a room guest token as a share link, or the reverse", async () => {
server = await startServer();
const { token } = await roomOnStage(server);
const { linkToken } = await createShare(server);
expect(
await server.guests.resolve({ shareToken: token, clientIp: "1.1.1.1" }),
).toMatchObject({ ok: false });
expect(
(await server.request("GET", `/guest/${linkToken}`, { user: null }))
.status,
).toBe(404);
});
it("only lets the host manage the guest link", async () => {
server = await startServer();
const { roomId } = await roomOnStage(server);
expect(
(
await server.request("POST", `/rooms/${roomId}/guest-link`, {
user: "bob",
body: { enabled: true },
})
).status,
).toBe(403);
});
it("rate limits room guests per IP", async () => {
server = await startServer();
let refused = false;
for (let i = 0; i < 61; i++) {
const result = await server.guests.resolve({
roomGuestToken: "guess",
clientIp: "9.9.9.9",
});
if (result.ok === false && result.reason === "Too many requests") {
refused = true;
}
}
expect(refused).toBe(true);
});
});