From 857d5e6a8f52cb8cc70e0b03a6dcd167e08b9a1a Mon Sep 17 00:00:00 2001 From: Ashutosh Kasaudhan Date: Tue, 28 Jul 2026 16:53:42 +0000 Subject: [PATCH] backend: enforce ownership checks, fix auth bypass and lock down CORS/queue dashboard Also scopes MCP subprocess env to per-server keys and stops logging credentials. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/.env.example | 19 +++++ apps/backend/package.json | 1 + apps/backend/src/index.ts | 57 +++++++++---- apps/backend/src/modules/admin.ts | 37 +++++++++ apps/backend/src/modules/authz.ts | 85 +++++++++++++++++++ apps/backend/src/modules/chat.ts | 58 ++++++++++--- apps/backend/src/modules/design.ts | 33 ++++++-- apps/backend/src/modules/middleware.ts | 70 +++++++++++++--- apps/backend/src/modules/project.ts | 101 +++++++++++++---------- apps/backend/src/modules/question.ts | 65 +++++++++------ apps/backend/src/modules/run.ts | 23 ++++-- apps/backend/src/modules/sessions.ts | 10 +++ apps/backend/src/modules/user.helpers.ts | 3 +- apps/backend/src/modules/user.ts | 26 +++--- apps/frontend/src/lib/api.ts | 3 - apps/frontend/src/lib/sse.ts | 4 +- bun.lock | 1 + packages/agents/agent/MCPs/registry.ts | 30 +++++-- packages/agents/agent/utils/sb.test.ts | 13 +-- 19 files changed, 484 insertions(+), 155 deletions(-) create mode 100644 apps/backend/.env.example create mode 100644 apps/backend/src/modules/admin.ts create mode 100644 apps/backend/src/modules/authz.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..dd531bf --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,19 @@ +# Required — the server refuses to boot without these. +JWT_SECRET= +# Shared secret the agent worker uses for service-to-service calls +# (/internal/session/*, and the /api/* routes it reads/writes as a system caller). +INTERNAL_SERVICE_TOKEN= + +DATABASE_URL=postgresql://lovabledb:lovable@localhost:5432/lovablePostgres +REDIS_HOST=localhost +REDIS_PORT=6379 + +GOOGLE_CLIENT_ID= + +# Comma-separated browser origins allowed to call the API. +CORS_ORIGINS=http://localhost:5173 + +# Basic-auth credentials for the BullMQ dashboard at /admin/queues. +# Leave empty to not mount the dashboard at all. +ADMIN_DASHBOARD_USER= +ADMIN_DASHBOARD_PASSWORD= diff --git a/apps/backend/package.json b/apps/backend/package.json index 27221a6..4f0ab91 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -25,6 +25,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "bullmq": "^5.80.9", + "cors": "^2.8.5", "express-list-endpoints": "^7.1.1", "google-auth-library": "^10.9.0", "ioredis": "^5.11.1", diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index da74ed5..fe36bf1 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -7,16 +7,36 @@ import chatRouter from './modules/chat'; import designRouter from './modules/design'; import { questionRouter } from './modules/question'; import sessionRouter from './modules/sessions'; -import expressListEndpoints from "express-list-endpoints"; +import { assertAuthConfig } from './modules/middleware'; +import { adminDashboardAuth, isAdminDashboardEnabled } from './modules/admin'; import { createBullBoard } from '@bull-board/api'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; import { ExpressAdapter } from '@bull-board/express'; import { runQueue } from './modules/worker'; +import { logger } from './modules/utils'; + +assertAuthConfig(); const app = express(); -app.use(cors()) -app.use(express.json()) -console.log("Starting server") + +// Browsers may only call the API from origins we know about — CORS_ORIGINS is a +// comma-separated allowlist (dev default: the Vite dev server). +const allowedOrigins = (process.env.CORS_ORIGINS ?? "http://localhost:5173") + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + +app.use(cors({ + // Disallowed origins get a normal response without CORS headers (the + // browser blocks it) rather than a 500 from a thrown error. + origin(origin, callback) { + callback(null, !origin || allowedOrigins.includes(origin)); + }, + methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Authorization", "Content-Type"], +})) +app.use(express.json({ limit: process.env.JSON_BODY_LIMIT ?? "1mb" })) +logger.info("Starting server") app.use("/api/project", projectRouter); app.use("/api/run", runRouter); app.use("/api/user", userRouter); @@ -25,16 +45,23 @@ app.use("/api/design", designRouter) app.use("/api/question", questionRouter) app.use("/internal/session", sessionRouter) -const bullBoardAdapter = new ExpressAdapter(); -bullBoardAdapter.setBasePath("/admin/queues"); -createBullBoard({ - queues: [new BullMQAdapter(runQueue)], - serverAdapter: bullBoardAdapter, -}); -app.use("/admin/queues", bullBoardAdapter.getRouter()); +// The queue dashboard exposes every job's payload (prompts, user ids), so it +// only mounts when credentials are configured, and always behind basic auth. +if (isAdminDashboardEnabled()) { + const bullBoardAdapter = new ExpressAdapter(); + bullBoardAdapter.setBasePath("/admin/queues"); + createBullBoard({ + queues: [new BullMQAdapter(runQueue)], + serverAdapter: bullBoardAdapter, + }); + app.use("/admin/queues", adminDashboardAuth, bullBoardAdapter.getRouter()); +} else { + logger.warn("BullMQ dashboard disabled: set ADMIN_DASHBOARD_USER and ADMIN_DASHBOARD_PASSWORD to enable it"); +} -console.table(expressListEndpoints(questionRouter)); app.listen(3000, () =>{ - console.log("Server is running on port 3000") - console.log("BullMQ dashboard on http://localhost:3000/admin/queues") -}) \ No newline at end of file + logger.info("Server is running on port 3000") + if (isAdminDashboardEnabled()) { + logger.info("BullMQ dashboard on http://localhost:3000/admin/queues") + } +}) diff --git a/apps/backend/src/modules/admin.ts b/apps/backend/src/modules/admin.ts new file mode 100644 index 0000000..44e2e83 --- /dev/null +++ b/apps/backend/src/modules/admin.ts @@ -0,0 +1,37 @@ +import type { Request, Response, NextFunction } from "express"; +import { timingSafeEqual } from "node:crypto"; + +// The dashboard is browser-facing, so it uses HTTP basic auth (the browser can +// prompt for it) rather than the worker's bearer token. +export function isAdminDashboardEnabled(): boolean { + return Boolean(process.env.ADMIN_DASHBOARD_USER && process.env.ADMIN_DASHBOARD_PASSWORD); +} + +function safeEquals(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + return aBuf.length === bBuf.length && timingSafeEqual(aBuf, bBuf); +} + +export function adminDashboardAuth(req: Request, res: Response, next: NextFunction) { + const header = req.headers.authorization; + const expectedUser = process.env.ADMIN_DASHBOARD_USER; + const expectedPassword = process.env.ADMIN_DASHBOARD_PASSWORD; + + if (!expectedUser || !expectedPassword) { + return res.status(404).end(); + } + + if (header?.startsWith("Basic ")) { + const [user, ...passwordParts] = Buffer.from(header.slice("Basic ".length), "base64") + .toString("utf8") + .split(":"); + const password = passwordParts.join(":"); + if (user && safeEquals(user, expectedUser) && safeEquals(password, expectedPassword)) { + return next(); + } + } + + res.setHeader("WWW-Authenticate", 'Basic realm="queues"'); + return res.status(401).json({ message: "Unauthorized" }); +} diff --git a/apps/backend/src/modules/authz.ts b/apps/backend/src/modules/authz.ts new file mode 100644 index 0000000..ecbcedd --- /dev/null +++ b/apps/backend/src/modules/authz.ts @@ -0,0 +1,85 @@ +import type { Response } from "express"; +import { prisma } from "../prisma"; +import type { AuthRequest } from "./middleware"; + +// Ownership checks live here rather than being repeated per route so every +// project/run-scoped handler enforces the same rule: internal (worker) callers +// act on behalf of the system and pass, everyone else must own the project. +// +// Both helpers write the error response and return false when access is denied, +// so callers only need `if (!(await authorizeProject(...))) return`. + +export async function authorizeProject( + req: AuthRequest, + res: Response, + projectId: string, +): Promise { + if (req.isInternal) { + return true; + } + + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ success: false, message: "Unauthorized" }); + return false; + } + + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { userId: true }, + }); + + if (!project) { + res.status(404).json({ success: false, message: "Project not found" }); + return false; + } + if (project.userId !== userId) { + res.status(403).json({ success: false, message: "Forbidden" }); + return false; + } + + return true; +} + +export async function authorizeRun( + req: AuthRequest, + res: Response, + runId: string, +): Promise { + if (req.isInternal) { + return true; + } + + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ success: false, message: "Unauthorized" }); + return false; + } + + const run = await prisma.run.findUnique({ + where: { id: runId }, + select: { project: { select: { userId: true } } }, + }); + + if (!run) { + res.status(404).json({ success: false, message: "Run not found" }); + return false; + } + if (run.project.userId !== userId) { + res.status(403).json({ success: false, message: "Forbidden" }); + return false; + } + + return true; +} + +// Routes that create resources for the caller need a real user, never the +// worker's shared secret. +export function requireUserId(req: AuthRequest, res: Response): string | null { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ success: false, message: "Unauthorized" }); + return null; + } + return userId; +} diff --git a/apps/backend/src/modules/chat.ts b/apps/backend/src/modules/chat.ts index a2ae96b..d0f3fcd 100644 --- a/apps/backend/src/modules/chat.ts +++ b/apps/backend/src/modules/chat.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import type { Request, Response } from "express"; -import { auth } from "./middleware"; +import { auth, type AuthRequest } from "./middleware"; +import { authorizeProject, authorizeRun, requireUserId } from "./authz"; import { randomUUIDv7 } from "bun"; import { prisma } from "../prisma"; import { redis } from "./redis"; @@ -26,14 +27,29 @@ GET /chat/:projectId/history → all past runs' events, for reload chatRouter.post('/', auth, createRun) chatRouter.post('/:projectId', auth, createRun) -async function createRun(req: Request, res: Response){ - const userId = req.headers.userid +async function createRun(req: AuthRequest, res: Response){ + // Identity comes from the verified JWT, not a client-supplied header. + const userId = requireUserId(req, res) + if(!userId){ + return + } let projectId = req.params?.projectId - const userPrompt = req.body.userPrompt + const userPrompt = req.body?.userPrompt const existingSandboxId = req.body?.sandboxId - if(typeof userId !== 'string' || typeof userPrompt !== 'string'){ - return res.status(400).json({success: false, message: `Invalid userid or userPrompt`}) + if(typeof userPrompt !== 'string' || userPrompt.length === 0){ + return res.status(400).json({success: false, message: `Invalid userPrompt`}) + } + if(existingSandboxId !== undefined && existingSandboxId !== null && typeof existingSandboxId !== 'string'){ + return res.status(400).json({success: false, message: `Invalid sandboxId`}) + } + if(projectId !== undefined){ + if(typeof projectId !== 'string'){ + return res.status(400).json({success: false, message: `Invalid projectId`}) + } + if(!(await authorizeProject(req, res, projectId))){ + return + } } if(!projectId){ const project = await prisma.project.create({data: { @@ -91,11 +107,14 @@ async function createRun(req: Request, res: Response){ // Lets the frontend reconstruct a run's UI state from just the runId in the // URL (e.g. /w/:runId after a page refresh) — RunProvider's state otherwise // only lives in memory for the current tab. -chatRouter.get('/:runId/state', auth, async (req: Request, res: Response) => { +chatRouter.get('/:runId/state', auth, async (req: AuthRequest, res: Response) => { const { runId } = req.params if(typeof runId !== 'string'){ return res.status(400).json({success: false, message: `Invalid runId type`}) } + if(!(await authorizeRun(req, res, runId))){ + return + } const run = await prisma.run.findUnique({where: {id: runId}}) if(!run){ @@ -143,18 +162,27 @@ chatRouter.get('/:runId/state', auth, async (req: Request, res: Response) => { }) }) -chatRouter.post('/:projectId/:runId/continue', auth, async (req: Request, res: Response) => { - const userId = req.headers.userid +chatRouter.post('/:projectId/:runId/continue', auth, async (req: AuthRequest, res: Response) => { + const userId = requireUserId(req, res) + if(!userId){ + return + } const { projectId, runId } = req.params const answers: Answers[] = req.body?.answers ?? [] const selectedDesignId: string | undefined = req.body?.selectedDesignId - if(typeof userId !== 'string' || typeof projectId !== 'string' || typeof runId !== 'string'){ + if(typeof projectId !== 'string' || typeof runId !== 'string'){ return res.status(400).json({success: false, message: `Invalid params`}) } if(!Array.isArray(answers)){ return res.status(400).json({success: false, message: `answers must be an array (send [] if none)`}) } + if(selectedDesignId !== undefined && typeof selectedDesignId !== 'string'){ + return res.status(400).json({success: false, message: `Invalid selectedDesignId`}) + } + if(!(await authorizeProject(req, res, projectId))){ + return + } const run = await prisma.run.findFirst({where: {id: runId, projectId}}) if(!run){ @@ -196,11 +224,14 @@ chatRouter.post('/:projectId/:runId/continue', auth, async (req: Request, res: R }) // SSE frontend --> Backend -chatRouter.get('/:runId/stream', auth, async (req: Request, res: Response) =>{ +chatRouter.get('/:runId/stream', auth, async (req: AuthRequest, res: Response) =>{ const {runId} = req.params if(typeof runId !== 'string'){ return res.status(400).json({message: 'runId should be of string type'}) } + if(!(await authorizeRun(req, res, runId))){ + return + } // Validate + fetch before committing to SSE headers, so a bad/missing // runId (e.g. a stale reconnect after the run was deleted) gets a clean @@ -268,12 +299,15 @@ chatRouter.get('/:runId/stream', auth, async (req: Request, res: Response) =>{ }) -chatRouter.get('/:projectId/history', auth, async (req: Request, res: Response) =>{ +chatRouter.get('/:projectId/history', auth, async (req: AuthRequest, res: Response) =>{ const {projectId} = req.params if(typeof projectId !== 'string'){ return res.status(400).json({message: `Invalid projectId type`}) } + if(!(await authorizeProject(req, res, projectId))){ + return + } const runs = await prisma.run.findMany({ where: { projectId: projectId }, orderBy: { startedAt: 'desc' }, diff --git a/apps/backend/src/modules/design.ts b/apps/backend/src/modules/design.ts index 51e22cb..6c4c71d 100644 --- a/apps/backend/src/modules/design.ts +++ b/apps/backend/src/modules/design.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { prisma } from "../prisma"; -import { auth, internalAuth } from "./middleware"; +import { auth, internalAuth, type AuthRequest } from "./middleware"; +import { authorizeProject } from "./authz"; import type { Request, Response } from "express"; import { randomUUID } from "bullmq"; import { randomUUIDv5, randomUUIDv7 } from "bun"; @@ -27,9 +28,15 @@ designRouter.post("/:projectId", internalAuth, async( req: Request, res: Respons message: "Invalid projectId", }); } + if (!Array.isArray(designs) || designs.some((d) => typeof d !== "string")) { + return res.status(400).json({ + success: false, + message: "designs must be an array of strings", + }); + } let result try{ - console.log(`Saving designs to the db`) + logger.info(`Saving designs to the db`) result = await Promise.all(designs.map((design: string) => prisma.design.create({ data:{ @@ -50,7 +57,7 @@ designRouter.post("/:projectId", internalAuth, async( req: Request, res: Respons return res.status(201).json({success: true, data: result}) }) // get all designs -designRouter.get("/:projectId/getDesigns", auth, async (req: Request, res: Response) => { +designRouter.get("/:projectId/getDesigns", auth, async (req: AuthRequest, res: Response) => { const projectId = req.params.projectId; if (typeof projectId !== "string") { @@ -59,6 +66,9 @@ designRouter.get("/:projectId/getDesigns", auth, async (req: Request, res: Respo message: "Invalid projectId", }); } + if (!(await authorizeProject(req, res, projectId))) { + return; + } try { const designs = await prisma.design.findMany({ @@ -80,7 +90,7 @@ designRouter.get("/:projectId/getDesigns", auth, async (req: Request, res: Respo }); // get selected design -designRouter.get("/:projectId/selectedDesign", auth, async (req: Request, res: Response) => { +designRouter.get("/:projectId/selectedDesign", auth, async (req: AuthRequest, res: Response) => { const projectId = req.params.projectId; if (typeof projectId !== "string") { @@ -89,6 +99,9 @@ designRouter.get("/:projectId/selectedDesign", auth, async (req: Request, res: R message: "Invalid projectId", }); } + if (!(await authorizeProject(req, res, projectId))) { + return; + } try { const design = await prisma.design.findFirst({ @@ -118,7 +131,7 @@ designRouter.get("/:projectId/selectedDesign", auth, async (req: Request, res: R }); -designRouter.patch("/:projectId/designs/:designId", auth, async (req: Request, res: Response) => { +designRouter.patch("/:projectId/designs/:designId", auth, async (req: AuthRequest, res: Response) => { const { projectId, designId } = req.params; if ( @@ -130,6 +143,9 @@ designRouter.patch("/:projectId/designs/:designId", auth, async (req: Request, r message: "Invalid params", }); } + if (!(await authorizeProject(req, res, projectId))) { + return; + } try { const design = await prisma.design.findFirst({ @@ -177,9 +193,9 @@ designRouter.patch("/:projectId/designs/:designId", auth, async (req: Request, r } }); -designRouter.post("/:projectId/selectDesign", auth, async (req: Request, res: Response) => { +designRouter.post("/:projectId/selectDesign", auth, async (req: AuthRequest, res: Response) => { const { projectId } = req.params; - const { htmlContent } = req.body; + const { htmlContent } = req.body ?? {}; if (typeof projectId !== "string" || typeof htmlContent !== "string") { return res.status(400).json({ @@ -187,6 +203,9 @@ designRouter.post("/:projectId/selectDesign", auth, async (req: Request, res: Re message: "Invalid params", }); } + if (!(await authorizeProject(req, res, projectId))) { + return; + } try { const design = await prisma.design.findFirst({ diff --git a/apps/backend/src/modules/middleware.ts b/apps/backend/src/modules/middleware.ts index e048261..a23134d 100644 --- a/apps/backend/src/modules/middleware.ts +++ b/apps/backend/src/modules/middleware.ts @@ -1,4 +1,5 @@ import type { Request, Response, NextFunction } from "express"; +import { timingSafeEqual } from "node:crypto"; import jwt from "jsonwebtoken"; export interface AuthRequest extends Request { @@ -6,6 +7,35 @@ export interface AuthRequest extends Request { id: string; email: string; }; + // Set for trusted service-to-service calls (agent worker), which have no + // end user in context and therefore skip per-user ownership checks. + isInternal?: boolean; +} + +function constantTimeEquals(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + if (aBuf.length !== bBuf.length) { + return false; + } + return timingSafeEqual(aBuf, bBuf); +} + +function isInternalToken(token: string): boolean { + const expected = process.env.INTERNAL_SERVICE_TOKEN; + if (!expected) { + return false; + } + return constantTimeEquals(token, expected); +} + +function bearerToken(req: Request): string | undefined { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + return undefined; + } + const token = authHeader.slice("Bearer ".length).trim(); + return token.length > 0 ? token : undefined; } export function auth( @@ -13,25 +43,24 @@ export function auth( res: Response, next: NextFunction ) { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith("Bearer ")) { + const token = bearerToken(req); + if (!token) { return res.status(401).json({ message: "Unauthorized", }); } - const token = authHeader.split(" ")[1]; - // The agent worker calls these same /api/* routes as a trusted system // caller (no end user in context) — a shared secret instead of a JWT. - if (token === process.env.INTERNAL_SERVICE_TOKEN) { + if (isInternalToken(token)) { + req.isInternal = true; return next(); } try { const payload = jwt.verify( token, - process.env.JWT_SECRET! + requireJwtSecret() ) as { id: string; email: string; @@ -50,18 +79,37 @@ export function auth( // For service-to-service calls from the agent worker (session state/event // persistence) — a shared secret, not a user JWT. export function internalAuth( - req: Request, + req: AuthRequest, res: Response, next: NextFunction ) { - const authHeader = req.headers.authorization; - const token = authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined; + const token = bearerToken(req); - if (!token || token !== process.env.INTERNAL_SERVICE_TOKEN) { + if (!token || !isInternalToken(token)) { return res.status(401).json({ message: "Unauthorized", }); } + req.isInternal = true; next(); -} \ No newline at end of file +} + +export function requireJwtSecret(): string { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET is not configured"); + } + return secret; +} + +// Fail fast on boot rather than silently serving an unauthenticatable API +// (missing JWT_SECRET) or one whose internal routes can never be reached. +export function assertAuthConfig() { + const missing = ["JWT_SECRET", "INTERNAL_SERVICE_TOKEN"].filter( + (key) => !process.env[key], + ); + if (missing.length > 0) { + throw new Error(`Missing required auth env vars: ${missing.join(", ")}`); + } +} diff --git a/apps/backend/src/modules/project.ts b/apps/backend/src/modules/project.ts index 20d22db..de4ec58 100644 --- a/apps/backend/src/modules/project.ts +++ b/apps/backend/src/modules/project.ts @@ -1,7 +1,8 @@ import { Router } from "express"; import { prisma } from "../prisma"; import { auth, type AuthRequest } from "./middleware"; -import type { Request, Response } from "express"; +import { authorizeProject, requireUserId } from "./authz"; +import type { Response } from "express"; import { randomUUID } from "node:crypto"; import { R2 } from "../../../../packages/agents/agent/services/file-storage/fileStorage"; import { logger } from "./utils"; @@ -17,28 +18,26 @@ const projectRouter = Router(); const r2 = new R2(); projectRouter.get("/", auth, async (req: AuthRequest, res: Response) => { - const userId = req.user.id + const userId = requireUserId(req, res) if(!userId){ - - res.status(401).json({success: false, message: `UserId not given`}) + return } - await prisma.project.findMany({where: {userId: userId}}) const projects = await prisma.project.findMany({where: {userId: userId}}) - if(!projects){ - res.status(404).json({success: false, message: `Projects not found`}) - } res.status(200).json({success: true, data: projects}) }); -projectRouter.post("/", async (req: Request, res: Response) => { - - const userId = req.body.userId +projectRouter.post("/", auth, async (req: AuthRequest, res: Response) => { + // The owner comes from the verified token, never from the request body. + const userId = requireUserId(req, res) + if(!userId){ + return + } // I could give it a name but another LLM call happens, // rather do this: make this name field optional and // while generating summary of whole task, ask LLM for the title // and then update it. - const name = req.body.name + const name = typeof req.body?.name === "string" ? req.body.name : null try{ const saveIntoDB = await prisma.project.create({ @@ -53,28 +52,28 @@ projectRouter.post("/", async (req: Request, res: Response) => { } return res.status(201).json({success: true, message: `project created`, data: saveIntoDB}) }catch(e){ - return res.json(500).json({message: `Internal server error`}) + logger.error(`Failed to create project: ${e}`) + return res.status(500).json({success: false, message: `Internal server error`}) } }) -projectRouter.get("/:projectId", auth, async (req: Request, res: Response) => { +projectRouter.get("/:projectId", auth, async (req: AuthRequest, res: Response) => { const projectId = req.params.projectId - if(!projectId){ - - res.status(401).json({success: false, message: `UserId not given`}) - } if (typeof projectId !== "string") { return res.status(400).json({ success: false, message: "Invalid projectId", }); } - const projects = await prisma.project.findUniqueOrThrow({where: {id: projectId}}) + if(!(await authorizeProject(req, res, projectId))){ + return + } + const project = await prisma.project.findUniqueOrThrow({where: {id: projectId}}) - res.status(200).json({success: true, data: projects}) + res.status(200).json({success: true, data: project}) }); -projectRouter.patch('/:projectId', auth, async (req: Request, res: Response) =>{ +projectRouter.patch('/:projectId', auth, async (req: AuthRequest, res: Response) =>{ const projectId = req.params.projectId - const { name, archived, starred, isComplex } = req.body; + const { name, archived, starred, isComplex } = req.body ?? {}; const data: { name?: string; @@ -83,57 +82,70 @@ projectRouter.patch('/:projectId', auth, async (req: Request, res: Response) =>{ isComplex?: boolean; } = {}; + if (typeof projectId !== "string") { + return res.status(400).json({ + success: false, + message: "Invalid projectId", + }); + } + if (name !== undefined) { + if (typeof name !== "string") { + return res.status(400).json({success: false, message: "name must be a string"}) + } data.name = name; } if (archived !== undefined) { + if (typeof archived !== "boolean") { + return res.status(400).json({success: false, message: "archived must be a boolean"}) + } data.isArchived = archived; } if (starred !== undefined) { + if (typeof starred !== "boolean") { + return res.status(400).json({success: false, message: "starred must be a boolean"}) + } data.isStarred = starred; } if (isComplex !== undefined) { + if (typeof isComplex !== "boolean") { + return res.status(400).json({success: false, message: "isComplex must be a boolean"}) + } data.isComplex = isComplex; } - if (typeof projectId !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid projectId", - }); + if(!(await authorizeProject(req, res, projectId))){ + return } try{ - const project = await prisma.project.findUniqueOrThrow({where: {id: projectId}}) - - if(!project){ - return res.status(404).json({message: `Project not found`}) - } const dbUpdate = await prisma.project.update({where: {id: projectId}, data: data}) return res.status(200).json({success: true, data: dbUpdate}) } catch(e){ - return res.status(500).json({message: `Internal Server error`}) + logger.error(`Failed to update project ${projectId}: ${e}`) + return res.status(500).json({success: false, message: `Internal Server error`}) } }) -projectRouter.delete('/:projectId', auth, async (req: Request, res: Response) =>{ +projectRouter.delete('/:projectId', auth, async (req: AuthRequest, res: Response) =>{ const projectId = req.params.projectId - if(!projectId){ - - res.status(401).json({success: false, message: `UserId not given`}) - } if (typeof projectId !== "string") { return res.status(400).json({ success: false, message: "Invalid projectId", }); } - const dbUpdate = await prisma.project.delete({where: {id: projectId}}) - if(!dbUpdate){ - return res.send(500).json({success: false, message: `Failed to update DB`}) + if(!(await authorizeProject(req, res, projectId))){ + return + } + try{ + await prisma.project.delete({where: {id: projectId}}) + } catch(e){ + logger.error(`Failed to delete project ${projectId}: ${e}`) + return res.status(500).json({success: false, message: `Failed to update DB`}) } res.status(200).json({success: true}) }); @@ -142,20 +154,19 @@ projectRouter.delete('/:projectId', auth, async (req: Request, res: Response) => // reads the durable copy rather than reconnecting to a possibly-dead sandbox. projectRouter.get("/:projectId/files", auth, async (req: AuthRequest, res: Response) => { const { projectId } = req.params; - const userId = req.user.id; if (typeof projectId !== "string") { return res.status(400).json({ success: false, message: "Invalid projectId" }); } + if (!(await authorizeProject(req, res, projectId))) { + return; + } try { const project = await prisma.project.findUnique({ where: { id: projectId } }); if (!project) { return res.status(404).json({ success: false, message: "Project not found" }); } - if (project.userId !== userId) { - return res.status(403).json({ success: false, message: "Not your project" }); - } const prefix = r2.filesPrefix(project.userId, projectId); const keys = await r2.listFiles(prefix); diff --git a/apps/backend/src/modules/question.ts b/apps/backend/src/modules/question.ts index 9f07038..7b2b825 100644 --- a/apps/backend/src/modules/question.ts +++ b/apps/backend/src/modules/question.ts @@ -3,7 +3,8 @@ GET /projects/:projectId/questions */ import { Router } from "express"; import { prisma } from "../prisma"; -import { auth, internalAuth } from "./middleware"; +import { auth, internalAuth, type AuthRequest } from "./middleware"; +import { authorizeProject } from "./authz"; import type { Request, Response } from "express"; import { randomUUIDv7 } from "bun"; import { logger } from "./utils"; @@ -12,7 +13,7 @@ type IncomingQuestion = {question: string, option: string[]} export const questionRouter = Router(); -questionRouter.get('/:projectId/getQuestions', auth, async (req: Request, res: Response) =>{ +questionRouter.get('/:projectId/getQuestions', auth, async (req: AuthRequest, res: Response) =>{ const projectId = req.params.projectId if(typeof projectId !== 'string'){ return res.status(400).json({ @@ -20,6 +21,9 @@ questionRouter.get('/:projectId/getQuestions', auth, async (req: Request, res: R message: "Invalid projectId", }); } + if(!(await authorizeProject(req, res, projectId))){ + return + } try{ logger.info(`Fetching questions`) @@ -37,34 +41,43 @@ questionRouter.get('/:projectId/getQuestions', auth, async (req: Request, res: R questionRouter.post('/:projectId/:runId', internalAuth, async (req: Request, res: Response) =>{ const {projectId, runId} = req.params - const {questionsObj} = req.body + const {questionsObj} = req.body ?? {} if(typeof projectId !== 'string' || typeof runId !== 'string'){ return res.status(400).json({success: false, message: `Bad types of the params`}) } - const result = await Promise.all( - questionsObj.map((question: IncomingQuestion) => - prisma.question.create({ - data: { - id: randomUUIDv7(), - runId, - projectId, - question: question.question, - options: question.option, - createdAt: new Date(), - }, - }) - ) - ); - return res.status(201).json({ - success: true, - data: result - }); + if(!Array.isArray(questionsObj)){ + return res.status(400).json({success: false, message: `questionsObj must be an array`}) + } + + try{ + const result = await Promise.all( + questionsObj.map((question: IncomingQuestion) => + prisma.question.create({ + data: { + id: randomUUIDv7(), + runId, + projectId, + question: question.question, + options: question.option, + createdAt: new Date(), + }, + }) + ) + ); + return res.status(201).json({ + success: true, + data: result + }); + } catch(e){ + logger.error(`Error occurred while saving questions ${e}`) + return res.status(500).json({success: false, message: `Internal server error`}) + } }) -questionRouter.post('/:projectId/:runId/answers', auth, async (req: Request, res: Response) =>{ +questionRouter.post('/:projectId/:runId/answers', auth, async (req: AuthRequest, res: Response) =>{ const {projectId, runId} = req.params - const {answers} = req.body as {answers: {questionId: string, answer: string}[]} + const {answers} = (req.body ?? {}) as {answers: {questionId: string, answer: string}[]} if(typeof projectId !== 'string' || typeof runId !== 'string'){ return res.status(400).json({success: false, message: `Bad types of the params`}) @@ -72,6 +85,12 @@ questionRouter.post('/:projectId/:runId/answers', auth, async (req: Request, res if(!Array.isArray(answers) || answers.length === 0){ return res.status(400).json({success: false, message: `answers must be a non-empty array`}) } + if(answers.some((a) => typeof a?.questionId !== 'string' || typeof a?.answer !== 'string')){ + return res.status(400).json({success: false, message: `each answer needs a questionId and answer string`}) + } + if(!(await authorizeProject(req, res, projectId))){ + return + } try{ await Promise.all(answers.map(async ({questionId, answer}) => { diff --git a/apps/backend/src/modules/run.ts b/apps/backend/src/modules/run.ts index e65fd3a..ae615e8 100644 --- a/apps/backend/src/modules/run.ts +++ b/apps/backend/src/modules/run.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { prisma } from "../prisma"; -import { auth, internalAuth } from "./middleware"; +import { auth, internalAuth, type AuthRequest } from "./middleware"; +import { authorizeProject } from "./authz"; import type { Request, Response } from "express"; import { randomUUIDv7 } from "bun"; import { logger } from "./utils"; @@ -18,7 +19,7 @@ POST /projects/:projectId/:runId/todos/:taskId/summary → mark a Todo complet */ -runRouter.get("/:projectId/runs", auth, async (req: Request, res: Response) => { +runRouter.get("/:projectId/runs", auth, async (req: AuthRequest, res: Response) => { const projectId = req.params.projectId; if (typeof projectId !== "string") { @@ -27,6 +28,9 @@ runRouter.get("/:projectId/runs", auth, async (req: Request, res: Response) => { message: "Invalid projectId", }); } + if (!(await authorizeProject(req, res, projectId))) { + return; + } try { const runs = await prisma.run.findMany({ @@ -57,7 +61,7 @@ runRouter.get("/:projectId/runs", auth, async (req: Request, res: Response) => { }); -runRouter.get("/:projectId/runs/:runId", auth, async (req: Request, res: Response) => { +runRouter.get("/:projectId/runs/:runId", auth, async (req: AuthRequest, res: Response) => { const { projectId, runId } = req.params; if ( @@ -69,6 +73,9 @@ runRouter.get("/:projectId/runs/:runId", auth, async (req: Request, res: Respons message: "Invalid params", }); } + if (!(await authorizeProject(req, res, projectId))) { + return; + } try { const run = await prisma.run.findFirst({ @@ -98,7 +105,7 @@ runRouter.get("/:projectId/runs/:runId", auth, async (req: Request, res: Respons }); -runRouter.get("/:projectId/:runId/todos", auth, async (req: Request, res: Response) => { +runRouter.get("/:projectId/:runId/todos", auth, async (req: AuthRequest, res: Response) => { const { projectId, runId } = req.params; if ( typeof projectId !== "string" || @@ -109,6 +116,9 @@ runRouter.get("/:projectId/:runId/todos", auth, async (req: Request, res: Respon message: "Invalid params", }); } + if (!(await authorizeProject(req, res, projectId))) { + return; + } try { const run = await prisma.run.findFirst({ @@ -150,7 +160,7 @@ runRouter.get("/:projectId/:runId/todos", auth, async (req: Request, res: Respon }); -runRouter.get("/:projectId/:runId/summaries", auth, async (req: Request, res: Response) => { +runRouter.get("/:projectId/:runId/summaries", auth, async (req: AuthRequest, res: Response) => { const { projectId, runId } = req.params; if ( @@ -162,6 +172,9 @@ runRouter.get("/:projectId/:runId/summaries", auth, async (req: Request, res: Re message: "Invalid params", }); } + if (!(await authorizeProject(req, res, projectId))) { + return; + } try { const run = await prisma.run.findFirst({ diff --git a/apps/backend/src/modules/sessions.ts b/apps/backend/src/modules/sessions.ts index 609d051..d6178b7 100644 --- a/apps/backend/src/modules/sessions.ts +++ b/apps/backend/src/modules/sessions.ts @@ -19,6 +19,9 @@ sessionRouter.post('/:runId/events', internalAuth, async (req: Request, res: Res if(typeof runId !== 'string'){ return res.status(400).json({success: false, message: `Invalid runId type`}) } + if(typeof event?.type !== 'string'){ + return res.status(400).json({success: false, message: `Event needs a type`}) + } try{ await prisma.runEvent.create({data: { @@ -65,6 +68,13 @@ sessionRouter.post('/:runId/state', internalAuth, async (req: Request, res: Resp if(typeof runId !== 'string'){ return res.status(400).json({success: false, message: `Invalid runId type`}) } + if( + (context_snapshot !== undefined && typeof context_snapshot !== 'string') || + (session_snapshot !== undefined && typeof session_snapshot !== 'string') || + (iteration !== undefined && typeof iteration !== 'number') + ){ + return res.status(400).json({success: false, message: `Invalid snapshot payload`}) + } try{ await prisma.run.update({ diff --git a/apps/backend/src/modules/user.helpers.ts b/apps/backend/src/modules/user.helpers.ts index db8c310..97c6b96 100644 --- a/apps/backend/src/modules/user.helpers.ts +++ b/apps/backend/src/modules/user.helpers.ts @@ -1,4 +1,5 @@ import jwt from "jsonwebtoken"; +import { requireJwtSecret } from "./middleware"; export interface PublicUser { id: string; @@ -20,7 +21,7 @@ export function isValidPassword(password: unknown): password is string { export function signUserToken(user: { id: string; email: string }): string { return jwt.sign( { id: user.id, email: user.email }, - process.env.JWT_SECRET!, + requireJwtSecret(), { expiresIn: "7d" } ); } diff --git a/apps/backend/src/modules/user.ts b/apps/backend/src/modules/user.ts index e3bdfdc..a1b1f8e 100644 --- a/apps/backend/src/modules/user.ts +++ b/apps/backend/src/modules/user.ts @@ -14,17 +14,13 @@ POST /users/logout → stateless logout ack */ const userRouter = Router(); -console.log("User router initialized"); userRouter.post("/signup", async (req: Request, res: Response) => { - console.log("Signup request received"); const { email, password, name } = req.body ?? {}; if (!isValidEmail(email)) { - console.error(`Invalid email: ${email}`); return res.status(400).json({ success: false, message: "Invalid email" }); } if (!isValidPassword(password)) { - logger.error(`Password is not valid: ${password}`); return res.status(400).json({ success: false, message: "Password must be at least 8 characters", @@ -34,7 +30,6 @@ userRouter.post("/signup", async (req: Request, res: Response) => { try { const existing = await prisma.user.findUnique({ where: { email } }); if (existing) { - console.error(`Email already registered: ${email}`); return res.status(409).json({ success: false, message: "Email already registered" }); } const passwordHash = await Bun.password.hash(password); @@ -46,11 +41,11 @@ userRouter.post("/signup", async (req: Request, res: Response) => { }, }); const token = signUserToken(user); - - console.log("Token created"); + return res.status(201).json({ success: true, data: { token, user: toPublicUser(user) } }); } catch (e) { - return res.status(500).json({ success: false, message: `Internal server error: ${e}` }); + logger.error(`Signup failed: ${e}`); + return res.status(500).json({ success: false, message: "Internal server error" }); } }); @@ -83,6 +78,7 @@ userRouter.post("/login", async (req: Request, res: Response) => { .status(200) .json({ success: true, data: { token, user: toPublicUser(user) } }); } catch (e) { + logger.error(`Login failed: ${e}`); return res .status(500) .json({ success: false, message: "Internal server error" }); @@ -132,7 +128,7 @@ userRouter.post("/google", async (req: Request, res: Response) => { .status(200) .json({ success: true, data: { token, user: toPublicUser(user) } }); } catch (e) { - console.error("Google sign-in failed:", e); + logger.error(`Google sign-in failed: ${e}`); return res .status(401) .json({ success: false, message: "Invalid Google token" }); @@ -140,22 +136,22 @@ userRouter.post("/google", async (req: Request, res: Response) => { }); userRouter.get("/me", auth, async (req: AuthRequest, res: Response) => { - console.log("Getting user called"); + const userId = req.user?.id; + if (!userId) { + return res.status(401).json({ success: false, message: "Unauthorized" }); + } try { - console.log("Getting user"); const user = await prisma.user.findUnique({ - where: { id: req.user!.id }, + where: { id: userId }, }); if (!user) { return res .status(404) .json({ success: false, message: "User not found" }); } - console.log("User found"); - console.log(toPublicUser(user)); return res.status(200).json({ success: true, data: toPublicUser(user) }); } catch (e) { - console.error(`Error getting user: ${e}`); + logger.error(`Error getting user: ${e}`); return res .status(500) .json({ success: false, message: "Internal server error" }); diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 801ca77..bfb94c7 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -25,9 +25,6 @@ async function request(path: string, opts: RequestOptions = {}): Promise { const session = getStoredSession(); if (session) { finalHeaders.set("Authorization", `Bearer ${session.token}`); - // chat.ts's createRun reads the user id off a raw `userid` header rather - // than the JWT payload auth() already decoded — send both until that's unified. - finalHeaders.set("userid", session.user.id); } } diff --git a/apps/frontend/src/lib/sse.ts b/apps/frontend/src/lib/sse.ts index 107f5b2..736d77c 100644 --- a/apps/frontend/src/lib/sse.ts +++ b/apps/frontend/src/lib/sse.ts @@ -15,9 +15,7 @@ export function openEventStream(path: string, handlers: SSEHandlers): () => void (async () => { try { const res = await fetch(path, { - headers: session - ? { Authorization: `Bearer ${session.token}`, userid: session.user.id } - : {}, + headers: session ? { Authorization: `Bearer ${session.token}` } : {}, signal: controller.signal, }); if (!res.ok || !res.body) { diff --git a/bun.lock b/bun.lock index 5bc66de..8fcb9aa 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "bullmq": "^5.80.9", + "cors": "^2.8.5", "express-list-endpoints": "^7.1.1", "google-auth-library": "^10.9.0", "ioredis": "^5.11.1", diff --git a/packages/agents/agent/MCPs/registry.ts b/packages/agents/agent/MCPs/registry.ts index 9017d94..215fdfa 100644 --- a/packages/agents/agent/MCPs/registry.ts +++ b/packages/agents/agent/MCPs/registry.ts @@ -6,29 +6,43 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" type ServerConfig = { command: string, args: string[], - env?: string + // Names (not values) of the env vars this server needs — only these are + // forwarded, so a third-party MCP process never sees our DB URL, JWT + // secret, R2 keys or other providers' tokens. + envKeys?: string[] +} + +const BASE_ENV_KEYS = ["PATH", "HOME", "NODE_ENV", "TMPDIR"] + +function serverEnv(envKeys: string[] = []): Record{ + const env: Record = {} + for(const key of [...BASE_ENV_KEYS, ...envKeys]){ + const value = process.env[key] + if(value !== undefined) env[key] = value + } + return env } const SERVER_CONFIGS: Record = { tavily:{ command: "npx", args: ["-y", "tavily-mcp@latest"], - env: process.env.TAVILY_API_KEY + envKeys: ["TAVILY_API_KEY"] }, figma: { command: "npmx", args: ["-y", "figma-developer-mcp", "--studio"], - env: process.env.FIGMA_API_KEY + envKeys: ["FIGMA_API_KEY"] }, context7: { command: "npx", args: ["-y", "@upstash/context-7-mcp@latest"], - env: process.env.CONTEXT7_API_KEY + envKeys: ["CONTEXT7_API_KEY"] }, stitch: { command: "node", args: ["./services/stitch-server.js"], - env: process.env.STITCH_API_KEY + envKeys: ["STITCH_API_KEY"] }, apify:{ command: "npx", @@ -37,7 +51,7 @@ const SERVER_CONFIGS: Record = { vercel: { command: "npx", args: ["-y", "vercel-mcp-server"], - env: process.env.VERCEL_TOKEN + envKeys: ["VERCEL_TOKEN"] } } const clients : Map = new Map() @@ -50,9 +64,7 @@ async function getClient(serverName: string) : Promise{ const transport = new StdioClientTransport({ command: config.command, args: config.args, - env: { - ...process.env as Record - } + env: serverEnv(config.envKeys) }) const client = new Client({ name: `${serverName}-client`, diff --git a/packages/agents/agent/utils/sb.test.ts b/packages/agents/agent/utils/sb.test.ts index 54ec917..a369238 100644 --- a/packages/agents/agent/utils/sb.test.ts +++ b/packages/agents/agent/utils/sb.test.ts @@ -1,12 +1,13 @@ import { E2BSandbox } from './sandbox' -console.log("E2B:", process.env.E2B_API_KEY); +// Presence only — never print credential values, even truncated. console.log({ - account: process.env.R2_ACCOUNT_ID, - endpoint: process.env.R2_ENDPOINT, - bucket: process.env.R2_BUCKET_NAME, - access: process.env.R2_ACCESS_KEY_ID, - secret: process.env.R2_SECRET_ACCESS_KEY?.slice(0, 5) + "...", + e2bKey: Boolean(process.env.E2B_API_KEY), + r2Account: Boolean(process.env.R2_ACCOUNT_ID), + r2Endpoint: Boolean(process.env.R2_ENDPOINT), + r2Bucket: Boolean(process.env.R2_BUCKET_NAME), + r2AccessKey: Boolean(process.env.R2_ACCESS_KEY_ID), + r2SecretKey: Boolean(process.env.R2_SECRET_ACCESS_KEY), }); const sanbox: E2BSandbox = await E2BSandbox.StartSandbox("ashu2", "p1") // console.log(await sanbox.Execute(sanbox.sandboxId, {action: 'runCommand', command: "cd /home/user && find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null | sort"}))