Merge pull request #3657 from HeyPuter/juancastro/put-1616-shareblocks-is-missing-requireuseractor-any-authorized-app

🔧 PUT-1616: Close /share/blocks to app tokens
This commit is contained in:
Juan Fernando Castro
2026-08-28 16:46:04 -04:00
committed by GitHub
2 changed files with 75 additions and 0 deletions
@@ -19,6 +19,7 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
import { ShareController } from './ShareController.js';
/**
* Route-level coverage for the sharing endpoints. The service unit tests drive
@@ -578,4 +579,74 @@ describe('share endpoints over HTTP', () => {
const body = (await res.json()) as { all: boolean; items: unknown[] };
expect(body).toEqual({ all: false, items: [] });
});
// Reading it says who the user avoids; clearing it puts them back in touch.
it('is closed to an app acting for the user', async () => {
const user = env.users.user;
const minted = await post('/auth/get-user-app-token', user.token, {
origin: 'https://blocks-probe.example',
});
expect(minted.status).toBe(200);
const appToken = ((await minted.json()) as { token: string }).token;
expect(typeof appToken).toBe('string');
const asApp = [
get('/share/blocks', appToken, {}),
post('/share/blocks', appToken, { all: true }),
fetch(new URL('/share/blocks', env.apiOrigin), {
method: 'DELETE',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${appToken}`,
},
body: JSON.stringify({ all: true }),
}),
];
for (const res of await Promise.all(asApp)) {
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
}
// The same calls from the user's own session: the app is what is refused.
expect((await get('/share/blocks', user.token, {})).status).toBe(200);
expect(
(await post('/share/blocks', user.token, { all: true })).status,
).toBe(200);
const lifted = await fetch(new URL('/share/blocks', env.apiOrigin), {
method: 'DELETE',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${user.token}`,
},
body: JSON.stringify({ all: true }),
});
expect(lifted.status).toBe(200);
});
// So a fourth block route cannot quietly ship without the gate.
it('gates every block route on a user session', () => {
const proto = ShareController.prototype as {
__puterRoutes?: Array<{
method: string;
path: string;
options?: Record<string, unknown>;
}>;
};
const blocks = (proto.__puterRoutes ?? []).filter(
(r) => r.path === '/blocks',
);
expect(blocks.map((r) => r.method.toLowerCase()).sort()).toEqual([
'delete',
'get',
'post',
]);
for (const route of blocks) {
expect(
route.options?.requireUserActor,
`${route.method} /blocks`,
).toBe(true);
// Security management stays closed to every access token.
expect(route.options?.allowFullAccessToken).toBeUndefined();
}
});
});
@@ -311,6 +311,7 @@ export class ShareController extends PuterController {
}
// -- Blocking -----------------------------------------------------
// User sessions only: a block list is a safety control, not an app's to touch.
/**
* GET /share/blocks — who the caller is refusing shares from, and whether
@@ -318,6 +319,7 @@ export class ShareController extends PuterController {
*/
@Get('/blocks', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: SHARE_LIST_LIMIT,
})
@@ -344,6 +346,7 @@ export class ShareController extends PuterController {
*/
@Post('/blocks', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: SHARE_LIMIT,
})
@@ -371,6 +374,7 @@ export class ShareController extends PuterController {
*/
@Delete('/blocks', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: SHARE_LIMIT,
})