fix: readUrl perm check (#3183)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

This commit is contained in:
Daniel Salazar
2026-05-28 11:44:16 -07:00
committed by GitHub
parent f140d7221c
commit 8ac4bad196
2 changed files with 154 additions and 0 deletions
@@ -23,6 +23,7 @@ import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { AuthService } from './AuthService.js';
function createAuthService(): AuthService {
@@ -1449,6 +1450,114 @@ describe('AuthService (integration)', () => {
),
).rejects.toMatchObject({ statusCode: 403 });
});
// Regression for the post-#1001 token-inval check: an
// app-under-user actor must be able to mint a token for a file
// inside its own AppData. Without the `app-owns-appdata`
// implicator the issuer-subset gate 403s these — even though
// ACLService already allows the equivalent fs.read via its own
// short-circuit. puter-js getReadURL is the canonical caller.
it('app-under-user actor can mint fs:<uuid>:read for a file inside its own AppData', async () => {
const user = await makeUser();
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
user,
);
const appUid = `app-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
[
appUid,
`n-${appUid}`,
`t-${appUid}`,
`https://${appUid}.example/`,
user.id,
],
);
const appDataPath = `/${user.username}/AppData/${appUid}`;
await server.services.fs.mkdir(user.id, {
path: appDataPath,
createMissingParents: true,
} as never);
const body = Buffer.from('hello');
await server.services.fs.write(user.id, {
fileMetadata: {
path: `${appDataPath}/note.txt`,
size: body.byteLength,
contentType: 'text/plain',
},
fileContent: body,
} as never);
const fileEntry = await server.stores.fsEntry.getEntryByPath(
`${appDataPath}/note.txt`,
);
expect(fileEntry).not.toBeNull();
const appActor: Actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
app: { id: 0, uid: appUid },
} as Actor;
const jwt = await authService.createAccessToken(appActor, [
[`fs:${fileEntry!.uuid}:read`],
]);
expect(typeof jwt).toBe('string');
});
// Negative side of the implicator: a file the user owns but
// that lives *outside* the app's AppData must still be
// rejected — the issuer-subset gate is the only thing
// preventing an authorized app from minting a token over
// arbitrary user-owned uuids.
it('app-under-user actor cannot mint fs:<uuid>:read for a user-owned file outside its AppData', async () => {
const user = await makeUser();
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
user,
);
const appUid = `app-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
[
appUid,
`n-${appUid}`,
`t-${appUid}`,
`https://${appUid}.example/`,
user.id,
],
);
const body = Buffer.from('secret');
await server.services.fs.write(user.id, {
fileMetadata: {
path: `/${user.username}/Documents/secret.txt`,
size: body.byteLength,
contentType: 'text/plain',
},
fileContent: body,
} as never);
const fileEntry = await server.stores.fsEntry.getEntryByPath(
`/${user.username}/Documents/secret.txt`,
);
expect(fileEntry).not.toBeNull();
const appActor: Actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
app: { id: 0, uid: appUid },
} as Actor;
await expect(
authService.createAccessToken(appActor, [
[`fs:${fileEntry!.uuid}:read`],
]),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'forbidden',
});
});
});
describe('private-asset / public hosted-actor tokens', () => {
+45
View File
@@ -206,6 +206,51 @@ export class FSService extends PuterService {
},
});
// -- app-owns-appdata -----------------------------------------
// Mirror of the ACLService short-circuit at ACLService.check:
// an app-under-user actor implicitly holds fs:<uuid>:* on any
// entry inside its own /<username>/AppData/<appUid> subtree.
// ACL paths (fs.read etc.) already accept these via that
// short-circuit, but permissionService.checkMany — used by
// createAccessToken's issuer-subset gate — bypasses ACL, so
// without this implicator an app can fs.read its appdata but
// can't mint a token for the same fs:<uuid>:read it just read.
permissions.registerImplicator({
id: 'app-owns-appdata',
shortcut: true,
matches: (permission: string): boolean => {
return (
permission.startsWith('fs:') ||
permission.startsWith(`${MANAGE_PERM_PREFIX}:fs:`) ||
permission.startsWith(
`${MANAGE_PERM_PREFIX}:${MANAGE_PERM_PREFIX}:fs:`,
)
);
},
check: async ({ actor, permission }): Promise<unknown> => {
if (!actor.app || actor.accessToken) return undefined;
const username = actor.user?.username;
const appUid = actor.app.uid;
if (!username || !appUid) return undefined;
const stripped = permission.replaceAll(
`${MANAGE_PERM_PREFIX}:`,
'',
);
const uid = PermissionUtil.split(stripped)[1];
if (!uid) return undefined;
const entry = await fsEntryStore.getEntryByUuid(uid);
if (!entry) return undefined;
const root = `/${username}/AppData/${appUid}`;
if (entry.path === root || entry.path.startsWith(`${root}/`)) {
return {};
}
return undefined;
},
});
// -- fs-access-levels exploder ----------------------------------
// `fs:UUID:see` implies `[list, read, write, manage:fs:UUID]`.
// ACLService.check already expands the same-family chain