Skip to content

Commit 2737c63

Browse files
rorydavidsonclaude
andcommitted
fix: enforce folder/file ancestry checks to prevent cross-space document access
User-supplied folderId query parameters were accepted without verifying they belonged to the target space's Drive folder tree. An authenticated user in Space A could supply Space B's folder ID to list, upload, or create folders in unauthorised spaces. Similarly, download and delete endpoints accepted arbitrary file IDs without ownership verification. Add verifyFolderAncestry and verifyFileAncestry functions that walk the Drive API parent chain. Apply format validation and ancestry checks to all five affected endpoints (list, section list, upload, create folder, download, delete). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eacdb4d commit 2737c63

5 files changed

Lines changed: 140 additions & 7 deletions

File tree

apps/bff/src/routes/documents-extra.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ vi.mock("../services/drive.js", () => ({
3636
uploadFile: vi.fn(),
3737
searchFilesInFolders: vi.fn(),
3838
checkDriveAccess: vi.fn(),
39+
verifyFolderAncestry: vi.fn().mockResolvedValue(true),
40+
verifyFileAncestry: vi.fn().mockResolvedValue(true),
3941
}));
4042

4143
import * as db from "../services/db.js";

apps/bff/src/routes/documents.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ vi.mock('../services/drive.js', () => ({
2222
uploadFile: vi.fn(),
2323
searchFilesInFolders: vi.fn(),
2424
checkDriveAccess: vi.fn(),
25+
verifyFolderAncestry: vi.fn().mockResolvedValue(true),
26+
verifyFileAncestry: vi.fn().mockResolvedValue(true),
2527
}));
2628

2729
import * as db from '../services/db.js';

apps/bff/src/routes/documents.ts

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import os from "os";
1111
import { requireAuth } from "../middleware/requireAuth.js";
1212
import { asyncHandler } from "../middleware/asyncHandler.js";
1313
import { getSpaces, getSpaceById, getSectionById, createAuditLog, getCategoryConfigs } from "../services/db.js";
14-
import { listFiles, downloadFile, uploadFile, deleteFile, createFolder } from "../services/drive.js";
14+
import { listFiles, downloadFile, uploadFile, deleteFile, createFolder, verifyFolderAncestry, verifyFileAncestry } from "../services/drive.js";
1515

