Fix potential IDOR vulnerability in workspace parsed files endpoints

Add ownership validation to prevent users from deleting or embedding
parsed files that don't belong to them. Previously, the delete and
embed endpoints only validated authentication but not resource ownership,
allowing users to delete attached files for users within workspaces they are also a member of.

Changes:
- Delete endpoint now filters by userId and workspaceId
- Embed endpoint validates file belongs to user and workspace (redundant)
- delete() returns false when no matching records found (returns 403)
- Added JSDoc comments for clarity
GHSA-p5rf-8p88-979c
This commit is contained in:
Timothy Carambat
2026-03-13 15:22:07 -07:00
parent 6b2ed8ec12
commit f2030343d7
2 changed files with 33 additions and 7 deletions

View File

@@ -50,10 +50,16 @@ function workspaceParsedFilesEndpoints(app) {
try {
const { fileIds = [] } = reqBody(request);
if (!fileIds.length) return response.sendStatus(400).end();
const user = await userFromSession(request, response);
const workspace = response.locals.workspace;
const success = await WorkspaceParsedFiles.delete({
id: { in: fileIds.map((id) => parseInt(id)) },
id: {
in: fileIds.map((id) => parseInt(id)),
},
...(user ? { userId: user.id } : {}),
workspaceId: workspace.id,
});
return response.status(success ? 200 : 500).end();
return response.status(success ? 200 : 403).end();
} catch (e) {
console.error(e.message, e);
return response.sendStatus(500).end();
@@ -77,7 +83,11 @@ function workspaceParsedFilesEndpoints(app) {
if (!fileId) return response.sendStatus(400).end();
const { success, error, document } =
await WorkspaceParsedFiles.moveToDocumentsAndEmbed(fileId, workspace);
await WorkspaceParsedFiles.moveToDocumentsAndEmbed(
user,
fileId,
workspace
);
if (!success) {
return response.status(500).json({

View File

@@ -43,6 +43,11 @@ const WorkspaceParsedFiles = {
}
},
/**
* Gets a parsed file by its ID or a clause.
* @param {object} clause - The clause to filter the parsed files.
* @returns {Promise<import("@prisma/client").workspace_parsed_files | null>} The parsed file.
*/
get: async function (clause = {}) {
try {
const file = await prisma.workspace_parsed_files.findFirst({
@@ -77,10 +82,10 @@ const WorkspaceParsedFiles = {
delete: async function (clause = {}) {
try {
await prisma.workspace_parsed_files.deleteMany({
const result = await prisma.workspace_parsed_files.deleteMany({
where: clause,
});
return true;
return result.count > 0;
} catch (error) {
console.error(error.message);
return false;
@@ -95,9 +100,20 @@ const WorkspaceParsedFiles = {
return _sum.tokenCountEstimate || 0;
},
moveToDocumentsAndEmbed: async function (fileId, workspace) {
/**
* Moves a parsed file to the documents and embeds it.
* @param {import("@prisma/client").users | null} user - The user performing the operation.
* @param {number} fileId - The ID of the parsed file.
* @param {import("@prisma/client").workspaces} workspace - The workspace the file belongs to.
* @returns {Promise<{ success: boolean, error: string | null, document: import("@prisma/client").workspace_documents | null }>} The result of the operation.
*/
moveToDocumentsAndEmbed: async function (user = null, fileId, workspace) {
try {
const parsedFile = await this.get({ id: parseInt(fileId) });
const parsedFile = await this.get({
id: parseInt(fileId),
...(user ? { userId: user.id } : {}),
workspaceId: workspace.id,
});
if (!parsedFile) throw new Error("File not found");
// Get file location from metadata