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