diff --git a/src/app/api/boards/[boardId]/route.ts b/src/app/api/boards/[boardId]/route.ts index 8d7a23aeb..1f1ea33f1 100644 --- a/src/app/api/boards/[boardId]/route.ts +++ b/src/app/api/boards/[boardId]/route.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards'; +import type { BoardParameters } from '@/lib/types'; import { parseRequest } from '@/lib/request'; -import { json, ok, serverError, unauthorized } from '@/lib/response'; -import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions'; +import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; +import { canDeleteBoard, canUpdateBoard, canViewBoard, canViewBoardEntities } from '@/permissions'; import { deleteBoard, getBoard, updateBoard } from '@/queries/prisma'; export async function GET(request: Request, { params }: { params: Promise<{ boardId: string }> }) { @@ -54,6 +55,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa return unauthorized(); } + if (type !== undefined || parameters !== undefined) { + const currentBoard = await getBoard(boardId); + + if (!currentBoard) { + return unauthorized(); + } + + const nextType = type ?? currentBoard.type; + const nextParameters = (parameters ?? currentBoard.parameters) as BoardParameters; + + if (!(await canViewBoardEntities(auth, nextType, nextParameters))) { + return badRequest({ message: 'Board contains inaccessible entities.' }); + } + } + try { const board = await updateBoard(boardId, { type, name, description, parameters }); diff --git a/src/app/api/boards/route.ts b/src/app/api/boards/route.ts index 655486182..fd78f0029 100644 --- a/src/app/api/boards/route.ts +++ b/src/app/api/boards/route.ts @@ -2,9 +2,9 @@ import { z } from 'zod'; import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards'; import { uuid } from '@/lib/crypto'; import { getQueryFilters, parseRequest } from '@/lib/request'; -import { json, unauthorized } from '@/lib/response'; +import { badRequest, json, unauthorized } from '@/lib/response'; import { pagingParams, searchParams, sortingParams } from '@/lib/schema'; -import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; +import { canCreateTeamWebsite, canCreateWebsite, canViewBoardEntities } from '@/permissions'; import { createBoard, getUserBoards } from '@/queries/prisma'; export async function GET(request: Request) { @@ -58,6 +58,10 @@ export async function POST(request: Request) { return unauthorized(); } + if (!(await canViewBoardEntities(auth, body.type, body.parameters))) { + return badRequest({ message: 'Board contains inaccessible entities.' }); + } + const data = { ...body, type: normalizeBoardType(body.type), diff --git a/src/app/api/share/[slug]/route.ts b/src/app/api/share/[slug]/route.ts index 933ae6e48..609e489b2 100644 --- a/src/app/api/share/[slug]/route.ts +++ b/src/app/api/share/[slug]/route.ts @@ -5,8 +5,12 @@ import { createToken } from '@/lib/jwt'; import prisma from '@/lib/prisma'; import redis from '@/lib/redis'; import { json, notFound } from '@/lib/response'; -import type { BoardParameters, WhiteLabel } from '@/lib/types'; -import { getBoard, getLink, getPixel, getShareByCode, getWebsite } from '@/queries/prisma'; +import type { Auth, BoardParameters, WhiteLabel } from '@/lib/types'; +import { canViewLink, canViewPixel, canViewWebsite } from '@/permissions'; +import { getBoard, getLink, getPixel, getShareByCode, getUser, getWebsite } from '@/queries/prisma'; + +type BoardEntityIds = ReturnType; +type OwnedEntity = { userId?: string | null; teamId?: string | null } | null; async function getAccountId(entity: { userId?: string; teamId?: string }): Promise { if (entity.userId) { @@ -44,6 +48,85 @@ async function getWhiteLabel(accountId: string): Promise { return null; } +async function filterEntityIds( + ids: string[], + canView: (id: string) => Promise, +): Promise { + const results = await Promise.all( + ids.map(async id => { + try { + return (await canView(id)) ? id : null; + } catch { + return null; + } + }), + ); + + return results.filter((id): id is string => !!id); +} + +async function getTeamUserIds(teamId: string) { + const teamUsers = await prisma.client.teamUser.findMany({ + where: { teamId }, + select: { userId: true }, + }); + + return new Set(teamUsers.map(({ userId }) => userId)); +} + +function isOwnedByTeam(entity: OwnedEntity, teamId: string, teamUserIds: Set) { + return entity?.teamId === teamId || !!(entity?.userId && teamUserIds.has(entity.userId)); +} + +async function filterBoardEntityIdsForShare( + entity: { userId?: string | null; teamId?: string | null }, + ids: BoardEntityIds, +): Promise { + if (entity.teamId) { + const teamUserIds = await getTeamUserIds(entity.teamId); + + return { + websiteIds: await filterEntityIds( + ids.websiteIds, + async id => isOwnedByTeam(await getWebsite(id), entity.teamId, teamUserIds), + ), + pixelIds: await filterEntityIds( + ids.pixelIds, + async id => isOwnedByTeam(await getPixel(id), entity.teamId, teamUserIds), + ), + linkIds: await filterEntityIds( + ids.linkIds, + async id => isOwnedByTeam(await getLink(id), entity.teamId, teamUserIds), + ), + }; + } + + if (!entity.userId) { + return { websiteIds: [], pixelIds: [], linkIds: [] }; + } + + const user = await getUser(entity.userId); + + if (!user) { + return { websiteIds: [], pixelIds: [], linkIds: [] }; + } + + const auth: Auth = { + user: { + id: user.id, + username: user.username, + role: user.role, + isAdmin: user.role === ROLES.admin, + }, + }; + + return { + websiteIds: await filterEntityIds(ids.websiteIds, id => canViewWebsite(auth, id)), + pixelIds: await filterEntityIds(ids.pixelIds, id => canViewPixel(auth, id)), + linkIds: await filterEntityIds(ids.linkIds, id => canViewLink(auth, id)), + }; +} + export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; @@ -70,9 +153,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu type: board.type, parameters: board.parameters as BoardParameters, }); - data.websiteIds = boardEntityIds.websiteIds; - data.pixelIds = boardEntityIds.pixelIds; - data.linkIds = boardEntityIds.linkIds; + const authorizedEntityIds = await filterBoardEntityIdsForShare(board, boardEntityIds); + data.websiteIds = authorizedEntityIds.websiteIds; + data.pixelIds = authorizedEntityIds.pixelIds; + data.linkIds = authorizedEntityIds.linkIds; } else if (share.shareType === ENTITY_TYPE.website) { entity = await getWebsite(share.entityId); if (!entity) return notFound(); diff --git a/src/permissions/board.test.ts b/src/permissions/board.test.ts new file mode 100644 index 000000000..afae58084 --- /dev/null +++ b/src/permissions/board.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { BOARD_TYPES } from '@/lib/boards'; +import { canViewBoardEntities } from './board'; +import { canViewLink } from './link'; +import { canViewPixel } from './pixel'; +import { canViewWebsite } from './website'; + +vi.mock('@/queries/prisma', () => ({ + getBoard: vi.fn(), + getTeamUser: vi.fn(), +})); + +vi.mock('@/lib/prisma', () => ({ + default: {}, +})); + +vi.mock('./website', () => ({ + canViewWebsite: vi.fn(), +})); + +vi.mock('./pixel', () => ({ + canViewPixel: vi.fn(), +})); + +vi.mock('./link', () => ({ + canViewLink: vi.fn(), +})); + +const auth = { + user: { + id: 'user-1', + username: 'user', + role: 'user', + isAdmin: false, + }, + shareToken: { + websiteIds: ['victim-website-id'], + }, +}; + +beforeEach(() => { + vi.mocked(canViewWebsite).mockReset(); + vi.mocked(canViewPixel).mockReset(); + vi.mocked(canViewLink).mockReset(); +}); + +test('canViewBoardEntities validates board IDs with user auth only', async () => { + vi.mocked(canViewWebsite).mockResolvedValue(true); + + await expect( + canViewBoardEntities(auth, BOARD_TYPES.website, { websiteId: 'owned-website-id' }), + ).resolves.toBe(true); + + expect(canViewWebsite).toHaveBeenCalledWith( + { + user: auth.user, + }, + 'owned-website-id', + ); +}); + +test('canViewBoardEntities rejects IDs not accessible to the user', async () => { + vi.mocked(canViewWebsite).mockResolvedValue(false); + + await expect( + canViewBoardEntities(auth, BOARD_TYPES.mixed, { + rows: [ + { + id: 'row-1', + columns: [ + { + id: 'column-1', + component: { + type: 'WebsiteChart', + entityType: 'website', + entityId: 'victim-website-id', + }, + }, + ], + }, + ], + }), + ).resolves.toBe(false); +}); diff --git a/src/permissions/board.ts b/src/permissions/board.ts index 67c25d03a..d63b4a495 100644 --- a/src/permissions/board.ts +++ b/src/permissions/board.ts @@ -1,7 +1,37 @@ import { hasPermission } from '@/lib/auth'; +import { getBoardEntityIds } from '@/lib/boards'; import { PERMISSIONS } from '@/lib/constants'; -import type { Auth } from '@/lib/types'; +import type { Auth, BoardParameters } from '@/lib/types'; import { getBoard, getTeamUser } from '@/queries/prisma'; +import { canViewLink } from './link'; +import { canViewPixel } from './pixel'; +import { canViewWebsite } from './website'; + +async function checkBoardEntityAccess(check: Promise) { + try { + return await check; + } catch { + return false; + } +} + +export async function canViewBoardEntities( + auth: Auth, + type: string | undefined, + parameters: BoardParameters = {}, +) { + const { websiteIds, pixelIds, linkIds } = getBoardEntityIds({ type, parameters }); + const userOnlyAuth: Auth = { user: auth.user }; + const checks = [ + ...websiteIds.map(id => checkBoardEntityAccess(canViewWebsite(userOnlyAuth, id))), + ...pixelIds.map(id => checkBoardEntityAccess(canViewPixel(userOnlyAuth, id))), + ...linkIds.map(id => checkBoardEntityAccess(canViewLink(userOnlyAuth, id))), + ]; + + const results = await Promise.all(checks); + + return results.every(Boolean); +} export async function canViewBoard({ user, shareToken }: Auth, boardId: string) { if (user?.isAdmin) { diff --git a/src/permissions/link.ts b/src/permissions/link.ts index be6eef71a..58b36cf33 100644 --- a/src/permissions/link.ts +++ b/src/permissions/link.ts @@ -22,6 +22,10 @@ export async function canViewLink({ user, shareToken }: Auth, linkId: string) { const link = await getLink(linkId); + if (!link) { + return false; + } + if (link.userId) { return user.id === link.userId; } @@ -46,6 +50,10 @@ export async function canUpdateLink({ user }: Auth, linkId: string) { const link = await getLink(linkId); + if (!link) { + return false; + } + if (link.userId) { return user.id === link.userId; } @@ -70,6 +78,10 @@ export async function canDeleteLink({ user }: Auth, linkId: string) { const link = await getLink(linkId); + if (!link) { + return false; + } + if (link.userId) { return user.id === link.userId; } diff --git a/src/permissions/pixel.ts b/src/permissions/pixel.ts index 061363063..23715bb84 100644 --- a/src/permissions/pixel.ts +++ b/src/permissions/pixel.ts @@ -22,6 +22,10 @@ export async function canViewPixel({ user, shareToken }: Auth, pixelId: string) const pixel = await getPixel(pixelId); + if (!pixel) { + return false; + } + if (pixel.userId) { return user.id === pixel.userId; } @@ -46,6 +50,10 @@ export async function canUpdatePixel({ user }: Auth, pixelId: string) { const pixel = await getPixel(pixelId); + if (!pixel) { + return false; + } + if (pixel.userId) { return user.id === pixel.userId; } @@ -70,6 +78,10 @@ export async function canDeletePixel({ user }: Auth, pixelId: string) { const pixel = await getPixel(pixelId); + if (!pixel) { + return false; + } + if (pixel.userId) { return user.id === pixel.userId; }