From 4ebc99152aa632bc1d7ed03930c87155601e846d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:40:52 +0000 Subject: [PATCH] fix: scope drawings cache by userId and add Socket.io authentication Security fixes: 1. Drawings cache now includes userId in cache key to prevent data leakage between users making identical queries. 2. Socket.io connections now require JWT authentication when auth is enabled. 3. Socket.io join-room verifies drawing ownership before allowing access. 4. Frontend passes auth token when connecting to Socket.io. Co-authored-by: ZimengXiong <83783148+ZimengXiong@users.noreply.github.com> --- backend/package-lock.json | 7 +++ backend/src/index.ts | 83 ++++++++++++++++++++++++++++++++++- frontend/package-lock.json | 33 +++++++++----- frontend/src/pages/Editor.tsx | 2 + 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 0cb3700..66160ea 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1164,6 +1164,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2654,6 +2655,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.0.tgz", "integrity": "sha512-XdpJDLxfztVY59X0zPI6sibRiGcxhTPXRD3IhJmjKf2jwMvkRGV1j7loB8U+heeamoU3XvihAaGRTR4aXXUN3A==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4088,6 +4090,7 @@ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -5100,6 +5103,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5258,6 +5262,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5348,6 +5353,7 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5441,6 +5447,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/backend/src/index.ts b/backend/src/index.ts index 5e90fa8..b5c7e0c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,7 @@ import { getCsrfTokenHeader, getOriginFromReferer, } from "./security"; +import jwt from "jsonwebtoken"; import { config } from "./config"; import { requireAuth } from "./middleware/auth"; import { errorHandler, asyncHandler } from "./middleware/errorHandler"; @@ -176,11 +177,13 @@ type DrawingsCacheEntry = { body: Buffer; expiresAt: number }; const drawingsCache = new Map(); const buildDrawingsCacheKey = (keyParts: { + userId: string; searchTerm: string; collectionFilter: string; includeData: boolean; }) => JSON.stringify([ + keyParts.userId, keyParts.searchTerm, keyParts.collectionFilter, keyParts.includeData ? "full" : "summary", @@ -721,16 +724,92 @@ interface User { const roomUsers = new Map(); +// Track which authenticated user owns each socket for authorization checks +const socketUserMap = new Map(); + +/** + * Verify JWT from Socket.io auth and check if auth is required. + * When auth is disabled (single-user mode), all connections are allowed. + */ +const getSocketAuthUserId = async (token?: string): Promise => { + // Check if auth is enabled + const systemConfig = await prisma.systemConfig.findUnique({ + where: { id: "default" }, + select: { authEnabled: true }, + }); + + if (!systemConfig || !systemConfig.authEnabled) { + // Auth disabled: allow all connections (single-user / bootstrap mode) + return "bootstrap-admin"; + } + + // Auth enabled: require valid JWT + if (!token) return null; + + try { + const decoded = jwt.verify(token, config.jwtSecret) as Record; + if ( + typeof decoded.userId !== "string" || + typeof decoded.email !== "string" || + decoded.type !== "access" + ) { + return null; + } + + // Verify user is still active + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { id: true, isActive: true }, + }); + + if (!user || !user.isActive) return null; + return user.id; + } catch { + return null; + } +}; + +io.use(async (socket, next) => { + try { + const token = socket.handshake.auth?.token as string | undefined; + const userId = await getSocketAuthUserId(token); + + if (!userId) { + return next(new Error("Authentication required")); + } + + socketUserMap.set(socket.id, userId); + next(); + } catch { + next(new Error("Authentication failed")); + } +}); + io.on("connection", (socket) => { + const authenticatedUserId = socketUserMap.get(socket.id); + socket.on( "join-room", - ({ + async ({ drawingId, user, }: { drawingId: string; user: Omit; }) => { + // Verify the authenticated user owns this drawing + if (authenticatedUserId) { + const drawing = await prisma.drawing.findFirst({ + where: { id: drawingId, userId: authenticatedUserId }, + select: { id: true }, + }); + + if (!drawing) { + socket.emit("error", { message: "Drawing not found or access denied" }); + return; + } + } + const roomId = `drawing_${drawingId}`; socket.join(roomId); @@ -771,6 +850,7 @@ io.on("connection", (socket) => { ); socket.on("disconnect", () => { + socketUserMap.delete(socket.id); roomUsers.forEach((users, roomId) => { const index = users.findIndex((u) => u.socketId === socket.id); if (index !== -1) { @@ -840,6 +920,7 @@ app.get("/drawings", requireAuth, asyncHandler(async (req, res, next) => { : false; const cacheKey = buildDrawingsCacheKey({ + userId: req.user.id, searchTerm: searchTerm ?? "", collectionFilter: collectionFilterKey, includeData: shouldIncludeData, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6260a95..85dd990 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -162,6 +162,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -516,6 +517,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -559,6 +561,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2609,8 +2612,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2775,6 +2777,7 @@ "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2786,6 +2789,7 @@ "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" } @@ -2856,6 +2860,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -3219,6 +3224,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3269,7 +3275,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3499,6 +3504,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3834,6 +3840,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -4207,6 +4214,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -4446,8 +4454,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.1.6", @@ -4666,6 +4673,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5499,6 +5507,7 @@ "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20.0" }, @@ -5846,7 +5855,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6876,6 +6884,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", @@ -7041,7 +7050,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7057,7 +7065,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7113,6 +7120,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7125,6 +7133,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7138,8 +7147,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -7854,6 +7862,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7992,6 +8001,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8181,6 +8191,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -8274,6 +8285,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8600,6 +8612,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 7ead0e3..795d03d 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -267,9 +267,11 @@ export const Editor: React.FC = () => { ? window.location.origin : (import.meta.env.VITE_API_URL || 'http://localhost:8000'); + const authToken = localStorage.getItem('excalidash-access-token'); const socket = io(socketUrl, { path: '/socket.io', transports: ['websocket', 'polling'], + auth: authToken ? { token: authToken } : {}, }); socketRef.current = socket;