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.
This commit is contained in:
2026-09-13 13:36:33 +08:00
parent 30ec8a43cf
commit 68b976575a
2 changed files with 149 additions and 0 deletions
+73
View File
@@ -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);
}
});
});
+76
View File
@@ -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 `<contentDir>/.ttrpg/sessions/`
* on the server, and is embedded in MQTT topics (`ttrpg/<id>/...`). 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<string>([
"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;
}