mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-14 18:01:21 +00:00
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:
1 parent
cec8382d4d
commit
72203152d9
3 files changed
+28
-9
No files matched your search
Generated
+1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user