mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 10:33:01 +00:00
patched security holes
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user