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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user