From 68b976575a33ebe9308f8f68c108c2b3fe8cbe72 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 13 Sep 2026 13:36:33 +0800 Subject: [PATCH] feat(session): add shared session id validation Session ids become directory names server-side and topic levels on the broker, so they must reject path separators, ./.., whitespace and Windows reserved device names. toSessionId derives a safe id from a display name; isValidSessionId is the boundary check shared by the CLI and the web client. --- src/shared/session-id.test.ts | 73 +++++++++++++++++++++++++++++++++ src/shared/session-id.ts | 76 +++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/shared/session-id.test.ts create mode 100644 src/shared/session-id.ts diff --git a/src/shared/session-id.test.ts b/src/shared/session-id.test.ts new file mode 100644 index 0000000..7a85de0 --- /dev/null +++ b/src/shared/session-id.test.ts @@ -0,0 +1,73 @@ +import { + SESSION_ID_MAX_LENGTH, + isValidSessionId, + toSessionId, +} from "./session-id"; + +describe("isValidSessionId", () => { + test.each(["default", "a", "A1", "my-game", "under_score", "-"])( + "accepts %p", + (id) => { + expect(isValidSessionId(id)).toBe(true); + }, + ); + + test.each([ + "", + ".", + "..", + "../evil", + "..\\..\\evil", + "a/b", + "a\\b", + "a b", + "a.b", + "a/b/../c", + ])("rejects %p", (id) => { + expect(isValidSessionId(id)).toBe(false); + }); + + test("rejects non-strings and over-long ids", () => { + expect(isValidSessionId(null)).toBe(false); + expect(isValidSessionId(undefined)).toBe(false); + expect(isValidSessionId(42)).toBe(false); + expect(isValidSessionId("a".repeat(SESSION_ID_MAX_LENGTH + 1))).toBe(false); + }); + + test("rejects Windows reserved device names", () => { + expect(isValidSessionId("con")).toBe(false); + expect(isValidSessionId("COM1")).toBe(false); + expect(isValidSessionId("Nul")).toBe(false); + }); +}); + +describe("toSessionId", () => { + test("slugifies names", () => { + expect(toSessionId("My Game")).toBe("my-game"); + expect(toSessionId(" Curse of Strahd!! ")).toBe("curse-of-strahd"); + expect(toSessionId("")).toBe("session"); + expect(toSessionId("!!!")).toBe("session"); + }); + + test("avoids Windows reserved device names", () => { + expect(toSessionId("CON")).toBe("con-session"); + expect(isValidSessionId(toSessionId("CON"))).toBe(true); + }); + + test("always yields a valid id, including for hostile input", () => { + const names = [ + "My Game", + "", + "..", + "../etc/passwd", + "a/b\\c", + "CON", + "Ünïcödé", + "x".repeat(200), + "\0\0", + ]; + for (const name of names) { + expect(isValidSessionId(toSessionId(name))).toBe(true); + } + }); +}); diff --git a/src/shared/session-id.ts b/src/shared/session-id.ts new file mode 100644 index 0000000..379264f --- /dev/null +++ b/src/shared/session-id.ts @@ -0,0 +1,76 @@ +/** + * Session id rules — shared by the CLI broker (server) and the web client. + * + * A session id becomes a directory name under `/.ttrpg/sessions/` + * on the server, and is embedded in MQTT topics (`ttrpg//...`). Because the + * server derives filesystem paths directly from that topic, ids must be + * strictly validated at every trust boundary: without it, a topic level such as + * `..`, `.` or (on Windows) `..\..\evil` escapes the sessions directory. + * + * The pattern is intentionally conservative — only ASCII letters, digits, `_` + * and `-` — while still accepting ids that older versions may have written so + * existing sessions remain manageable. + */ + +export const SESSION_ID_MAX_LENGTH = 64; + +/** Valid session ids: 1-64 chars of `[A-Za-z0-9_-]`, never a path segment. */ +export const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + +/** + * Windows reserves these names (case-insensitively) for devices, so they can + * never be used as a directory name — even with an extension. + */ +const RESERVED_DEVICE_NAMES = new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9", +]); + +/** True when `id` is safe to use as a sessions-directory name and topic level. */ +export function isValidSessionId(id: unknown): id is string { + return ( + typeof id === "string" && + id.length > 0 && + id.length <= SESSION_ID_MAX_LENGTH && + SESSION_ID_PATTERN.test(id) && + !RESERVED_DEVICE_NAMES.has(id.toLowerCase()) + ); +} + +/** + * Derive a filesystem-safe session id from a human-readable name. + * The result is always a valid session id. + */ +export function toSessionId(name: string): string { + const slug = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, SESSION_ID_MAX_LENGTH) + .replace(/-+$/g, "") || "session"; + + // Some names slugify onto a reserved device name (`Con` -> `con`); suffix + // those so the session can actually be created on Windows. + return RESERVED_DEVICE_NAMES.has(slug) ? `${slug}-session` : slug; +}