patched security holes

This commit is contained in:
Oliver Bryan
2026-01-21 22:44:57 +00:00
parent db0be8330e
commit be57b4d6df
11 changed files with 129 additions and 23 deletions

View File

@@ -35,6 +35,10 @@ export const withAuth = <T extends BunRequest>(handler: AuthedRouteHandler<T>):
return new Response("Session expired", { status: 401 }); return new Response("Session expired", { status: 401 });
} }
if (session.userId !== userId) {
return new Response("Invalid session", { status: 401 });
}
return handler( return handler(
Object.assign(req, { Object.assign(req, {
userId, userId,

View File

@@ -190,3 +190,11 @@ export async function getIssueAssigneeCount(issueId: number): Promise<number> {
.where(eq(IssueAssignee.issueId, issueId)); .where(eq(IssueAssignee.issueId, issueId));
return result?.count ?? 0; return result?.count ?? 0;
} }
export async function isIssueAssignee(issueId: number, userId: number): Promise<boolean> {
const [assignee] = await db
.select({ id: IssueAssignee.id })
.from(IssueAssignee)
.where(and(eq(IssueAssignee.issueId, issueId), eq(IssueAssignee.userId, userId)));
return Boolean(assignee);
}

View File

@@ -1,6 +1,6 @@
import { IssueCreateRequestSchema } from "@sprint/shared"; import { IssueCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { createIssue, getProjectByID } from "../../db/queries"; import { createIssue, getOrganisationMemberRole, getProjectByID } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function issueCreate(req: AuthedRequest) { 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); 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( const issue = await createIssue(
project.id, project.id,
title, title,

View File

@@ -1,18 +1,37 @@
import { IssueDeleteRequestSchema } from "@sprint/shared"; import { IssueDeleteRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun"; import type { AuthedRequest } from "../../auth/middleware";
import { deleteIssue } from "../../db/queries"; import { deleteIssue, getIssueByID, getOrganisationMemberRole, getProjectByID } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; 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); const parsed = await parseJsonBody(req, IssueDeleteRequestSchema);
if ("error" in parsed) return parsed.error; if ("error" in parsed) return parsed.error;
const { id } = parsed.data; const { id } = parsed.data;
const result = await deleteIssue(id); const issue = await getIssueByID(id);
if (result.rowCount === 0) { if (!issue) {
return errorResponse(`no issue with id ${id} found`, "ISSUE_NOT_FOUND", 404); 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 }); return Response.json({ success: true });
} }

View File

@@ -1,9 +1,15 @@
import { type IssueRecord, IssueUpdateRequestSchema } from "@sprint/shared"; import { type IssueRecord, IssueUpdateRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun"; import type { AuthedRequest } from "../../auth/middleware";
import { getIssueByID, setIssueAssignees, updateIssue } from "../../db/queries"; import {
getIssueByID,
getOrganisationMemberRole,
getProjectByID,
setIssueAssignees,
updateIssue,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; 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); const parsed = await parseJsonBody(req, IssueUpdateRequestSchema);
if ("error" in parsed) return parsed.error; if ("error" in parsed) return parsed.error;
@@ -23,7 +29,25 @@ export default async function issueUpdate(req: BunRequest) {
const hasIssueFieldUpdates = const hasIssueFieldUpdates =
title !== undefined || description !== undefined || status !== undefined || sprintId !== undefined; 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) { if (hasIssueFieldUpdates) {
[issue] = await updateIssue(id, { [issue] = await updateIssue(id, {
title, title,
@@ -31,8 +55,6 @@ export default async function issueUpdate(req: BunRequest) {
sprintId, sprintId,
status, status,
}); });
} else {
issue = await getIssueByID(id);
} }
if (assigneeIds !== undefined) { if (assigneeIds !== undefined) {

View File

@@ -1,6 +1,6 @@
import { ProjectCreateRequestSchema } from "@sprint/shared"; import { ProjectCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; 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"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function projectCreate(req: AuthedRequest) { 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); 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); const creator = await getUserById(req.userId);
if (!creator) { if (!creator) {
return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404); return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404);

View File

@@ -10,6 +10,10 @@ import {
createTimedSession, createTimedSession,
getActiveTimedSession, getActiveTimedSession,
getIssueAssigneeCount, getIssueAssigneeCount,
getIssueByID,
getOrganisationMemberRole,
getProjectByID,
isIssueAssignee,
} from "../../db/queries"; } from "../../db/queries";
import { parseJsonBody } from "../../validation"; import { parseJsonBody } from "../../validation";
@@ -19,6 +23,35 @@ export default async function timerToggle(req: AuthedRequest) {
const { issueId } = parsed.data; 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); const assigneeCount = await getIssueAssigneeCount(issueId);
if (assigneeCount > 1) { if (assigneeCount > 1) {
return Response.json( return Response.json(

View File

@@ -2,7 +2,6 @@ import { useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import Account from "@/components/account"; import Account from "@/components/account";
import { IssueForm } from "@/components/issue-form"; import { IssueForm } from "@/components/issue-form";
import { SprintForm } from "@/components/sprint-form";
import LogOutButton from "@/components/log-out-button"; import LogOutButton from "@/components/log-out-button";
import OrgIcon from "@/components/org-icon"; import OrgIcon from "@/components/org-icon";
import { OrganisationSelect } from "@/components/organisation-select"; import { OrganisationSelect } from "@/components/organisation-select";
@@ -12,6 +11,7 @@ import { useSelection } from "@/components/selection-provider";
import { ServerConfiguration } from "@/components/server-configuration"; import { ServerConfiguration } from "@/components/server-configuration";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display"; import SmallUserDisplay from "@/components/small-user-display";
import { SprintForm } from "@/components/sprint-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,

View File

@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { import {
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_COMMENT_MAX_LENGTH, ISSUE_COMMENT_MAX_LENGTH,
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_STATUS_MAX_LENGTH, ISSUE_STATUS_MAX_LENGTH,
ISSUE_TITLE_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH,
ORG_DESCRIPTION_MAX_LENGTH, ORG_DESCRIPTION_MAX_LENGTH,

View File

@@ -1,13 +1,13 @@
export type { export type {
ApiError, ApiError,
AuthResponse, AuthResponse,
IssueCreateRequest,
IssueCommentCreateRequest, IssueCommentCreateRequest,
IssueCommentDeleteRequest, IssueCommentDeleteRequest,
IssueCommentResponseType,
IssueCommentsByIssueQuery, IssueCommentsByIssueQuery,
IssueCreateRequest,
IssueDeleteRequest, IssueDeleteRequest,
IssueResponseType, IssueResponseType,
IssueCommentResponseType,
IssuesByProjectQuery, IssuesByProjectQuery,
IssuesReplaceStatusRequest, IssuesReplaceStatusRequest,
IssuesStatusCountQuery, IssuesStatusCountQuery,
@@ -50,13 +50,13 @@ export type {
export { export {
ApiErrorSchema, ApiErrorSchema,
AuthResponseSchema, AuthResponseSchema,
IssueCreateRequestSchema,
IssueCommentCreateRequestSchema, IssueCommentCreateRequestSchema,
IssueCommentDeleteRequestSchema, IssueCommentDeleteRequestSchema,
IssueCommentsByIssueQuerySchema,
IssueDeleteRequestSchema,
IssueCommentResponseSchema,
IssueCommentRecordSchema, IssueCommentRecordSchema,
IssueCommentResponseSchema,
IssueCommentsByIssueQuerySchema,
IssueCreateRequestSchema,
IssueDeleteRequestSchema,
IssueRecordSchema, IssueRecordSchema,
IssueResponseSchema, IssueResponseSchema,
IssuesByProjectQuerySchema, IssuesByProjectQuerySchema,
@@ -101,8 +101,8 @@ export {
UserUpdateRequestSchema, UserUpdateRequestSchema,
} from "./api-schemas"; } from "./api-schemas";
export { export {
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_COMMENT_MAX_LENGTH, ISSUE_COMMENT_MAX_LENGTH,
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_STATUS_MAX_LENGTH, ISSUE_STATUS_MAX_LENGTH,
ISSUE_TITLE_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH,
ORG_DESCRIPTION_MAX_LENGTH, ORG_DESCRIPTION_MAX_LENGTH,

View File

@@ -2,8 +2,8 @@ import { integer, json, pgTable, timestamp, uniqueIndex, varchar } from "drizzle
import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import type { z } from "zod"; import type { z } from "zod";
import { import {
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_COMMENT_MAX_LENGTH, ISSUE_COMMENT_MAX_LENGTH,
ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_STATUS_MAX_LENGTH, ISSUE_STATUS_MAX_LENGTH,
ISSUE_TITLE_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH,
ORG_DESCRIPTION_MAX_LENGTH, ORG_DESCRIPTION_MAX_LENGTH,