Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/app/api/me/websites/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma';
import {
getAllUserWebsitesIncludingTeamOwner,
getUserWebsitesIncludingAdminOwned,
} from '@/queries/prisma';

export async function GET(request: Request) {
const schema = z.object({
Expand All @@ -22,5 +25,5 @@ export async function GET(request: Request) {
return json(await getAllUserWebsitesIncludingTeamOwner(auth.user.id, filters));
}

return json(await getUserWebsites(auth.user.id, filters));
return json(await getUserWebsitesIncludingAdminOwned(auth.user.id, filters));
}
7 changes: 5 additions & 2 deletions src/app/api/users/[userId]/websites/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website';
import {
getAllUserWebsitesIncludingTeamOwner,
getUserWebsitesIncludingAdminOwned,
} from '@/queries/prisma/website';

export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({
Expand All @@ -29,5 +32,5 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
return json(await getAllUserWebsitesIncludingTeamOwner(userId, filters));
}

return json(await getUserWebsites(userId, filters));
return json(await getUserWebsitesIncludingAdminOwned(userId, filters));
}
7 changes: 5 additions & 2 deletions src/app/api/websites/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createShare, createWebsite, getWebsiteCount } from '@/queries/prisma';
import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website';
import {
getAllUserWebsitesIncludingTeamOwner,
getUserWebsitesIncludingAdminOwned,
} from '@/queries/prisma/website';

const CLOUD_WEBSITE_LIMIT = 3;

Expand All @@ -32,7 +35,7 @@ export async function GET(request: Request) {
return json(await getAllUserWebsitesIncludingTeamOwner(userId, filters));
}

return json(await getUserWebsites(userId, filters));
return json(await getUserWebsitesIncludingAdminOwned(userId, filters));
}

export async function POST(request: Request) {
Expand Down
8 changes: 6 additions & 2 deletions src/permissions/website.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { hasPermission } from '@/lib/auth';
import { PERMISSIONS } from '@/lib/constants';
import { getEntity } from '@/lib/entity';
import type { Auth } from '@/lib/types';
import { getTeamUser, getWebsite } from '@/queries/prisma';
import { getTeamUser, getWebsite, isAdminOwnedWebsite } from '@/queries/prisma';

export async function canViewWebsite({ user, shareToken }: Auth, websiteId: string) {
if (user?.isAdmin) {
Expand All @@ -27,7 +27,11 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
}

if (entity.userId) {
return user.id === entity.userId;
if (user.id === entity.userId) {
return true;
}

return isAdminOwnedWebsite(websiteId);
Comment on lines 29 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Blanket view grant for all admin-owned websites

isAdminOwnedWebsite only checks whether the website's owner has the admin role — it does not verify any relationship between the requesting user and that admin. Any authenticated non-admin user can now directly view the data of every website owned by any admin account simply by knowing (or guessing) its ID. There is no per-website opt-in or per-admin sharing intent captured here; if an admin has websites intended to remain private, this check will still return true and grant access to all authenticated users.

}

if (entity.teamId) {
Expand Down
54 changes: 54 additions & 0 deletions src/queries/prisma/website.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ export async function getWebsite(websiteId: string) {
return attachShareIdToWebsite(website);
}

export async function isAdminOwnedWebsite(websiteId: string) {
const website = await prisma.client.website.findFirst({
where: {
id: websiteId,
deletedAt: null,
user: {
role: ROLES.admin,
deletedAt: null,
},
},
select: {
id: true,
},
});

return !!website;
}

export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) {
const { search } = filters;
const { getSearchParameters, pagedQuery } = prisma;
Expand All @@ -48,6 +66,12 @@ export async function getAllUserWebsitesIncludingTeamOwner(userId: string, filte
where: {
OR: [
{ userId },
{
user: {
role: ROLES.admin,
deletedAt: null,
},
},
{
team: {
deletedAt: null,
Expand Down Expand Up @@ -91,6 +115,36 @@ export async function getUserWebsites(userId: string, filters?: QueryFilters) {
);
}

export async function getUserWebsitesIncludingAdminOwned(userId: string, filters?: QueryFilters) {
return getWebsites(
{
where: {
OR: [
{ userId },
{
user: {
role: ROLES.admin,
deletedAt: null,
},
},
],
},
include: {
user: {
select: {
username: true,
id: true,
},
},
},
},
{
orderBy: 'name',
...filters,
},
);
}
Comment on lines +118 to +146

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 All admin websites leaked to every user's listing

getUserWebsitesIncludingAdminOwned returns websites owned by userId OR owned by any user with the admin role — without any relationship to the caller. In a multi-admin deployment every non-admin user now receives the full website list of every admin account in the system (not just the "system" admin that may have set up shared websites). Combined with the permission change in canViewWebsite, this means one admin's private analytics data is unconditionally visible to all users. If the intent is to share only certain admin-managed websites, a marker on the website (e.g. isPublic / sharedWithAll) or an explicit team membership would be safer than a blanket role-based filter.


export async function getTeamWebsites(teamId: string, filters?: QueryFilters) {
return getWebsites(
{
Expand Down