mirror of
https://github.com/willchen96/mike.git
synced 2026-06-08 20:25:13 +02:00
The tabular-review routes accept user-supplied document_ids in
request bodies (POST /tabular-review, PATCH /:reviewId) and stale
cell rows on byte-fetching paths (POST /:reviewId/regenerate-cell,
POST /:reviewId/generate). None of those paths checked whether the
caller can read those documents — a free-account attacker could plant
foreign UUIDs into their own review and have the server fetch the
bytes from R2 + run an LLM extraction over them, returning verbatim
text via the standard review GET.
Adds filterAccessibleDocumentIds(documentIds, userId, userEmail, db)
next to the existing access helpers (owner-of-doc OR project member),
and applies it at the four entry points:
- POST /tabular-review drop unauthorised on insert
- PATCH /:reviewId drop newly-added unauthorised; keep
already-attached cells so non-owner
collaborators don't accidentally
orphan rows they can't directly
access
- POST /:reviewId/regenerate-cell refuse byte fetch when caller has
no access to the underlying doc
- POST /:reviewId/generate filter docIds before parallel LLM
fetch (defense-in-depth for legacy
cells planted before this fix)
Fails closed silently rather than 403'ing so legacy clients that pass
stale ids don't error out the whole review.
Detected by Aeon + manual review.
Severity: high
CWE-639 (Authorization Bypass Through User-Controlled Key)
187 lines
6.1 KiB
TypeScript
187 lines
6.1 KiB
TypeScript
/**
|
|
* Project / document access helpers.
|
|
*
|
|
* Sharing makes the previous "scope by user_id" pattern incorrect — a doc
|
|
* can belong to user A's project that A has shared with B's email, and B
|
|
* must still be able to read/edit it. These helpers centralize the
|
|
* "owner OR shared project member" check so every route uses the same
|
|
* logic instead of re-implementing the join.
|
|
*
|
|
* Returned `isOwner` lets callers gate operations that should stay
|
|
* owner-only (delete, rename, member management).
|
|
*/
|
|
|
|
import type { createServerSupabase } from "./supabase";
|
|
|
|
type Db = ReturnType<typeof createServerSupabase>;
|
|
|
|
export type ProjectAccess =
|
|
| {
|
|
ok: true;
|
|
isOwner: boolean;
|
|
project: {
|
|
id: string;
|
|
user_id: string;
|
|
shared_with: string[] | null;
|
|
};
|
|
}
|
|
| { ok: false };
|
|
|
|
export async function checkProjectAccess(
|
|
projectId: string,
|
|
userId: string,
|
|
userEmail: string | null | undefined,
|
|
db: Db,
|
|
): Promise<ProjectAccess> {
|
|
const { data: project } = await db
|
|
.from("projects")
|
|
.select("id, user_id, shared_with")
|
|
.eq("id", projectId)
|
|
.single();
|
|
if (!project) return { ok: false };
|
|
const proj = project as {
|
|
id: string;
|
|
user_id: string;
|
|
shared_with: string[] | null;
|
|
};
|
|
if (proj.user_id === userId) {
|
|
return { ok: true, isOwner: true, project: proj };
|
|
}
|
|
const sharedWith = Array.isArray(proj.shared_with) ? proj.shared_with : [];
|
|
const email = (userEmail ?? "").toLowerCase();
|
|
if (
|
|
email &&
|
|
sharedWith.some((e) => (e ?? "").toLowerCase() === email)
|
|
) {
|
|
return { ok: true, isOwner: false, project: proj };
|
|
}
|
|
return { ok: false };
|
|
}
|
|
|
|
/**
|
|
* Check whether the current user can access a document the caller has
|
|
* already loaded (saves a round-trip vs. having the helper re-fetch).
|
|
* Owner-of-doc passes immediately; otherwise we fall through to a
|
|
* project-membership check via `shared_with`.
|
|
*/
|
|
export async function ensureDocAccess(
|
|
doc: { user_id: string; project_id: string | null },
|
|
userId: string,
|
|
userEmail: string | null | undefined,
|
|
db: Db,
|
|
): Promise<{ ok: true; isOwner: boolean } | { ok: false }> {
|
|
if (doc.user_id === userId) return { ok: true, isOwner: true };
|
|
if (!doc.project_id) return { ok: false };
|
|
const access = await checkProjectAccess(
|
|
doc.project_id,
|
|
userId,
|
|
userEmail,
|
|
db,
|
|
);
|
|
if (access.ok) return { ok: true, isOwner: false };
|
|
return { ok: false };
|
|
}
|
|
|
|
/**
|
|
* Same shape as `ensureDocAccess`, for tabular_reviews. A review can be
|
|
* shared in two ways:
|
|
* 1. Indirectly — if `project_id` is set, everyone with project access
|
|
* can read/operate on it.
|
|
* 2. Directly — `tabular_reviews.shared_with` is a per-review email list
|
|
* so standalone reviews (project_id null) can also be shared.
|
|
* The owner (review.user_id) always has access.
|
|
*/
|
|
export async function ensureReviewAccess(
|
|
review: {
|
|
user_id: string;
|
|
project_id: string | null;
|
|
shared_with?: string[] | null;
|
|
},
|
|
userId: string,
|
|
userEmail: string | null | undefined,
|
|
db: Db,
|
|
): Promise<{ ok: true; isOwner: boolean } | { ok: false }> {
|
|
if (review.user_id === userId) return { ok: true, isOwner: true };
|
|
const email = (userEmail ?? "").toLowerCase();
|
|
if (email && Array.isArray(review.shared_with)) {
|
|
if (review.shared_with.some((e) => (e ?? "").toLowerCase() === email)) {
|
|
return { ok: true, isOwner: false };
|
|
}
|
|
}
|
|
if (!review.project_id) return { ok: false };
|
|
const access = await checkProjectAccess(
|
|
review.project_id,
|
|
userId,
|
|
userEmail,
|
|
db,
|
|
);
|
|
if (access.ok) return { ok: true, isOwner: false };
|
|
return { ok: false };
|
|
}
|
|
|
|
/**
|
|
* Filter a list of document IDs down to those the caller is actually
|
|
* authorised to read — owners pass, plus any document whose `project_id`
|
|
* the caller has access to (own project or `shared_with` member).
|
|
*
|
|
* The tabular-review routes accept user-supplied `document_ids` from
|
|
* request bodies; without this filter an attacker who has any review of
|
|
* their own can plant arbitrary doc UUIDs and have the server fetch + run
|
|
* an LLM extraction over their bytes (CWE-639).
|
|
*/
|
|
export async function filterAccessibleDocumentIds(
|
|
documentIds: string[],
|
|
userId: string,
|
|
userEmail: string | null | undefined,
|
|
db: Db,
|
|
): Promise<string[]> {
|
|
if (documentIds.length === 0) return [];
|
|
const { data: docs } = await db
|
|
.from("documents")
|
|
.select("id, user_id, project_id")
|
|
.in("id", documentIds);
|
|
const rows = (docs ?? []) as {
|
|
id: string;
|
|
user_id: string;
|
|
project_id: string | null;
|
|
}[];
|
|
if (rows.length === 0) return [];
|
|
const accessibleProjectIds = new Set(
|
|
await listAccessibleProjectIds(userId, userEmail, db),
|
|
);
|
|
const out: string[] = [];
|
|
for (const d of rows) {
|
|
if (d.user_id === userId) {
|
|
out.push(d.id);
|
|
} else if (d.project_id && accessibleProjectIds.has(d.project_id)) {
|
|
out.push(d.id);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Returns the set of project IDs the user can access — own projects plus
|
|
* any project where their email is in `shared_with`. Used to scope chat
|
|
* lists and similar collection queries.
|
|
*/
|
|
export async function listAccessibleProjectIds(
|
|
userId: string,
|
|
userEmail: string | null | undefined,
|
|
db: Db,
|
|
): Promise<string[]> {
|
|
const [{ data: own }, { data: shared }] = await Promise.all([
|
|
db.from("projects").select("id").eq("user_id", userId),
|
|
userEmail
|
|
? db
|
|
.from("projects")
|
|
.select("id")
|
|
.contains("shared_with", [userEmail])
|
|
.neq("user_id", userId)
|
|
: Promise.resolve({ data: [] as { id: string }[] }),
|
|
]);
|
|
const ids = new Set<string>();
|
|
for (const p of (own ?? []) as { id: string }[]) ids.add(p.id);
|
|
for (const p of (shared ?? []) as { id: string }[]) ids.add(p.id);
|
|
return [...ids];
|
|
}
|