diff --git a/apps/client/package.json b/apps/client/package.json index 92063b8..56edd8f 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -47,6 +47,7 @@ "build": "bun build src/server.ts --outdir dist --target bun", "start": "bun run dist/server.js", "lint": "prettier --check . && eslint --format=pretty src", + "test:unit": "bun test", "format": "prettier --write . && eslint --fix --format=pretty src", "prepack": "pnpm run build && pnpm run lint", "changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'apps/client/*'", diff --git a/apps/client/src/safe-path.ts b/apps/client/src/safe-path.ts new file mode 100644 index 0000000..6c10e63 --- /dev/null +++ b/apps/client/src/safe-path.ts @@ -0,0 +1,14 @@ +import { isAbsolute, relative, resolve, sep } from "node:path"; + +/** + * Resolves a decoded request path inside `root`. + * Returns `null` when the path points outside of it (`../`, encoded `..%2F`, absolute paths). + */ +export const resolveInside = (root: string, requestPath: string): string | null => { + const base = resolve(root); + const full = resolve(base, `.${sep}${requestPath}`); + const rel = relative(base, full); + + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null; + return full; +}; diff --git a/apps/client/src/server.ts b/apps/client/src/server.ts index d25739b..bc92b3e 100644 --- a/apps/client/src/server.ts +++ b/apps/client/src/server.ts @@ -1,8 +1,10 @@ import { program } from "commander"; -import { join } from "node:path"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; import { getGameEnv } from "./env"; import { updateManifest } from "./manifest"; +import { resolveInside } from "./safe-path"; import { startWatch } from "./watch"; export type IManifestFile = { path: string; hash: string; size: number }; @@ -44,11 +46,13 @@ export const MANIFEST: Pick = { }; const resolveWebDir = (str: string) => { - return import.meta - .resolve(`@nanoforge-dev/loader-website/${str.replace(/^\//, "")}`) - .replace(/^file:\/\//, ""); + return fileURLToPath( + import.meta.resolve(`@nanoforge-dev/loader-website/${str.replace(/^\//, "")}`), + ); }; +const webDir = dirname(resolveWebDir("index.html")); + const headers = { "Access-Control-Allow-Methods": "GET", }; @@ -67,8 +71,9 @@ const server = Bun.serve({ }, "/*": async (req) => { try { - const path = decodeURIComponent(new URL(req.url).pathname); - const file = Bun.file(resolveWebDir(path)); + const path = resolveInside(webDir, decodeURIComponent(new URL(req.url).pathname)); + if (!path) return new Response(null, { status: 404 }); + const file = Bun.file(path); if (!(await file.exists())) return new Response(null, { @@ -112,9 +117,10 @@ const server = Bun.serve({ "/game/*": async (req) => { try { const path = decodeURIComponent(new URL(req.url).pathname.replace("/game", "")); - const file = Bun.file(join(dir, path)); + const gamePath = resolveInside(dir, path); + const file = gamePath ? Bun.file(gamePath) : null; - if (!(await file.exists())) { + if (!file || !(await file.exists())) { return Response.json({ error: "Game file not found", path }, { status: 404, headers }); } diff --git a/apps/client/test/safe-path.spec.ts b/apps/client/test/safe-path.spec.ts new file mode 100644 index 0000000..36ce79d --- /dev/null +++ b/apps/client/test/safe-path.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; +import { join, resolve } from "node:path"; + +import { resolveInside } from "../src/safe-path"; + +const root = resolve("/srv/game"); + +describe("resolveInside", () => { + it("resolves paths inside the root", () => { + expect(resolveInside(root, "/index.js")).toBe(join(root, "index.js")); + expect(resolveInside(root, "/assets/a b.png")).toBe(join(root, "assets", "a b.png")); + expect(resolveInside(root, "/assets/../index.js")).toBe(join(root, "index.js")); + expect(resolveInside(root, "/..file.txt")).toBe(join(root, "..file.txt")); + }); + + it("resolves the root itself", () => { + expect(resolveInside(root, "/")).toBe(root); + }); + + it("refuses paths that leave the root", () => { + expect(resolveInside(root, "/../secret.txt")).toBeNull(); + expect(resolveInside(root, decodeURIComponent("/..%2F..%2Fetc%2Fpasswd"))).toBeNull(); + expect(resolveInside(root, "/assets/../../secret.txt")).toBeNull(); + expect(resolveInside(root, "..")).toBeNull(); + }); + + it("treats an absolute request path as relative to the root", () => { + expect(resolveInside(root, "/etc/passwd")).toBe(join(root, "etc", "passwd")); + }); + + it("refuses a sibling folder sharing the root's prefix", () => { + expect(resolveInside(root, "/../game-secrets/key")).toBeNull(); + }); +}); diff --git a/docs/docs/client.mdx b/docs/docs/client.mdx index ebe6447..5ec6780 100644 --- a/docs/docs/client.mdx +++ b/docs/docs/client.mdx @@ -38,6 +38,9 @@ Open them in the browser next to the game: | `/env` | JSON: the variables the client gets, without the `NANOFORGE_` prefix, plus `tlsEnabled`. | | `/game/` | One file of the build, for example `/game/main.js`. | +Only files inside the build folder are served at `/game/`, and only files of the loading page at +the other paths. Any other path returns a 404. + `/manifest` lists the build folder each time it is requested, so files added by a rebuild show up on the next page load. A hash is computed again only when a file's size or modification time changes.