From af655feb99a68e7b86e3ac4a5a8864bc24bba112 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 28 Aug 2026 21:00:34 -0400 Subject: [PATCH] fix: stop an embedded fs: in a path from escalating to full FS access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fs-path-to-uid permission rewriter split permission strings on the raw \`fs:\` substring instead of parsing on component boundaries. A path can itself contain \`fs:\` — a home dir named \`…fs\`, or \`fs\` in the mode position — and the raw split mistook that for the mode delimiter. Crafting \`fs:/fs:junk:read\` made the split do two things at once: it dropped the \`junk:read\` mode, and it consumed the trailing \`fs\`, turning the harmless-looking nonexistent path shown in the consent dialog into the victim's real home path. The rewriter then stored a bare \`fs:\`, which subsumes every mode via parent-permission matching — full read+write+delete from a request the user saw as \`junk:read\`. Parse with PermissionUtil.split() in both matches() and rewrite(), matching every other permission parser, and locate the \`fs\` component by position (after an optional \`manage\` prefix). The path component is now exactly what sits between \`fs\` and the next unescaped colon, and all trailing components are preserved as the mode — so an embedded \`fs:\` is data, never a delimiter. The crafted input now addresses the nonexistent \`/fs\` and 404s. Regression tests pin both halves: the exact PoC must 404, and an \`fs:\` in the mode position must survive the rewrite instead of collapsing to a bare permission. Both fail on the old rewriter and pass on the fix. --- src/backend/services/fs/FSService.test.ts | 20 ++++++++++++++++++++ src/backend/services/fs/FSService.ts | 17 ++++++++++------- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts index fa75ce8703..f346ffb6e7 100644 --- a/src/backend/services/fs/FSService.test.ts +++ b/src/backend/services/fs/FSService.test.ts @@ -3367,6 +3367,26 @@ describe('FSService permission rules', () => { expect(error.legacyCode).toBe('subject_does_not_exist'); }); + // The old raw `split('fs:')` dropped the mode and stripped the trailing `fs`, resolving this to a bare `fs:` that subsumes every mode. + it('does not let an embedded fs: escalate a scoped grant to a bare one', async () => { + const error = await caught(() => + server.services.permission.rewritePermission( + `fs:${user.home}fs:junk:read`, + ), + ); + expect(error.statusCode).toBe(404); + expect(error.legacyCode).toBe('subject_does_not_exist'); + }); + + it('keeps the mode when a later component contains fs:', async () => { + // `fs` in the mode position is data, not a delimiter: the path resolves and the mode is preserved, never collapsed to bare. + await expect( + server.services.permission.rewritePermission( + `fs:${file.path}:fs:read`, + ), + ).resolves.toBe(`fs:${file.uuid}:fs:read`); + }); + it('leaves uuid-addressed and non-fs permissions untouched', async () => { await expect( server.services.permission.rewritePermission( diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index 3c1efb1080..b48052ab1f 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -182,14 +182,18 @@ export class FSService extends PuterService { !permission.startsWith(`${MANAGE_PERM_PREFIX}:fs:`) ) return false; - const [, specifier] = permission.split('fs:'); + // Parse on component boundaries: a path can contain `fs:` (e.g. a home dir named `…fs`), which a raw split mistakes for the mode delimiter. + const parts = PermissionUtil.split(permission); + const fsIndex = parts[0] === MANAGE_PERM_PREFIX ? 1 : 0; + const specifier = parts[fsIndex + 1]; return Boolean(specifier && specifier.startsWith('/')); }, rewrite: async (permission: string): Promise => { - const [manageOpt, pathPerm] = permission.split('fs:'); - const parts = PermissionUtil.split(pathPerm); - const path = parts[0]; - const rest = parts.slice(1); + const parts = PermissionUtil.split(permission); + const hasManage = parts[0] === MANAGE_PERM_PREFIX; + const fsIndex = hasManage ? 1 : 0; + const path = parts[fsIndex + 1]; + const rest = parts.slice(fsIndex + 2); if (!path) return permission; const entry = await fsEntryStore.getEntryByPath(path); if (!entry) { @@ -197,9 +201,8 @@ export class FSService extends PuterService { legacyCode: 'subject_does_not_exist', }); } - const manage = manageOpt.replace(':', ''); const joined = PermissionUtil.join('fs', entry.uuid, ...rest); - return manage ? `${manage}:${joined}` : joined; + return hasManage ? `${MANAGE_PERM_PREFIX}:${joined}` : joined; }, });