Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*'",
Expand Down
14 changes: 14 additions & 0 deletions apps/client/src/safe-path.ts
Original file line number Diff line number Diff line change
@@ -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;
};
22 changes: 14 additions & 8 deletions apps/client/src/server.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -44,11 +46,13 @@ export const MANIFEST: Pick<IManifest, "watch"> = {
};

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",
};
Expand All @@ -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, {
Expand Down Expand Up @@ -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 });
}

Expand Down
34 changes: 34 additions & 0 deletions apps/client/test/safe-path.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 3 additions & 0 deletions docs/docs/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<path>` | One file of the build, for example `/game/main.js`. |

Only files inside the build folder are served at `/game/<path>`, 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.
Expand Down
Loading