fix(perms): answer the app-root-dir check without provisioning it

`/auth/request-app-root-dir` conflates two questions: may the caller claim its
root directory, and where is it. The second provisions `AppData/<uid>` on first
ask, so a caller that only wanted the first — `puter.perms.check('appRootDir')`
— created a directory by asking about it.

Adds `check: true`, which runs the same actor guard and stops at the answer.
A caller that may not claim it still gets the 403, so the flag can't widen
anything. Existing callers are unaffected: without it the route behaves exactly
as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Salazar
2026-08-21 01:30:19 -04:00
co-authored by Claude Opus 5
parent d8d4aeee3d
commit 18098dd71b
2 changed files with 58 additions and 9 deletions
@@ -2094,6 +2094,50 @@ describe('LegacyFSController.requestAppRootDir', () => {
expect(body.path).toBe(`/${username}/AppData/${appUid}`);
expect(body.is_dir).toBe(true);
});
// `check: true` answers the question without acting on the answer, so that
// asking whether the access is held is not itself a filesystem write.
it('answers check:true without creating the directory', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const appUid = 'app-check-only';
const appActor = makeActor({ ...actor, app: { uid: appUid } });
const rootPath = `/${username}/AppData/${appUid}`;
const { res, captured } = makeRes();
await withActor(appActor, () =>
controller.requestAppRootDir(
makeReq({
body: { app_uid: appUid, access: 'read', check: true },
actor: appActor,
}),
res,
),
);
expect(captured.body).toEqual({ allowed: true });
expect(
await server.stores.fsEntry.getEntryByPath(rootPath),
).toBeFalsy();
});
// The guard runs first either way, so a caller that may not claim it is
// refused rather than told it is allowed.
it('still refuses check:true from a caller that is not the app', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
withActor(actor, () =>
controller.requestAppRootDir(
makeReq({
body: { app_uid: 'app-xyz', check: true },
actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 403 });
});
});
// ── checkAppAcl ─────────────────────────────────────────────────────
@@ -832,9 +832,7 @@ export class LegacyFSController extends PuterController {
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
| Record<string, unknown>
| null
| undefined,
Record<string, unknown> | null | undefined,
});
const oldPath = source.path;
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
@@ -1281,8 +1279,7 @@ export class LegacyFSController extends PuterController {
}
type SignedOrEmpty =
| (SignedFile & { path?: string })
| Record<string, never>;
(SignedFile & { path?: string }) | Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
@@ -1846,6 +1843,12 @@ export class LegacyFSController extends PuterController {
/**
* POST /auth/request-app-root-dir — an app-under-user requests stat on its
* own app root directory. The app must own itself.
*
* `check: true` answers whether the caller may claim it and stops there,
* for callers asking the question rather than acting on the answer —
* otherwise `puter.perms.check('appRootDir', …)` would provision a
* directory just by being asked. Answering is the whole response: a caller
* that may not claim it gets the 403 below either way.
*/
requestAppRootDir = async (req: Request, res: Response): Promise<void> => {
const actor = this.#requireActor(req);
@@ -1872,6 +1875,11 @@ export class LegacyFSController extends PuterController {
legacyCode: 'unauthorized',
});
if (getBoolean(body, 'check')) {
res.json({ allowed: true });
return;
}
const rootPath = `/${username}/AppData/${appUid}`;
// Auto-create the AppData/<uid> tree on first call.
const entry = await this.services.fs.mkdir(userId, {
@@ -1892,10 +1900,7 @@ export class LegacyFSController extends PuterController {
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
| 'see'
| 'list'
| 'read'
| 'write';
'see' | 'list' | 'read' | 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',