1616
// Allowed MIME types for uploads — documents and common office formats only
1717
const ALLOWED_MIME_TYPES = new Set([
@@ -87,6 +87,16 @@ export function isAdminUser(groups: string[]): boolean {
8787
return groups.some((g) => g === "portal_admin" || g === "/portal_admin");
8888
}
8989

90+
// ---------------------------------------------------------------------------
91+
// Helper: validate a user-supplied Drive folder ID
92+
// ---------------------------------------------------------------------------
93+
94+
const DRIVE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
95+
96+
function isValidDriveId(id: string): boolean {
97+
return DRIVE_ID_PATTERN.test(id) && id.length >= 1 && id.length <= 128;
98+
}
99+
90100
// ---------------------------------------------------------------------------
91101
// GET /documents/categories — category sort-order config (auth required, no admin needed)
92102
// Used by the spaces listing page to order category sections correctly.
@@ -139,7 +149,21 @@ router.get("/:spaceId", async (req: Request, res: Response): Promise<void> => {
139149

140150
try {
141151
const folderId = req.query.folderId as string | undefined;
142-
const targetFolderId = folderId || space.driveFolderId;
152+
let targetFolderId = space.driveFolderId;
153+
154+
if (folderId) {
155+
if (!isValidDriveId(folderId)) {
156+
res.status(400).json({ error: "Invalid folder ID", code: "INVALID_FOLDER_ID" });
157+
return;
158+
}
159+
const belongs = await verifyFolderAncestry(folderId, space.driveFolderId);
160+
if (!belongs) {
161+
res.status(403).json({ error: "Folder is not within this space", code: "FOLDER_OUTSIDE_SPACE" });
162+
return;
163+
}
164+
targetFolderId = folderId;
165+
}
166+
143167
const files = await listFiles(targetFolderId);
144168
res.json({ space, files });
145169
} catch (err) {
@@ -211,7 +235,21 @@ router.get(
211235

212236
try {
213237
const folderId = req.query.folderId as string | undefined;
214-
const targetFolderId = folderId || section.driveFolderId;
238+
let targetFolderId = section.driveFolderId;
239+
240+
if (folderId) {
241+
if (!isValidDriveId(folderId)) {
242+
res.status(400).json({ error: "Invalid folder ID", code: "INVALID_FOLDER_ID" });
243+
return;
244+
}
245+
const belongs = await verifyFolderAncestry(folderId, space.driveFolderId);
246+
if (!belongs) {
247+
res.status(403).json({ error: "Folder is not within this space", code: "FOLDER_OUTSIDE_SPACE" });
248+
return;
249+
}
250+
targetFolderId = folderId;
251+
}
252+
215253
const files = await listFiles(targetFolderId);
216254
res.json({ space, section, files });
217255
} catch (err) {
@@ -253,9 +291,14 @@ router.get(
253291
}
254292

255293
try {
256-
const { stream, mimeType, name } = await downloadFile(
257-
String(req.params.fileId),
258-
);
294+
const fileId = String(req.params.fileId);
295+
const fileBelongs = await verifyFileAncestry(fileId, space.driveFolderId);
296+
if (!fileBelongs) {
297+
res.status(403).json({ error: "File is not within this space", code: "FILE_OUTSIDE_SPACE" });
298+
return;
299+
}
300+
301+
const { stream, mimeType, name } = await downloadFile(fileId);
259302
// ?download=1 → force browser save-as dialog (attachment) regardless of type.
260303
// Without the flag: PDFs stream inline (so the in-portal PDF viewer can fetch them);
261304
// all other types are always forced to attachment.
@@ -377,12 +420,22 @@ router.post(
377420
}
378421

379422
try {
380-
// Priority: 1. folderId (subfolder), 2. sectionId (category folder), 3. space.driveFolderId (root)
381423
const folderId = req.query.folderId as string | undefined;
382424
const sectionId = req.query.sectionId as string | undefined;
383425
let targetFolderId = space.driveFolderId;
384426

385427
if (folderId) {
428+
if (!isValidDriveId(folderId)) {
429+
fs.unlink(file.path, () => { });
430+
res.status(400).json({ error: "Invalid folder ID", code: "INVALID_FOLDER_ID" });
431+
return;
432+
}
433+
const belongs = await verifyFolderAncestry(folderId, space.driveFolderId);
434+
if (!belongs) {
435+
fs.unlink(file.path, () => { });
436+
res.status(403).json({ error: "Folder is not within this space", code: "FOLDER_OUTSIDE_SPACE" });
437+
return;
438+
}
386439
targetFolderId = folderId;
387440
} else if (sectionId) {
388441
const section = await getSectionById(space.id, sectionId);
@@ -482,6 +535,15 @@ router.post(
482535
let targetFolderId = space.driveFolderId;
483536

484537
if (folderId) {
538+
if (!isValidDriveId(folderId)) {
539+
res.status(400).json({ error: "Invalid folder ID", code: "INVALID_FOLDER_ID" });
540+
return;
541+
}
542+
const belongs = await verifyFolderAncestry(folderId, space.driveFolderId);
543+
if (!belongs) {
544+
res.status(403).json({ error: "Folder is not within this space", code: "FOLDER_OUTSIDE_SPACE" });
545+
return;
546+
}
485547
targetFolderId = folderId;
486548
} else if (sectionId) {
487549
const section = await getSectionById(space.id, sectionId);
@@ -547,6 +609,12 @@ router.delete(
547609
}
548610

549611
try {
612+
const fileBelongs = await verifyFileAncestry(fileId, space.driveFolderId);
613+
if (!fileBelongs) {
614+
res.status(403).json({ error: "File is not within this space", code: "FILE_OUTSIDE_SPACE" });
615+
return;
616+
}
617+
550618
await deleteFile(fileId);
551619

552620
res.status(204).end();

apps/bff/src/services/drive.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,61 @@ export async function deleteFile(fileId: string): Promise<void> {
412412
});
413413
}
414414

415+
/**
416+
* Verify that `folderId` is a descendant of (or equal to) `rootFolderId`
417+
* by walking the parents chain. Returns true if the ancestry is confirmed.
418+
* Protects against IDOR where a user supplies an arbitrary Drive folder ID
419+
* to access files outside their authorised space.
420+
*/
421+
export async function verifyFolderAncestry(
422+
folderId: string,
423+
rootFolderId: string,
424+
): Promise<boolean> {
425+
if (folderId === rootFolderId) return true;
426+
if (isMockMode()) return true;
427+
428+
const MAX_DEPTH = 15;
429+
let currentId = folderId;
430+
431+
for (let i = 0; i < MAX_DEPTH; i++) {
432+
const res = await drive().files.get({
433+
fileId: currentId,
434+
fields: "parents",
435+
supportsAllDrives: true,
436+
});
437+
438+
const parents = res.data.parents;
439+
if (!parents || parents.length === 0) return false;
440+
441+
if (parents.includes(rootFolderId)) return true;
442+
currentId = parents[0];
443+
}
444+
445+
return false;
446+
}
447+
448+
/**
449+
* Verify that a file belongs to the folder tree rooted at `rootFolderId`.
450+
* Gets the file's immediate parent and then walks up the ancestry chain.
451+
*/
452+
export async function verifyFileAncestry(
453+
fileId: string,
454+
rootFolderId: string,
455+
): Promise<boolean> {
456+
if (isMockMode()) return true;
457+
458+
const res = await drive().files.get({
459+
fileId,
460+
fields: "parents",
461+
supportsAllDrives: true,
462+
});
463+
464+
const parents = res.data.parents;
465+
if (!parents || parents.length === 0) return false;
466+
467+
return verifyFolderAncestry(parents[0], rootFolderId);
468+
}
469+
415470
/**
416471
* Check whether the Drive service is reachable (used at startup).
417472
*/

pnpm-workspace.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
packages:
22
- 'apps/*'
33
- 'packages/*'
4+
allowBuilds:
5+
better-sqlite3: true
6+
canvas: true
7+
esbuild: true
8+
sharp: true
9+
unrs-resolver: true

0 commit comments

Comments
 (0)