From a14194c482fa25f2443cdcb5e180c41f43ad7a84 Mon Sep 17 00:00:00 2001 From: forehalo Date: Tue, 30 Apr 2024 03:46:59 +0000 Subject: [PATCH] fix(server): blob controller permission (#6746) --- .../server/src/core/workspaces/controller.ts | 10 +- .../server/src/core/workspaces/permission.ts | 6 +- .../server/tests/nestjs/throttler.spec.ts | 8 +- packages/backend/server/tests/utils/user.ts | 8 + .../server/tests/workspace/controller.spec.ts | 272 ++++++++++++++++++ 5 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 packages/backend/server/tests/workspace/controller.spec.ts diff --git a/packages/backend/server/src/core/workspaces/controller.ts b/packages/backend/server/src/core/workspaces/controller.ts index 2b6014b2bfad..b0bd3e65a87f 100644 --- a/packages/backend/server/src/core/workspaces/controller.ts +++ b/packages/backend/server/src/core/workspaces/controller.ts @@ -43,7 +43,13 @@ export class WorkspacesController { ) { // if workspace is public or have any public page, then allow to access // otherwise, check permission - if (!(await this.permission.tryCheckWorkspace(workspaceId, user?.id))) { + if ( + !(await this.permission.isPublicAccessible( + workspaceId, + workspaceId, + user?.id + )) + ) { throw new ForbiddenException('Permission denied'); } @@ -81,7 +87,7 @@ export class WorkspacesController { const docId = new DocID(guid, ws); if ( // if a user has the permission - !(await this.permission.isAccessible( + !(await this.permission.isPublicAccessible( docId.workspace, docId.guid, user?.id diff --git a/packages/backend/server/src/core/workspaces/permission.ts b/packages/backend/server/src/core/workspaces/permission.ts index 4cf2a4fae9c2..e89ed8f6089d 100644 --- a/packages/backend/server/src/core/workspaces/permission.ts +++ b/packages/backend/server/src/core/workspaces/permission.ts @@ -84,7 +84,11 @@ export class PermissionService { /** * check if a doc binary is accessible by a user */ - async isAccessible(ws: string, id: string, user?: string): Promise { + async isPublicAccessible( + ws: string, + id: string, + user?: string + ): Promise { if (ws === id) { // if workspace is public or have any public page, then allow to access const [isPublicWorkspace, publicPages] = await Promise.all([ diff --git a/packages/backend/server/tests/nestjs/throttler.spec.ts b/packages/backend/server/tests/nestjs/throttler.spec.ts index 2c113b1fccf6..6b92898f3332 100644 --- a/packages/backend/server/tests/nestjs/throttler.spec.ts +++ b/packages/backend/server/tests/nestjs/throttler.spec.ts @@ -20,7 +20,7 @@ import { Throttle, ThrottlerStorage, } from '../../src/fundamentals/throttler'; -import { createTestingApp, sessionCookie } from '../utils'; +import { createTestingApp, internalSignIn } from '../utils'; const test = ava as TestFn<{ storage: ThrottlerStorage; @@ -113,11 +113,7 @@ test.beforeEach(async t => { const auth = app.get(AuthService); const u1 = await auth.signUp('u1', 'u1@affine.pro', 'test'); - const res = await request(app.getHttpServer()) - .post('/api/auth/sign-in') - .send({ email: u1.email, password: 'test' }); - - t.context.cookie = sessionCookie(res.headers)!; + t.context.cookie = await internalSignIn(app, u1.id); }); test.afterEach.always(async t => { diff --git a/packages/backend/server/tests/utils/user.ts b/packages/backend/server/tests/utils/user.ts index 14041151108e..feb807d3d4c6 100644 --- a/packages/backend/server/tests/utils/user.ts +++ b/packages/backend/server/tests/utils/user.ts @@ -10,6 +10,14 @@ import { import type { UserType } from '../../src/core/user'; import { gql } from './common'; +export async function internalSignIn(app: INestApplication, userId: string) { + const auth = app.get(AuthService); + + const session = await auth.createUserSession({ id: userId }); + + return `${AuthService.sessionCookieName}=${session.sessionId}`; +} + export function sessionCookie(headers: any): string { const cookie = headers['set-cookie']?.find((c: string) => c.startsWith(`${AuthService.sessionCookieName}=`) diff --git a/packages/backend/server/tests/workspace/controller.spec.ts b/packages/backend/server/tests/workspace/controller.spec.ts new file mode 100644 index 000000000000..4ada30db3ad0 --- /dev/null +++ b/packages/backend/server/tests/workspace/controller.spec.ts @@ -0,0 +1,272 @@ +import { Readable } from 'node:stream'; + +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; +import request from 'supertest'; + +import { AppModule } from '../../src/app.module'; +import { CurrentUser } from '../../src/core/auth'; +import { AuthService } from '../../src/core/auth/service'; +import { DocHistoryManager, DocManager } from '../../src/core/doc'; +import { WorkspaceBlobStorage } from '../../src/core/storage'; +import { createTestingApp, internalSignIn } from '../utils'; + +const test = ava as TestFn<{ + u1: CurrentUser; + db: PrismaClient; + app: INestApplication; + storage: Sinon.SinonStubbedInstance; + doc: Sinon.SinonStubbedInstance; +}>; + +test.beforeEach(async t => { + const { app } = await createTestingApp({ + imports: [AppModule], + tapModule: m => { + m.overrideProvider(WorkspaceBlobStorage) + .useValue(Sinon.createStubInstance(WorkspaceBlobStorage)) + .overrideProvider(DocManager) + .useValue(Sinon.createStubInstance(DocManager)) + .overrideProvider(DocHistoryManager) + .useValue(Sinon.createStubInstance(DocHistoryManager)); + }, + }); + + const auth = app.get(AuthService); + t.context.u1 = await auth.signUp('u1', 'u1@affine.pro', '1'); + const db = app.get(PrismaClient); + + t.context.db = db; + t.context.app = app; + t.context.storage = app.get(WorkspaceBlobStorage); + t.context.doc = app.get(DocManager); + + await db.workspacePage.create({ + data: { + workspace: { + create: { + id: 'public', + public: true, + }, + }, + pageId: 'private', + public: false, + }, + }); + + await db.workspacePage.create({ + data: { + workspace: { + create: { + id: 'private', + public: false, + }, + }, + pageId: 'public', + public: true, + }, + }); + + await db.workspacePage.create({ + data: { + workspace: { + create: { + id: 'totally-private', + public: false, + }, + }, + pageId: 'private', + public: false, + }, + }); +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +function blob() { + function stream() { + return Readable.from(Buffer.from('blob')); + } + + const init = stream(); + const ret = { + body: init, + metadata: { + contentType: 'text/plain', + lastModified: new Date(), + contentLength: 4, + }, + }; + + init.on('end', () => { + ret.body = stream(); + }); + + return ret; +} + +// blob +test('should be able to get blob from public workspace', async t => { + const { app, u1, storage } = t.context; + + // no authenticated user + storage.get.resolves(blob()); + let res = await request(t.context.app.getHttpServer()).get( + '/api/workspaces/public/blobs/test' + ); + + t.is(res.status, HttpStatus.OK); + t.is(res.get('content-type'), 'text/plain'); + t.is(res.text, 'blob'); + + // authenticated user + const cookie = await internalSignIn(app, u1.id); + res = await request(t.context.app.getHttpServer()) + .get('/api/workspaces/public/blobs/test') + .set('Cookie', cookie); + + t.is(res.status, HttpStatus.OK); + t.is(res.get('content-type'), 'text/plain'); + t.is(res.text, 'blob'); +}); + +test('should be able to get private workspace with public pages', async t => { + const { app, u1, storage } = t.context; + + // no authenticated user + storage.get.resolves(blob()); + let res = await request(app.getHttpServer()).get( + '/api/workspaces/private/blobs/test' + ); + + t.is(res.status, HttpStatus.OK); + t.is(res.get('content-type'), 'text/plain'); + t.is(res.text, 'blob'); + + // authenticated user + const cookie = await internalSignIn(app, u1.id); + res = await request(app.getHttpServer()) + .get('/api/workspaces/private/blobs/test') + .set('cookie', cookie); + + t.is(res.status, HttpStatus.OK); + t.is(res.get('content-type'), 'text/plain'); + t.is(res.text, 'blob'); +}); + +test('should not be able to get private workspace with no public pages', async t => { + const { app, u1 } = t.context; + + let res = await request(app.getHttpServer()).get( + '/api/workspaces/totally-private/blobs/test' + ); + + t.is(res.status, HttpStatus.FORBIDDEN); + + res = await request(app.getHttpServer()) + .get('/api/workspaces/totally-private/blobs/test') + .set('cookie', await internalSignIn(app, u1.id)); + + t.is(res.status, HttpStatus.FORBIDDEN); +}); + +test('should be able to get permission granted workspace', async t => { + const { app, u1, db, storage } = t.context; + + const cookie = await internalSignIn(app, u1.id); + await db.workspaceUserPermission.create({ + data: { + workspaceId: 'totally-private', + userId: u1.id, + type: 1, + accepted: true, + }, + }); + + storage.get.resolves(blob()); + const res = await request(app.getHttpServer()) + .get('/api/workspaces/totally-private/blobs/test') + .set('Cookie', cookie); + + t.is(res.status, HttpStatus.OK); + t.is(res.text, 'blob'); +}); + +test('should return 404 if blob not found', async t => { + const { app, storage } = t.context; + + // @ts-expect-error mock + storage.get.resolves({ body: null }); + const res = await request(app.getHttpServer()).get( + '/api/workspaces/public/blobs/test' + ); + + t.is(res.status, HttpStatus.NOT_FOUND); +}); + +// doc +// NOTE: permission checking of doc api is the same with blob api, skip except one +test('should not be able to get private workspace with private page', async t => { + const { app, u1 } = t.context; + + let res = await request(app.getHttpServer()).get( + '/api/workspaces/private/docs/private-page' + ); + + t.is(res.status, HttpStatus.FORBIDDEN); + + res = await request(app.getHttpServer()) + .get('/api/workspaces/private/docs/private-page') + .set('cookie', await internalSignIn(app, u1.id)); + + t.is(res.status, HttpStatus.FORBIDDEN); +}); + +test('should be able to get doc', async t => { + const { app, doc } = t.context; + + doc.getBinary.resolves({ + binary: Buffer.from([0, 0]), + timestamp: Date.now(), + }); + + const res = await request(app.getHttpServer()).get( + '/api/workspaces/private/docs/public' + ); + + t.is(res.status, HttpStatus.OK); + t.is(res.get('content-type'), 'application/octet-stream'); + t.deepEqual(res.body, Buffer.from([0, 0])); +}); + +test('should be able to change page publish mode', async t => { + const { app, doc, db } = t.context; + + doc.getBinary.resolves({ + binary: Buffer.from([0, 0]), + timestamp: Date.now(), + }); + + let res = await request(app.getHttpServer()).get( + '/api/workspaces/private/docs/public' + ); + + t.is(res.status, HttpStatus.OK); + t.is(res.get('publish-mode'), 'page'); + + await db.workspacePage.update({ + where: { workspaceId_pageId: { workspaceId: 'private', pageId: 'public' } }, + data: { mode: 1 }, + }); + + res = await request(app.getHttpServer()).get( + '/api/workspaces/private/docs/public' + ); + + t.is(res.status, HttpStatus.OK); + t.is(res.get('publish-mode'), 'edgeless'); +});