mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-15 08:09:16 +00:00
426 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|