diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index be8fec23d..917e8ee05 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -20,6 +20,7 @@ import Busboy from 'busboy'; import type { Request, Response } from 'express'; import { posix as pathPosix } from 'node:path'; +import { assertNormalized } from '../../services/fs/resolveNode.js'; import { pipeline } from 'node:stream/promises'; import type { Actor } from '../../core/actor.js'; import { Context } from '../../core/context.js'; @@ -1787,7 +1788,8 @@ export class FSController extends PuterController { pathToNormalize = `/${username}${pathToNormalize.slice(1)}`; } - let normalizedPath = pathPosix.normalize(pathToNormalize); + assertNormalized(pathToNormalize); + let normalizedPath = pathToNormalize; if (!normalizedPath.startsWith('/')) { normalizedPath = `/${normalizedPath}`; } diff --git a/src/backend/core/http/middleware/hostRedirects.ts b/src/backend/core/http/middleware/hostRedirects.ts index d2cb0b99a..91f4039a5 100644 --- a/src/backend/core/http/middleware/hostRedirects.ts +++ b/src/backend/core/http/middleware/hostRedirects.ts @@ -21,6 +21,7 @@ import type { RequestHandler } from 'express'; import { stat } from 'node:fs/promises'; import path from 'node:path'; import type { IConfig } from '../../../types'; +import { assertNormalized } from '../../../services/fs/resolveNode.js'; /** Native-app subdomains served via `nativeAppStatic`. */ const NATIVE_APP_SUBDOMAINS = [ @@ -130,10 +131,8 @@ export const createNativeAppStatic = (config: IConfig): RequestHandler => { ? path.join(root, active, 'dist') : path.join(root, active); - // req.path is already url-decoded by express; normalize strips any - // `..` segments before sendFile's `root` option enforces its own - // traversal guard. - const requested = path.normalize(req.path); + const requested = req.path; + assertNormalized(requested); const absolute = path.join(appRoot, requested); try { diff --git a/src/backend/drivers/ai-image/ImageGenerationDriver.ts b/src/backend/drivers/ai-image/ImageGenerationDriver.ts index 20bd9166a..22e66733f 100644 --- a/src/backend/drivers/ai-image/ImageGenerationDriver.ts +++ b/src/backend/drivers/ai-image/ImageGenerationDriver.ts @@ -19,6 +19,7 @@ import crypto from 'node:crypto'; import { posix as pathPosix } from 'node:path'; +import { assertNormalized } from '../../services/fs/resolveNode.js'; import { Readable } from 'node:stream'; import { Context } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; @@ -419,7 +420,7 @@ export class ImageGenerationDriver extends PuterDriver { if (resolved === '~' || resolved.startsWith('~/')) { resolved = `/${username}${resolved.slice(1)}`; } - resolved = pathPosix.normalize(resolved); + assertNormalized(resolved); if (!resolved.startsWith('/')) { resolved = `/${resolved}`; } diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.ts index 1219f921f..fc35bac5f 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.ts @@ -18,6 +18,7 @@ */ import { posix as pathPosix } from 'node:path'; +import { assertNormalized } from '../../services/fs/resolveNode.js'; import { Readable } from 'node:stream'; import { Context } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; @@ -458,7 +459,7 @@ export class VideoGenerationDriver extends PuterDriver { if (resolved === '~' || resolved.startsWith('~/')) { resolved = `/${username}${resolved.slice(1)}`; } - resolved = pathPosix.normalize(resolved); + assertNormalized(resolved); if (!resolved.startsWith('/')) { resolved = `/${resolved}`; } diff --git a/src/backend/services/acl/ACLService.ts b/src/backend/services/acl/ACLService.ts index 07c0aa331..4ccc0f3d3 100644 --- a/src/backend/services/acl/ACLService.ts +++ b/src/backend/services/acl/ACLService.ts @@ -112,6 +112,7 @@ export class ACLService extends PuterService { if (resource.path === '/') { return (PUBLIC_READ_MODES as AclMode[]).includes(mode); } + const ancestors = await resource.resolveAncestors(); const components = resource.path.slice(1).split('/'); @@ -171,7 +172,6 @@ export class ACLService extends PuterService { const authorizer = actor.accessToken.issuer; if (!(await this.check(authorizer, resource, mode))) return false; - const ancestors = await resource.resolveAncestors(); for (const ancestor of ancestors) { const permissions = mode === MANAGE_PERM_PREFIX @@ -219,7 +219,6 @@ export class ACLService extends PuterService { // Fall back to the permission scan: walk ancestors, any hit wins. // Widen the scan to all "higher" modes (`write` covers `read`/`list`/ // `see`, etc.) so granting a stronger mode implies the weaker ones. - const ancestors = await resource.resolveAncestors(); for (const ancestor of ancestors) { const permissions = mode === MANAGE_PERM_PREFIX diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index 44db6064b..ae20c6043 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -18,6 +18,7 @@ */ import { posix as pathPosix } from 'node:path'; +import { assertNormalized } from './resolveNode.js'; import { createHash } from 'node:crypto'; import { Readable, Transform } from 'node:stream'; import type { TransformCallback } from 'node:stream'; @@ -268,7 +269,8 @@ export class FSService extends PuterService { ); } - let normalizedPath = pathPosix.normalize(trimmedPath); + assertNormalized(trimmedPath); + let normalizedPath = trimmedPath; if (!normalizedPath.startsWith('/')) { normalizedPath = `/${normalizedPath}`; } diff --git a/src/backend/services/fs/resolveNode.ts b/src/backend/services/fs/resolveNode.ts index 4d063c3d1..f2905ac7a 100644 --- a/src/backend/services/fs/resolveNode.ts +++ b/src/backend/services/fs/resolveNode.ts @@ -130,6 +130,15 @@ export function splitParentAndName(absolutePath: string): { return { parentPath: parentPath === '.' ? '/' : parentPath, name }; } +export function assertNormalized(input: string): string { + if (pathPosix.normalize(input) !== input) { + throw new HttpError(400, 'Invalid path', { + legacyCode: 'bad_request', + }); + } + return input; +} + export function normalizeAbsolutePath(path: string): string { const trimmed = typeof path === 'string' ? path.trim() : ''; if (trimmed.length === 0) { @@ -137,7 +146,8 @@ export function normalizeAbsolutePath(path: string): string { legacyCode: 'bad_request', }); } - let normalized = pathPosix.normalize(trimmed); + assertNormalized(trimmed); + let normalized = trimmed; if (!normalized.startsWith('/')) { normalized = `/${normalized}`; } diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index ffcccfdfb..28fd29674 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -19,6 +19,7 @@ import { statfs } from 'node:fs/promises'; import { posix as pathPosix } from 'node:path'; +import { assertNormalized } from '../../services/fs/resolveNode.js'; import { v4 as uuidv4 } from 'uuid'; import { HttpError } from '../../core/http/HttpError.js'; import type { LayerInstances } from '../../types.js'; @@ -175,7 +176,8 @@ export class FSEntryStore extends PuterStore { }); } - let normalized = pathPosix.normalize(trimmed); + assertNormalized(trimmed); + let normalized = trimmed; if (!normalized.startsWith('/')) { normalized = `/${normalized}`; }