fix: bound a scoped token to what it issued, which is nothing

CI caught three HTTP-level tests the earlier merge left behind, and they
were right to fail: dropping this branch's `manage` gate in favour of
main's row filter lost a case main's filter does not cover.

Main bounds an app to the rows it issued. A *scoped* access token is not
an app, so it was falling through unbounded — `/fs/stat` with
`return_shares` handed a `fs:<uid>:list` token the owner's whole share
list. Addresses stayed withheld, but who else can reach a file is no
more a narrow token's business than the addresses are.

So the filter is generalised rather than the gate restored: a scoped
token is bounded to nothing, since it issues nothing under its own name.
Filtering it by a null app would have been worse than not filtering —
that matches the owner's own rows. Apps and sessions behave exactly as
they do on main, and a full-access token still holds the account's reach.

The three tests now assert the answer instead of a refusal, including
the one whose name had always promised a refusal its body never checked.
This commit is contained in:
Juan Castro committed 2026-09-21 11:12:37 -04:00
1 parent cec8382d4d
commit 72203152d9
3 files changed
+28 -9

No files matched your search

+1
View File
@@ -1216,6 +1216,7 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {
@@ -1127,12 +1127,16 @@ describe('share endpoints over HTTP', () => {
expect(mine.status).toBe(200);
expect(await mine.text()).toContain(email);
// The app is answered, but bounded to the rows it issued — and it
// issued none, so the owner's invite is not among them.
const app = await appHolding(owner, file.uid, (u) => `fs:${u}:read`);
const peeked = await get('/share/shares', app.token, {
uid: file.uid,
});
expect(peeked.status).not.toBe(200);
expect(await peeked.text()).not.toContain(email);
expect(peeked.status).toBe(200);
const peekedBody = await peeked.text();
expect(peekedBody).not.toContain(email);
expect(JSON.parse(peekedBody).items).toEqual([]);
// `return_shares` is the same listing on an access-token surface.
const stat = await post('/fs/stat', app.token, {
@@ -1147,7 +1151,7 @@ describe('share endpoints over HTTP', () => {
// The ticket's second route in: `ShareController` admits no access
// token, but `/fs/stat` does, and `return_shares` is the same listing.
it('refuses a list-scoped access token on the `return_shares` surface', async () => {
it('withholds the listing from a list-scoped access token', async () => {
const owner = env.users.user;
const { file, email } = await invitedFile(owner);
const user = await env.server.stores.user.getByUsername(
@@ -1208,8 +1212,13 @@ describe('share endpoints over HTTP', () => {
});
expect(res.status).toBe(200);
const text = await res.text();
// The invite is listed; the address it names is not.
expect(JSON.parse(text).items.some((i: { pending?: boolean }) => i.pending)).toBe(true);
// `manage` buys an app authority over the item, not sight of who
// else the owner invited to it.
expect(
JSON.parse(text).items.some(
(i: { pending?: boolean }) => i.pending,
),
).toBe(false);
expect(text).not.toContain(email);
});
});
+13 -4
View File
@@ -2208,10 +2208,19 @@ export class ShareService extends PuterService {
// reaches the node — another app, a delegate, an address the user
// invited — is the user's business, not the app's.
const actingApp = this.#actingAppUid(actor);
const issuedHere = <T extends { data?: unknown }>(list: T[]): T[] =>
actingApp
? list.filter((row) => issuedByApp(row) === actingApp)
: list;
// A scoped token issues nothing under its own name, so it is bounded
// to nothing: filtering it by a null app would hand it the owner's own
// rows instead of none. A full-access token holds the account's reach
// and is not bounded at all.
const scopedToken =
!!actor.accessToken && actor.accessToken.fullAccess !== true;
const issuedHere = <T extends { data?: unknown }>(list: T[]): T[] => {
if (scopedToken) return [];
if (actingApp) {
return list.filter((row) => issuedByApp(row) === actingApp);
}
return list;
};
const inherited: Array<{ row: ShareIndexRow; via: string }> =
issuedHere(