diff --git a/packages/backend/prisma/migrations/20260308000000_add_instance_admin_field/migration.sql b/packages/backend/prisma/migrations/20260308000000_add_instance_admin_field/migration.sql new file mode 100644 index 0000000..b8314fb --- /dev/null +++ b/packages/backend/prisma/migrations/20260308000000_add_instance_admin_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "isInstanceAdmin" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 6cfc79b..efbadda 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -18,9 +18,10 @@ model User { jobTitle String? @db.VarChar(100) phone String? @db.VarChar(30) timezone String? @db.VarChar(50) - emailVerified Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + emailVerified Boolean @default(false) + isInstanceAdmin Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt workspaces WorkspaceMember[] projectMemberships ProjectMember[] diff --git a/packages/backend/prisma/seed.ts b/packages/backend/prisma/seed.ts index 9e39a38..b9f5a11 100644 --- a/packages/backend/prisma/seed.ts +++ b/packages/backend/prisma/seed.ts @@ -60,6 +60,7 @@ async function main() { passwordHash, displayName: "Alice Johnson", emailVerified: true, + isInstanceAdmin: true, }, }), prisma.user.create({ diff --git a/packages/backend/src/middleware/instance-admin.ts b/packages/backend/src/middleware/instance-admin.ts index c6ddae4..9db37ab 100644 --- a/packages/backend/src/middleware/instance-admin.ts +++ b/packages/backend/src/middleware/instance-admin.ts @@ -9,18 +9,12 @@ export async function instanceAdmin( throw new ForbiddenError("Authentication required"); } - const adminMembership = await request.server.prisma.workspaceMember.findFirst( - { - where: { - userId: request.user.id, - role: "ADMIN", - }, - }, - ); + const user = await request.server.prisma.user.findUnique({ + where: { id: request.user.id }, + select: { isInstanceAdmin: true }, + }); - if (!adminMembership) { - throw new ForbiddenError( - "Instance admin access required (must be admin of at least one workspace)", - ); + if (!user?.isInstanceAdmin) { + throw new ForbiddenError("Instance admin access required"); } } diff --git a/packages/backend/src/modules/auth/auth.service.ts b/packages/backend/src/modules/auth/auth.service.ts index 47cd528..d9be35b 100644 --- a/packages/backend/src/modules/auth/auth.service.ts +++ b/packages/backend/src/modules/auth/auth.service.ts @@ -105,12 +105,17 @@ export async function register( const passwordHash = await hashPassword(data.password); + // First user becomes instance admin + const userCount = await prisma.user.count(); + const isFirstUser = userCount === 0; + const user = await prisma.user.create({ data: { email: data.email.toLowerCase(), passwordHash, displayName: data.displayName, emailVerified: false, + isInstanceAdmin: isFirstUser, }, select: { id: true, @@ -143,8 +148,8 @@ export async function register( }, }); - // Send verification email (fire-and-forget) - sendVerificationEmail(user.email, verificationToken); + // Send verification email + await sendVerificationEmail(user.email, verificationToken); // Handle invite token if provided if (data.inviteToken) { @@ -386,7 +391,7 @@ export async function forgotPassword(prisma: PrismaClient, email: string) { }, }); - sendPasswordResetEmail(user.email, token); + await sendPasswordResetEmail(user.email, token); } export async function resetPassword( @@ -506,7 +511,7 @@ export async function resendVerification( }, }); - sendVerificationEmail(user.email, token); + await sendVerificationEmail(user.email, token); return { message: "Verification email sent." }; } diff --git a/packages/backend/src/modules/auth/oidc/oidc.controller.ts b/packages/backend/src/modules/auth/oidc/oidc.controller.ts index 1a0f45f..7936e0f 100644 --- a/packages/backend/src/modules/auth/oidc/oidc.controller.ts +++ b/packages/backend/src/modules/auth/oidc/oidc.controller.ts @@ -2,6 +2,17 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import * as oidcService from "./oidc.service.js"; import { env } from "../../../config/env.js"; +function sanitizeReturnUrl(raw: string | undefined): string { + if (!raw) return "/"; + // Check both raw and decoded values + for (const value of [raw, decodeURIComponent(raw)]) { + if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\") || value.includes("\0")) { + return "/"; + } + } + return raw; +} + export async function oidcLoginHandler( request: FastifyRequest, reply: FastifyReply, @@ -12,10 +23,11 @@ export async function oidcLoginHandler( }; try { + const returnUrl = sanitizeReturnUrl(query.return_url); const authUrl = await oidcService.initiateOidcLogin( request.server.prisma, query.connector_id, - query.return_url, + returnUrl, ); return reply.redirect(authUrl); @@ -71,11 +83,7 @@ export async function oidcCallbackHandler( }); // Redirect to the OIDC complete page - const returnUrl = result.returnUrl || "/"; - const safeReturnUrl = - returnUrl.startsWith("/") && !returnUrl.startsWith("//") - ? returnUrl - : "/"; + const safeReturnUrl = sanitizeReturnUrl(result.returnUrl ?? undefined); return reply.redirect( `${env.FRONTEND_URL}/auth/oidc/complete?return_url=${encodeURIComponent(safeReturnUrl)}`, ); diff --git a/packages/backend/src/modules/backup/backup-types.ts b/packages/backend/src/modules/backup/backup-types.ts new file mode 100644 index 0000000..68a36d0 --- /dev/null +++ b/packages/backend/src/modules/backup/backup-types.ts @@ -0,0 +1,23 @@ +import { CustomFieldType, Priority } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; + +const CUSTOM_FIELD_TYPES = new Set(Object.values(CustomFieldType)); +const PRIORITIES = new Set(Object.values(Priority)); + +export function toCustomFieldType(value: string): CustomFieldType { + if (!CUSTOM_FIELD_TYPES.has(value)) { + throw new Error(`Invalid custom field type: ${value}`); + } + return value as CustomFieldType; +} + +export function toPriority(value: string): Priority { + if (!PRIORITIES.has(value)) { + throw new Error(`Invalid priority: ${value}`); + } + return value as Priority; +} + +export function toJsonValue(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} diff --git a/packages/backend/src/modules/backup/backup.service.ts b/packages/backend/src/modules/backup/backup.service.ts index d978488..1af45d3 100644 --- a/packages/backend/src/modules/backup/backup.service.ts +++ b/packages/backend/src/modules/backup/backup.service.ts @@ -226,8 +226,9 @@ export async function exportBackup( messages = dbMessages.map((m) => { userIdSet.add(m.userId); - const reactions = (m as any).reactions - ? (m as any).reactions.map((r: any) => { + const mWithReactions = m as typeof m & { reactions?: Array<{ userId: string; emoji: string }> }; + const reactions = mWithReactions.reactions + ? mWithReactions.reactions.map((r) => { userIdSet.add(r.userId); return { userRef: r.userId, emoji: r.emoji }; }) @@ -277,8 +278,19 @@ export async function exportBackup( const pages = dbPages.map((p) => { if (p.createdById) userIdSet.add(p.createdById); - const comments = (p as any).comments - ? (p as any).comments.map((c: any) => { + type WikiComment = { + id: string; + body: string; + authorId: string; + resolved: boolean; + highlightId: string; + selectionStart: unknown; + selectionEnd: unknown; + createdAt: Date; + }; + const pWithComments = p as typeof p & { comments?: WikiComment[] }; + const comments = pWithComments.comments + ? pWithComments.comments.map((c) => { if (c.authorId) userIdSet.add(c.authorId); return { _originalId: c.id, diff --git a/packages/backend/src/modules/backup/restore.service.ts b/packages/backend/src/modules/backup/restore.service.ts index 3ad400b..872ea1c 100644 --- a/packages/backend/src/modules/backup/restore.service.ts +++ b/packages/backend/src/modules/backup/restore.service.ts @@ -6,6 +6,7 @@ import type { StorageProvider } from "../../services/storage.js"; import { IdMapper, UserMapper } from "./id-mapper.js"; import { ValidationError } from "../../utils/errors.js"; import { rewriteUrls } from "./backup-assets.js"; +import { toCustomFieldType, toPriority, toJsonValue } from "./backup-types.js"; /** Parse and validate a backup JSON buffer. */ function parseBackupFile(buffer: Buffer): BackupFile { @@ -242,8 +243,8 @@ export async function executeRestore( data: { projectId: newProject.id, name: f.name, - type: f.type as any, - options: f.options as any, + type: toCustomFieldType(f.type), + options: toJsonValue(f.options), required: f.required, position: f.position, }, @@ -284,7 +285,7 @@ export async function executeRestore( statusId, title: t.title, description: t.description, - priority: t.priority as any, + priority: toPriority(t.priority), position: t.position, dueDate: t.dueDate, startDate: t.startDate, @@ -337,7 +338,7 @@ export async function executeRestore( data: { taskId: newTask.id, fieldId, - value: v.value as any, + value: toJsonValue(v.value), }, }).catch(() => {}); } @@ -362,7 +363,7 @@ export async function executeRestore( data: { taskId: newTask.id, action: a.action, - details: a.details as any, + details: toJsonValue(a.details), actorId: userMapper.resolveOptional(a.actorRef), createdAt: new Date(a.createdAt), }, @@ -510,7 +511,7 @@ export async function executeRestore( data: { spaceId: newSpace.id, title: p.title, - content: content as any, + content: toJsonValue(content), icon: p.icon, position: p.position, parentId: null, @@ -529,8 +530,8 @@ export async function executeRestore( body: c.body, resolved: c.resolved, highlightId: c.highlightId, - selectionStart: c.selectionStart as any, - selectionEnd: c.selectionEnd as any, + selectionStart: toJsonValue(c.selectionStart), + selectionEnd: toJsonValue(c.selectionEnd), createdAt: new Date(c.createdAt), }, }); diff --git a/packages/backend/src/modules/workspaces/workspaces.routes.ts b/packages/backend/src/modules/workspaces/workspaces.routes.ts index dc3603a..e878ba3 100644 --- a/packages/backend/src/modules/workspaces/workspaces.routes.ts +++ b/packages/backend/src/modules/workspaces/workspaces.routes.ts @@ -32,6 +32,7 @@ export default async function workspacesRoutes(fastify: FastifyInstance) { fastify.route({ method: "POST", url: "/", + config: { rateLimit: { max: 5, timeWindow: "1 minute" } }, preHandler: [authenticate, validate(CreateWorkspaceSchema)], handler: createWorkspaceHandler, }); diff --git a/packages/backend/src/plugins/static.ts b/packages/backend/src/plugins/static.ts index e98991f..5835c65 100644 --- a/packages/backend/src/plugins/static.ts +++ b/packages/backend/src/plugins/static.ts @@ -4,6 +4,8 @@ import type { FastifyInstance } from "fastify"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { env } from "../config/env.js"; +import { authenticate } from "../middleware/authenticate.js"; +import { ForbiddenError } from "../utils/errors.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -27,9 +29,25 @@ export default fp(async (fastify: FastifyInstance) => { // Workspace-scoped uploads fastify.get<{ Params: { workspaceId: string; '*': string } }>( '/storage/:workspaceId/uploads/*', + { preHandler: [authenticate] }, async (request, reply) => { const { workspaceId } = request.params; const subPath = request.params['*']; + + // Path traversal protection + const resolved = path.resolve(storageDir, workspaceId, 'uploads', subPath); + if (!resolved.startsWith(storageDir + path.sep)) { + throw new ForbiddenError("Invalid file path"); + } + + // Workspace membership check + const membership = await fastify.prisma.workspaceMember.findUnique({ + where: { userId_workspaceId: { userId: request.user!.id, workspaceId } }, + }); + if (!membership) { + throw new ForbiddenError("Not a member of this workspace"); + } + return reply.sendFile( path.join(workspaceId, 'uploads', subPath), storageDir, diff --git a/packages/backend/src/services/storage.ts b/packages/backend/src/services/storage.ts index c4dda0d..1c8240d 100644 --- a/packages/backend/src/services/storage.ts +++ b/packages/backend/src/services/storage.ts @@ -32,6 +32,14 @@ export class LocalStorageProvider implements StorageProvider { this.rootDir = path.resolve(rootDir); } + private safePath(storagePath: string): string { + const fullPath = path.resolve(this.rootDir, storagePath); + if (!fullPath.startsWith(this.rootDir + path.sep) && fullPath !== this.rootDir) { + throw new Error("Path traversal detected"); + } + return fullPath; + } + private buildPath( category: string, scopeId: string, @@ -118,24 +126,24 @@ export class LocalStorageProvider implements StorageProvider { } async read(storagePath: string): Promise { - const fullPath = path.join(this.rootDir, storagePath); + const fullPath = this.safePath(storagePath); return fs.readFile(fullPath); } readStream(storagePath: string): fss.ReadStream { - const fullPath = path.join(this.rootDir, storagePath); + const fullPath = this.safePath(storagePath); return fss.createReadStream(fullPath); } async delete(storagePath: string): Promise { - const fullPath = path.join(this.rootDir, storagePath); + const fullPath = this.safePath(storagePath); await fs.unlink(fullPath).catch(() => { // Ignore if file already deleted }); } async exists(storagePath: string): Promise { - const fullPath = path.join(this.rootDir, storagePath); + const fullPath = this.safePath(storagePath); try { await fs.access(fullPath); return true; diff --git a/packages/frontend/src/features/wiki/hooks/use-page-lock.ts b/packages/frontend/src/features/wiki/hooks/use-page-lock.ts index dbc4b85..1ea3534 100644 --- a/packages/frontend/src/features/wiki/hooks/use-page-lock.ts +++ b/packages/frontend/src/features/wiki/hooks/use-page-lock.ts @@ -39,17 +39,22 @@ export function usePageLock(pageId: string) { releaseLock.mutate(); }, [releaseLock]); + const releaseLockRef = useRef(releaseLock); + const isLockedByMeRef = useRef(isLockedByMe); + useEffect(() => { releaseLockRef.current = releaseLock; }, [releaseLock]); + useEffect(() => { isLockedByMeRef.current = isLockedByMe; }, [isLockedByMe]); + // Cleanup on unmount useEffect(() => { return () => { if (heartbeatInterval.current) { clearInterval(heartbeatInterval.current); } - if (isLockedByMe) { - releaseLock.mutate(); + if (isLockedByMeRef.current) { + releaseLockRef.current.mutate(); } }; - }, [pageId]); // eslint-disable-line react-hooks/exhaustive-deps + }, [pageId]); return { lock,