require a separator when checking a path is inside its root

resolveUserPath and five sibling checks used startsWith(rootDir), which also
accepts a sibling directory whose name begins with the root's: from a root of
/home/br, "../br-backup/secret" resolves to /home/br-backup/secret and passed.
Compare against root + sep (or the root itself) via a shared isInside helper.

Verified the escape cases now deny while "", ".", and ordinary relative paths
still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
brunorezio
2026-07-25 23:30:20 +01:00
co-authored by Claude Opus 5
parent c13875cef8
commit 78130f21ce
+12 -7
View File
@@ -1,5 +1,5 @@
import { createRouter } from '@@/create-router'; import { createRouter } from '@@/create-router';
import { resolve, dirname, join, parse as parsePath } from 'node:path'; import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path'; import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
@@ -69,9 +69,14 @@ function getRootDir(user: UserCtx, root?: string): string {
throw errors.BAD_REQUEST(`Invalid root: ${root}`); throw errors.BAD_REQUEST(`Invalid root: ${root}`);
} }
// A resolved path counts as contained only when it IS the root or sits beneath it. A bare
// startsWith also accepts a sibling whose name merely begins with the root's — `/home/br-backup`
// passes a `/home/br` check — which is how `..` segments escaped.
const isInside = (root: string, target: string): boolean => target === root || target.startsWith(root + sep);
function resolveUserPath(rootDir: string, relPath: string): string { function resolveUserPath(rootDir: string, relPath: string): string {
const resolved = resolve(rootDir, relPath.replace(/^\/+/, '')); const resolved = resolve(rootDir, relPath.replace(/^\/+/, ''));
if (!resolved.startsWith(rootDir)) throw errors.FORBIDDEN('Path outside root directory'); if (!isInside(rootDir, resolved)) throw errors.FORBIDDEN('Path outside root directory');
return resolved; return resolved;
} }
@@ -176,7 +181,7 @@ router.get('/ls', async (ctx) => {
names.map(async (name) => { names.map(async (name) => {
const fullPath = resolve(absPath, name); const fullPath = resolve(absPath, name);
// Skip entries that escape the home dir (shouldn't happen but be safe) // Skip entries that escape the home dir (shouldn't happen but be safe)
if (!fullPath.startsWith(rootDir)) return null; if (!isInside(rootDir, fullPath)) return null;
const s = await stat(fullPath).catch(() => null); const s = await stat(fullPath).catch(() => null);
if (!s) return null; if (!s) return null;
return { return {
@@ -255,7 +260,7 @@ router.post('/upload', async (ctx) => {
for (const file of files) { for (const file of files) {
if (!(file instanceof File)) continue; if (!(file instanceof File)) continue;
const filePath = resolve(targetDir, file.name); const filePath = resolve(targetDir, file.name);
if (!filePath.startsWith(rootDir)) continue; if (!isInside(rootDir, filePath)) continue;
await mkdir(dirname(filePath), { recursive: true }); await mkdir(dirname(filePath), { recursive: true });
await Bun.write(filePath, file); await Bun.write(filePath, file);
} }
@@ -275,7 +280,7 @@ router.post('/rename', async (ctx) => {
if (absPath === rootDir) throw errors.FORBIDDEN('Cannot rename home directory'); if (absPath === rootDir) throw errors.FORBIDDEN('Cannot rename home directory');
const newPath = resolve(dirname(absPath), newName); const newPath = resolve(dirname(absPath), newName);
if (!newPath.startsWith(rootDir)) throw errors.FORBIDDEN('Path outside home directory'); if (!isInside(rootDir, newPath)) throw errors.FORBIDDEN('Path outside home directory');
await rename(absPath, newPath); await rename(absPath, newPath);
return ctx.json({ ok: true }); return ctx.json({ ok: true });
@@ -644,7 +649,7 @@ router.post('/save-result', async (ctx) => {
const homeDir = getRootDir(user, 'home'); const homeDir = getRootDir(user, 'home');
const destAbs = resolve(homeDir, relativePath); const destAbs = resolve(homeDir, relativePath);
if (!destAbs.startsWith(homeDir)) throw errors.FORBIDDEN('Path outside home directory'); if (!isInside(homeDir, destAbs)) throw errors.FORBIDDEN('Path outside home directory');
await mkdir(dirname(destAbs), { recursive: true }); await mkdir(dirname(destAbs), { recursive: true });
await cp(srcAbs, destAbs); await cp(srcAbs, destAbs);
@@ -1084,7 +1089,7 @@ router.get('/search', async (ctx) => {
for (const name of names) { for (const name of names) {
if (results.length >= MAX_RESULTS) break; if (results.length >= MAX_RESULTS) break;
const fullPath = resolve(dir, name); const fullPath = resolve(dir, name);
if (!fullPath.startsWith(rootDir)) continue; if (!isInside(rootDir, fullPath)) continue;
const s = await stat(fullPath).catch(() => null); const s = await stat(fullPath).catch(() => null);
if (!s) continue; if (!s) continue;
if (name.toLowerCase().includes(query)) { if (name.toLowerCase().includes(query)) {