harden report references in boards

This commit is contained in:
Francis Cao committed 2026-07-06 10:58:43 -07:00
1 parent a4a0167515
commit 7bdc2f215e
8 files changed
+346 -10

No files matched your search

@@ -1,6 +1,7 @@
import { Text } from '@umami/react-zen';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Link2Off } from '@/components/icons';
import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
import { Funnel } from './Funnel';
@@ -9,7 +10,11 @@ export function BoardFunnel({ websiteId, reportId }: { websiteId: string; report
if (!reportId) {
return (
<EmptyPlaceholder title="Select a saved funnel" description="Choose an existing funnel." />
<EmptyPlaceholder
icon={<Link2Off />}
title="Reconnect funnel"
description="Choose a funnel for this website."
/>
);
}
@@ -2,6 +2,7 @@ import { Text } from '@umami/react-zen';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange } from '@/components/hooks';
import { Link2Off } from '@/components/icons';
import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
import { Goal } from './Goal';
@@ -12,7 +13,13 @@ export function BoardGoal({ websiteId, reportId }: { websiteId: string; reportId
const { data, isLoading, error, isFetching } = useReportQuery(reportId || '');
if (!reportId) {
return <EmptyPlaceholder title="Select a saved goal" description="Choose an existing goal." />;
return (
<EmptyPlaceholder
icon={<Link2Off />}
title="Reconnect goal"
description="Choose a goal for this website."
/>
);
}
if (data && (data.type !== 'goal' || data.websiteId !== websiteId)) {
+9 -1
View File
@@ -10,6 +10,8 @@ import {
canCreateWebsite,
canUpdateBoard,
canViewBoardEntities,
hasValidBoardReports,
stripInvalidBoardReports,
} from '@/permissions';
import { createBoard, getBoard } from '@/queries/prisma';
@@ -57,12 +59,18 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
return badRequest({ message: 'Board contains inaccessible entities.' });
}
const sanitizedParameters = await stripInvalidBoardReports(sourceType, parameters);
if (!(await hasValidBoardReports(sourceType, sanitizedParameters))) {
return badRequest({ message: 'Board contains invalid saved reports.' });
}
const result = await createBoard({
id: uuid(),
type: sourceType,
name: body.name ?? sourceBoard.name,
description: body.description ?? sourceBoard.description,
parameters: parameters as Prisma.InputJsonValue,
parameters: sanitizedParameters as Prisma.InputJsonValue,
teamId: sourceBoard.teamId,
userId: sourceBoard.teamId ? undefined : sourceBoard.userId ?? auth.user.id,
});
+11 -1
View File
@@ -3,7 +3,13 @@ import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
import type { BoardParameters } from '@/lib/types';
import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
import { canDeleteBoard, canUpdateBoard, canViewBoard, canViewBoardEntities } from '@/permissions';
import {
canDeleteBoard,
canUpdateBoard,
canViewBoard,
canViewBoardEntities,
hasValidBoardReports,
} from '@/permissions';
import { deleteBoard, getBoard, updateBoard } from '@/queries/prisma';
export async function GET(request: Request, { params }: { params: Promise<{ boardId: string }> }) {
@@ -68,6 +74,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
if (!(await canViewBoardEntities(auth, nextType, nextParameters))) {
return badRequest({ message: 'Board contains inaccessible entities.' });
}
if (!(await hasValidBoardReports(nextType, nextParameters))) {
return badRequest({ message: 'Board contains invalid saved reports.' });
}
}
try {
+10 -1
View File
@@ -4,7 +4,12 @@ import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite, canViewBoardEntities } from '@/permissions';
import {
canCreateTeamWebsite,
canCreateWebsite,
canViewBoardEntities,
hasValidBoardReports,
} from '@/permissions';
import { createBoard, getUserBoards } from '@/queries/prisma';
export async function GET(request: Request) {
@@ -62,6 +67,10 @@ export async function POST(request: Request) {
return badRequest({ message: 'Board contains inaccessible entities.' });
}
if (!(await hasValidBoardReports(body.type, body.parameters))) {
return badRequest({ message: 'Board contains invalid saved reports.' });
}
const data = {
...body,
type: normalizeBoardType(body.type),
+6 -1
View File
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { hasValidBoardReports } from '@/permissions';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { badRequest, json, unauthorized } from '@/lib/response';
import { createBoard, getBoard, updateBoard } from '@/queries/prisma';
export async function GET(request: Request) {
@@ -45,6 +46,10 @@ export async function POST(request: Request) {
parameters: body.parameters ?? {},
};
if (!(await hasValidBoardReports(existing?.type ?? 'dashboard', data.parameters))) {
return badRequest({ message: 'Board contains invalid saved reports.' });
}
if (existing) {
const result = await updateBoard(userId, data);
+175 -1
View File
@@ -1,12 +1,18 @@
import { beforeEach, expect, test, vi } from 'vitest';
import { BOARD_TYPES } from '@/lib/boards';
import { canViewBoardEntities } from './board';
import { getReport } from '@/queries/prisma';
import {
canViewBoardEntities,
hasValidBoardReports,
stripInvalidBoardReports,
} from './board';
import { canViewLink } from './link';
import { canViewPixel } from './pixel';
import { canViewWebsite } from './website';
vi.mock('@/queries/prisma', () => ({
getBoard: vi.fn(),
getReport: vi.fn(),
getTeamUser: vi.fn(),
}));
@@ -42,6 +48,7 @@ beforeEach(() => {
vi.mocked(canViewWebsite).mockReset();
vi.mocked(canViewPixel).mockReset();
vi.mocked(canViewLink).mockReset();
vi.mocked(getReport).mockReset();
});
test('canViewBoardEntities validates board IDs with user auth only', async () => {
@@ -82,3 +89,170 @@ test('canViewBoardEntities rejects IDs not accessible to the user', async () =>
}),
).resolves.toBe(false);
});
test('hasValidBoardReports allows saved reports for the board website', async () => {
vi.mocked(getReport).mockResolvedValue({
id: 'report-1',
websiteId: 'website-1',
type: 'goal',
} as any);
await expect(
hasValidBoardReports(BOARD_TYPES.website, {
websiteId: 'website-1',
rows: [
{
id: 'row-1',
columns: [
{
id: 'column-1',
component: {
type: 'Goal',
props: {
reportId: 'report-1',
},
},
},
],
},
],
}),
).resolves.toBe(true);
});
test('hasValidBoardReports rejects saved reports from a different website', async () => {
vi.mocked(getReport).mockResolvedValue({
id: 'report-1',
websiteId: 'website-2',
type: 'goal',
} as any);
await expect(
hasValidBoardReports(BOARD_TYPES.website, {
websiteId: 'website-1',
rows: [
{
id: 'row-1',
columns: [
{
id: 'column-1',
component: {
type: 'Goal',
props: {
reportId: 'report-1',
},
},
},
],
},
],
}),
).resolves.toBe(false);
});
test('hasValidBoardReports rejects saved reports with the wrong type', async () => {
vi.mocked(getReport).mockResolvedValue({
id: 'report-1',
websiteId: 'website-1',
type: 'funnel',
} as any);
await expect(
hasValidBoardReports(BOARD_TYPES.website, {
websiteId: 'website-1',
rows: [
{
id: 'row-1',
columns: [
{
id: 'column-1',
component: {
type: 'Goal',
props: {
reportId: 'report-1',
},
},
},
],
},
],
}),
).resolves.toBe(false);
});
test('hasValidBoardReports rejects report widgets without a website context', async () => {
await expect(
hasValidBoardReports(BOARD_TYPES.pixel, {
pixelId: 'pixel-1',
rows: [
{
id: 'row-1',
columns: [
{
id: 'column-1',
component: {
type: 'Goal',
props: {
reportId: 'report-1',
},
},
},
],
},
],
}),
).resolves.toBe(false);
});
test('stripInvalidBoardReports removes only invalid report IDs', async () => {
vi.mocked(getReport).mockResolvedValue({
id: 'report-1',
websiteId: 'website-2',
type: 'goal',
} as any);
await expect(
stripInvalidBoardReports(BOARD_TYPES.website, {
websiteId: 'website-1',
rows: [
{
id: 'row-1',
columns: [
{
id: 'column-1',
component: {
type: 'Goal',
title: 'Saved signup goal',
description: 'Should stay on the board',
props: {
reportId: 'report-1',
customValue: 'keep-me',
},
},
},
],
},
],
}),
).resolves.toEqual({
websiteId: 'website-1',
rows: [
{
id: 'row-1',
columns: [
{
id: 'column-1',
component: {
type: 'Goal',
title: 'Saved signup goal',
description: 'Should stay on the board',
props: {
customValue: 'keep-me',
},
},
},
],
},
],
});
});
+121 -3
View File
@@ -1,12 +1,58 @@
import { hasPermission } from '@/lib/auth';
import { getBoardEntityIds } from '@/lib/boards';
import { BOARD_ENTITY_TYPES, getBoardEntityIds, getResolvedComponentEntity } from '@/lib/boards';
import { PERMISSIONS } from '@/lib/constants';
import type { Auth, BoardParameters } from '@/lib/types';
import { getBoard, getTeamUser } from '@/queries/prisma';
import type { Auth, BoardComponentConfig, BoardParameters } from '@/lib/types';
import { getBoard, getReport, getTeamUser } from '@/queries/prisma';
import { canViewLink } from './link';
import { canViewPixel } from './pixel';
import { canViewWebsite } from './website';
const BOARD_COMPONENT_REPORT_TYPES = {
Funnel: 'funnel',
Goal: 'goal',
} as const;
function getExpectedBoardReportType(componentType?: string) {
return componentType && componentType in BOARD_COMPONENT_REPORT_TYPES
? BOARD_COMPONENT_REPORT_TYPES[componentType as keyof typeof BOARD_COMPONENT_REPORT_TYPES]
: null;
}
async function isValidBoardReportReference(
board: { type?: string; parameters?: BoardParameters },
component: BoardComponentConfig,
reportCache: Map<string, ReturnType<typeof getReport>>,
) {
const expectedReportType = getExpectedBoardReportType(component.type);
if (!expectedReportType) {
return true;
}
const reportId = component.props?.reportId;
if (reportId == null || reportId === '') {
return true;
}
if (typeof reportId !== 'string') {
return false;
}
const entity = getResolvedComponentEntity(board, component);
if (entity.entityType !== BOARD_ENTITY_TYPES.website || !entity.entityId) {
return false;
}
const reportPromise = reportCache.get(reportId) ?? getReport(reportId);
reportCache.set(reportId, reportPromise);
const report = await reportPromise;
return !!report && report.type === expectedReportType && report.websiteId === entity.entityId;
}
async function checkBoardEntityAccess(check: Promise<boolean>) {
try {
return await check;
@@ -33,6 +79,78 @@ export async function canViewBoardEntities(
return results.every(Boolean);
}
export async function hasValidBoardReports(
type: string | undefined,
parameters: BoardParameters = {},
) {
const board = { type, parameters };
const reportCache = new Map<string, ReturnType<typeof getReport>>();
for (const row of parameters.rows ?? []) {
for (const column of row.columns ?? []) {
const component = column.component;
if (!component) {
continue;
}
if (!(await isValidBoardReportReference(board, component, reportCache))) {
return false;
}
}
}
return true;
}
export async function stripInvalidBoardReports(
type: string | undefined,
parameters: BoardParameters = {},
) {
const board = { type, parameters };
const reportCache = new Map<string, ReturnType<typeof getReport>>();
let hasChanges = false;
const rows = await Promise.all(
(parameters.rows ?? []).map(async row => {
const columns = await Promise.all(
row.columns.map(async column => {
const component = column.component;
if (!component || (await isValidBoardReportReference(board, component, reportCache))) {
return column;
}
hasChanges = true;
const { reportId: _reportId, ...nextProps } = component.props ?? {};
return {
...column,
component: {
...component,
props: Object.keys(nextProps).length > 0 ? nextProps : undefined,
},
};
}),
);
return {
...row,
columns,
};
}),
);
if (!hasChanges) {
return parameters;
}
return {
...parameters,
rows,
};
}
export async function canViewBoard({ user, shareToken }: Auth, boardId: string) {
if (user?.isAdmin) {
return true;