From be57b4d6dfa57303c8fed510accdb136da0c33f5 Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Wed, 21 Jan 2026 22:44:57 +0000 Subject: [PATCH] patched security holes --- packages/backend/src/auth/middleware.ts | 4 +++ packages/backend/src/db/queries/issues.ts | 8 +++++ packages/backend/src/routes/issue/create.ts | 14 +++++++- packages/backend/src/routes/issue/delete.ts | 29 +++++++++++++--- packages/backend/src/routes/issue/update.ts | 34 +++++++++++++++---- packages/backend/src/routes/project/create.ts | 10 +++++- packages/backend/src/routes/timer/toggle.ts | 33 ++++++++++++++++++ packages/frontend/src/components/top-bar.tsx | 2 +- packages/shared/src/api-schemas.ts | 2 +- packages/shared/src/index.ts | 14 ++++---- packages/shared/src/schema.ts | 2 +- 11 files changed, 129 insertions(+), 23 deletions(-) diff --git a/packages/backend/src/auth/middleware.ts b/packages/backend/src/auth/middleware.ts index 9a3ddb4..1ef34eb 100644 --- a/packages/backend/src/auth/middleware.ts +++ b/packages/backend/src/auth/middleware.ts @@ -35,6 +35,10 @@ export const withAuth = (handler: AuthedRouteHandler): return new Response("Session expired", { status: 401 }); } + if (session.userId !== userId) { + return new Response("Invalid session", { status: 401 }); + } + return handler( Object.assign(req, { userId, diff --git a/packages/backend/src/db/queries/issues.ts b/packages/backend/src/db/queries/issues.ts index ebef156..070e831 100644 --- a/packages/backend/src/db/queries/issues.ts +++ b/packages/backend/src/db/queries/issues.ts @@ -190,3 +190,11 @@ export async function getIssueAssigneeCount(issueId: number): Promise { .where(eq(IssueAssignee.issueId, issueId)); return result?.count ?? 0; } + +export async function isIssueAssignee(issueId: number, userId: number): Promise { + const [assignee] = await db + .select({ id: IssueAssignee.id }) + .from(IssueAssignee) + .where(and(eq(IssueAssignee.issueId, issueId), eq(IssueAssignee.userId, userId))); + return Boolean(assignee); +} diff --git a/packages/backend/src/routes/issue/create.ts b/packages/backend/src/routes/issue/create.ts index 3cab076..34cfb32 100644 --- a/packages/backend/src/routes/issue/create.ts +++ b/packages/backend/src/routes/issue/create.ts @@ -1,6 +1,6 @@ import { IssueCreateRequestSchema } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; -import { createIssue, getProjectByID } from "../../db/queries"; +import { createIssue, getOrganisationMemberRole, getProjectByID } from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function issueCreate(req: AuthedRequest) { @@ -14,6 +14,18 @@ export default async function issueCreate(req: AuthedRequest) { return errorResponse(`project not found: ${projectId}`, "PROJECT_NOT_FOUND", 404); } + const requesterMember = await getOrganisationMemberRole(project.organisationId, req.userId); + if (!requesterMember) { + return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403); + } + if (requesterMember.role !== "owner" && requesterMember.role !== "admin") { + return errorResponse( + "only organisation owners and admins can create issues", + "PERMISSION_DENIED", + 403, + ); + } + const issue = await createIssue( project.id, title, diff --git a/packages/backend/src/routes/issue/delete.ts b/packages/backend/src/routes/issue/delete.ts index ca6d967..d5544a5 100644 --- a/packages/backend/src/routes/issue/delete.ts +++ b/packages/backend/src/routes/issue/delete.ts @@ -1,18 +1,37 @@ import { IssueDeleteRequestSchema } from "@sprint/shared"; -import type { BunRequest } from "bun"; -import { deleteIssue } from "../../db/queries"; +import type { AuthedRequest } from "../../auth/middleware"; +import { deleteIssue, getIssueByID, getOrganisationMemberRole, getProjectByID } from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; -export default async function issueDelete(req: BunRequest) { +export default async function issueDelete(req: AuthedRequest) { const parsed = await parseJsonBody(req, IssueDeleteRequestSchema); if ("error" in parsed) return parsed.error; const { id } = parsed.data; - const result = await deleteIssue(id); - if (result.rowCount === 0) { + const issue = await getIssueByID(id); + if (!issue) { return errorResponse(`no issue with id ${id} found`, "ISSUE_NOT_FOUND", 404); } + const project = await getProjectByID(issue.projectId); + if (!project) { + return errorResponse("project not found", "PROJECT_NOT_FOUND", 404); + } + + const requesterMember = await getOrganisationMemberRole(project.organisationId, req.userId); + if (!requesterMember) { + return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403); + } + if (requesterMember.role !== "owner" && requesterMember.role !== "admin") { + return errorResponse( + "only organisation owners and admins can delete issues", + "PERMISSION_DENIED", + 403, + ); + } + + await deleteIssue(id); + return Response.json({ success: true }); } diff --git a/packages/backend/src/routes/issue/update.ts b/packages/backend/src/routes/issue/update.ts index 21e9b21..8484c7d 100644 --- a/packages/backend/src/routes/issue/update.ts +++ b/packages/backend/src/routes/issue/update.ts @@ -1,9 +1,15 @@ import { type IssueRecord, IssueUpdateRequestSchema } from "@sprint/shared"; -import type { BunRequest } from "bun"; -import { getIssueByID, setIssueAssignees, updateIssue } from "../../db/queries"; +import type { AuthedRequest } from "../../auth/middleware"; +import { + getIssueByID, + getOrganisationMemberRole, + getProjectByID, + setIssueAssignees, + updateIssue, +} from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; -export default async function issueUpdate(req: BunRequest) { +export default async function issueUpdate(req: AuthedRequest) { const parsed = await parseJsonBody(req, IssueUpdateRequestSchema); if ("error" in parsed) return parsed.error; @@ -23,7 +29,25 @@ export default async function issueUpdate(req: BunRequest) { const hasIssueFieldUpdates = title !== undefined || description !== undefined || status !== undefined || sprintId !== undefined; - let issue: IssueRecord | undefined; + const existingIssue = await getIssueByID(id); + if (!existingIssue) { + return errorResponse(`issue not found: ${id}`, "ISSUE_NOT_FOUND", 404); + } + + const project = await getProjectByID(existingIssue.projectId); + if (!project) { + return errorResponse("project not found", "PROJECT_NOT_FOUND", 404); + } + + const requesterMember = await getOrganisationMemberRole(project.organisationId, req.userId); + if (!requesterMember) { + return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403); + } + if (requesterMember.role !== "owner" && requesterMember.role !== "admin") { + return errorResponse("only organisation owners and admins can edit issues", "PERMISSION_DENIED", 403); + } + + let issue: IssueRecord | undefined = existingIssue; if (hasIssueFieldUpdates) { [issue] = await updateIssue(id, { title, @@ -31,8 +55,6 @@ export default async function issueUpdate(req: BunRequest) { sprintId, status, }); - } else { - issue = await getIssueByID(id); } if (assigneeIds !== undefined) { diff --git a/packages/backend/src/routes/project/create.ts b/packages/backend/src/routes/project/create.ts index 732330c..5fcfd25 100644 --- a/packages/backend/src/routes/project/create.ts +++ b/packages/backend/src/routes/project/create.ts @@ -1,6 +1,6 @@ import { ProjectCreateRequestSchema } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; -import { createProject, getProjectByKey, getUserById } from "../../db/queries"; +import { createProject, getOrganisationMemberRole, getProjectByKey, getUserById } from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function projectCreate(req: AuthedRequest) { @@ -14,6 +14,14 @@ export default async function projectCreate(req: AuthedRequest) { return errorResponse(`project with key ${key} already exists in this organisation`, "KEY_TAKEN", 400); } + const membership = await getOrganisationMemberRole(organisationId, req.userId); + if (!membership) { + return errorResponse("not a member of this organisation", "NOT_MEMBER", 403); + } + if (membership.role !== "owner" && membership.role !== "admin") { + return errorResponse("only owners and admins can create projects", "PERMISSION_DENIED", 403); + } + const creator = await getUserById(req.userId); if (!creator) { return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404); diff --git a/packages/backend/src/routes/timer/toggle.ts b/packages/backend/src/routes/timer/toggle.ts index bf852b0..f1704b4 100644 --- a/packages/backend/src/routes/timer/toggle.ts +++ b/packages/backend/src/routes/timer/toggle.ts @@ -10,6 +10,10 @@ import { createTimedSession, getActiveTimedSession, getIssueAssigneeCount, + getIssueByID, + getOrganisationMemberRole, + getProjectByID, + isIssueAssignee, } from "../../db/queries"; import { parseJsonBody } from "../../validation"; @@ -19,6 +23,35 @@ export default async function timerToggle(req: AuthedRequest) { const { issueId } = parsed.data; + const issue = await getIssueByID(issueId); + if (!issue) { + return Response.json( + { error: `issue not found: ${issueId}`, code: "ISSUE_NOT_FOUND" }, + { status: 404 }, + ); + } + + const project = await getProjectByID(issue.projectId); + if (!project) { + return Response.json({ error: "project not found", code: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + const membership = await getOrganisationMemberRole(project.organisationId, req.userId); + if (!membership) { + return Response.json( + { error: "you are not a member of this organisation", code: "NOT_MEMBER" }, + { status: 403 }, + ); + } + + const isAssigned = await isIssueAssignee(issueId, req.userId); + if (!isAssigned) { + return Response.json( + { error: "you must be assigned to this issue", code: "NOT_ASSIGNEE" }, + { status: 403 }, + ); + } + const assigneeCount = await getIssueAssigneeCount(issueId); if (assigneeCount > 1) { return Response.json( diff --git a/packages/frontend/src/components/top-bar.tsx b/packages/frontend/src/components/top-bar.tsx index 1e8fa26..fb90faa 100644 --- a/packages/frontend/src/components/top-bar.tsx +++ b/packages/frontend/src/components/top-bar.tsx @@ -2,7 +2,6 @@ import { useMemo } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import Account from "@/components/account"; import { IssueForm } from "@/components/issue-form"; -import { SprintForm } from "@/components/sprint-form"; import LogOutButton from "@/components/log-out-button"; import OrgIcon from "@/components/org-icon"; import { OrganisationSelect } from "@/components/organisation-select"; @@ -12,6 +11,7 @@ import { useSelection } from "@/components/selection-provider"; import { ServerConfiguration } from "@/components/server-configuration"; import { useAuthenticatedSession } from "@/components/session-provider"; import SmallUserDisplay from "@/components/small-user-display"; +import { SprintForm } from "@/components/sprint-form"; import { Button } from "@/components/ui/button"; import { DropdownMenu, diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index f86b784..26733a0 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { - ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_COMMENT_MAX_LENGTH, + ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_STATUS_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH, ORG_DESCRIPTION_MAX_LENGTH, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0786ac8..082d702 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,13 +1,13 @@ export type { ApiError, AuthResponse, - IssueCreateRequest, IssueCommentCreateRequest, IssueCommentDeleteRequest, + IssueCommentResponseType, IssueCommentsByIssueQuery, + IssueCreateRequest, IssueDeleteRequest, IssueResponseType, - IssueCommentResponseType, IssuesByProjectQuery, IssuesReplaceStatusRequest, IssuesStatusCountQuery, @@ -50,13 +50,13 @@ export type { export { ApiErrorSchema, AuthResponseSchema, - IssueCreateRequestSchema, IssueCommentCreateRequestSchema, IssueCommentDeleteRequestSchema, - IssueCommentsByIssueQuerySchema, - IssueDeleteRequestSchema, - IssueCommentResponseSchema, IssueCommentRecordSchema, + IssueCommentResponseSchema, + IssueCommentsByIssueQuerySchema, + IssueCreateRequestSchema, + IssueDeleteRequestSchema, IssueRecordSchema, IssueResponseSchema, IssuesByProjectQuerySchema, @@ -101,8 +101,8 @@ export { UserUpdateRequestSchema, } from "./api-schemas"; export { - ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_COMMENT_MAX_LENGTH, + ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_STATUS_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH, ORG_DESCRIPTION_MAX_LENGTH, diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 948bef0..7980f6d 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -2,8 +2,8 @@ import { integer, json, pgTable, timestamp, uniqueIndex, varchar } from "drizzle import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import type { z } from "zod"; import { - ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_COMMENT_MAX_LENGTH, + ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_STATUS_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH, ORG_DESCRIPTION_MAX_LENGTH,