harden report references in boards
This commit is contained in:
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)) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user