diff --git a/backend/package-lock.json b/backend/package-lock.json index 5b9f894..01fa6db 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "bcryptjs": "^3.0.2", "cookie-parser": "^1.4.6", "cors": "^2.8.5", + "csurf": "^1.11.0", "dotenv": "^17.2.3", "express": "^5.1.0", "express-rate-limit": "^8.2.0", @@ -27,6 +28,7 @@ "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.3", "@types/cors": "^2.8.19", + "@types/csurf": "^1.11.5", "@types/express": "^5.0.3", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", @@ -1477,6 +1479,16 @@ "@types/node": "*" } }, + "node_modules/@types/csurf": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@types/csurf/-/csurf-1.11.5.tgz", + "integrity": "sha512-5rw87+5YGixyL2W8wblSUl5DSZi5YOlXE6Awwn2ofLvqKr/1LruKffrQipeJKUX44VaxKj8m5es3vfhltJTOoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*" + } + }, "node_modules/@types/express": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", @@ -2787,6 +2799,100 @@ "node": ">= 8" } }, + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "license": "MIT", + "dependencies": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csurf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", + "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", + "deprecated": "This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions", + "license": "MIT", + "dependencies": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/csurf/node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/csurf/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "license": "ISC" + }, + "node_modules/csurf/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5882,6 +5988,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6010,6 +6125,12 @@ "rimraf": "bin.js" } }, + "node_modules/rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -6897,6 +7018,15 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -6962,6 +7092,18 @@ "node": ">=0.8.0" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undici-types": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", diff --git a/backend/package.json b/backend/package.json index 665c214..6587ce5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,8 +20,9 @@ "@types/express-validator": "^2.20.33", "@types/socket.io": "^3.0.1", "bcryptjs": "^3.0.2", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", - "cookie-parser": "^1.4.6", + "csurf": "^1.11.0", "dotenv": "^17.2.3", "express": "^5.1.0", "express-rate-limit": "^8.2.0", @@ -33,7 +34,9 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.3", "@types/cors": "^2.8.19", + "@types/csurf": "^1.11.5", "@types/express": "^5.0.3", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", @@ -45,7 +48,6 @@ "supertest": "^7.1.4", "ts-jest": "^29.4.4", "ts-node-dev": "^2.0.0", - "@types/cookie-parser": "^1.4.3", "typescript": "^5.9.2" } } diff --git a/backend/src/app.ts b/backend/src/app.ts index 18ec150..92cc86f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,12 +8,11 @@ import cors from "cors"; import { corsConfig, helmetConfig, - generalLimiter, - speedLimiter, + generalLimiter, securityHeaders, - requestSizeLimiter, - csrfProtection + requestSizeLimiter } from "./middleware/security"; +import { csrfProtection, csrfErrorHandler } from "./middleware/csrf"; import healthRoutes from "./routes/health"; import authRoutes from "./routes/auth"; import projectRoutes from "./routes/project"; @@ -53,6 +52,12 @@ export function createApp() { app.get("/", (req, res) => { res.json({ message: "Welcome to Collab Code Review API", status: "running" }); }); + + // Add endpoint to get CSRF token (global for all routes) + app.get("/api/csrf-token", (req, res) => { + res.json({ csrfToken: req.csrfToken() }); + }); + app.use("/health", healthRoutes); app.use("/api/auth", authRoutes); @@ -63,5 +68,8 @@ export function createApp() { app.use("/api/users", generalLimiter, userRoutes); app.use("/api/branch-protection", branchProtectionRoutes); + // CSRF error handler + app.use(csrfErrorHandler); + return { app, server, socketService }; } \ No newline at end of file diff --git a/backend/src/middleware/csrf.ts b/backend/src/middleware/csrf.ts new file mode 100644 index 0000000..5996dc8 --- /dev/null +++ b/backend/src/middleware/csrf.ts @@ -0,0 +1,36 @@ +import csrf from 'csurf'; +import { Request, Response, NextFunction } from 'express'; + +// Create CSRF protection middleware +// Uses cookies to store the secret (requires cookie-parser) +export const csrfProtection = csrf({ + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'none' as 'none', // Important for cross-origin + path: '/' + } +}); + +// Error handler for CSRF failures +export const csrfErrorHandler = ( + err: any, + req: Request, + res: Response, + next: NextFunction +) => { + if (err.code === 'EBADCSRFTOKEN') { + // Log security event + console.error('[CSRF] Invalid CSRF token detected', { + ip: req.ip, + path: req.path, + method: req.method + }); + + return res.status(403).json({ + error: 'Invalid CSRF token', + message: 'Request rejected due to invalid security token' + }); + } + next(err); +}; \ No newline at end of file diff --git a/backend/src/middleware/security.ts b/backend/src/middleware/security.ts index 136eb7b..d0f1763 100644 --- a/backend/src/middleware/security.ts +++ b/backend/src/middleware/security.ts @@ -52,7 +52,8 @@ export const corsConfig = cors({ 'Accept', 'Authorization', 'Cache-Control', - 'X-HTTP-Method-Override' + 'X-HTTP-Method-Override', + 'x-csrf-token' ], maxAge: 86400 // Cache preflight for 24 hours }); diff --git a/frontend/frontend/src/App.tsx b/frontend/frontend/src/App.tsx index 658f054..ece253d 100644 --- a/frontend/frontend/src/App.tsx +++ b/frontend/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { initializeCsrf } from './api'; import { BrowserRouter as Router, Routes, Route, Navigate, Link } from 'react-router-dom'; import SimpleHome from './pages/SimpleHome'; import Dashboard from './pages/Dashboard'; @@ -37,6 +38,11 @@ function App() { }; }, []); + useEffect(() => { + // Initialize CSRF token on app mount + initializeCsrf(); + }, []); + return ( diff --git a/frontend/frontend/src/api/index.ts b/frontend/frontend/src/api/index.ts index a898161..2853053 100644 --- a/frontend/frontend/src/api/index.ts +++ b/frontend/frontend/src/api/index.ts @@ -8,6 +8,73 @@ const API = axios.create({ withCredentials: true, // Send cookies (refresh token) with requests }); +// Track CSRF token +let csrfToken: string | null = null; + +// Fetch CSRF token on app initialization +export async function initializeCsrf(): Promise { + try { + const response = await axios.get( + `${import.meta.env.VITE_BASE_API}/api/csrf-token`, + { withCredentials: true } + ); + csrfToken = response.data.csrfToken; + console.log('[CSRF] Token fetched successfully'); + } catch (error) { + console.error('[CSRF] Failed to fetch token:', error); + } +} + +// Request interceptor: Add CSRF token to state-changing requests +API.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + // Add JWT token if available + const token = localStorage.getItem("token"); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + + // Add CSRF token to POST/PUT/PATCH/DELETE requests + if (csrfToken && ['post', 'put', 'patch', 'delete'].includes(config.method?.toLowerCase() || '')) { + config.headers = config.headers || {}; + config.headers['X-CSRF-Token'] = csrfToken; + } + + return config; + }, + (error) => Promise.reject(error) +); + +// Response interceptor: Handle CSRF token expiry/invalidation +API.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + // Handle CSRF token errors (403 with EBADCSRFTOKEN) + if (error.response?.status === 403 && !originalRequest._retry) { + const errorData = error.response.data as any; + if (errorData?.error === 'Invalid CSRF token') { + console.log('[CSRF] Token invalid, refetching...'); + originalRequest._retry = true; + + // Refetch CSRF token + await initializeCsrf(); + + // Retry original request with new token + if (csrfToken) { + originalRequest.headers = originalRequest.headers || {}; + originalRequest.headers['X-CSRF-Token'] = csrfToken; + return API(originalRequest); + } + } + } + + // ... existing 401 refresh token logic + return Promise.reject(error); + } +); + // Track token expiry for proactive refresh let tokenExpiryTime: number | null = null; let refreshTimer: ReturnType | null = null; @@ -55,10 +122,14 @@ function scheduleTokenRefresh() { // Helper to refresh access token using refresh token cookie async function refreshAccessToken(): Promise { try { + // Use raw axios but manually include CSRF token to avoid interceptor loops const response = await axios.post( `${import.meta.env.VITE_BASE_API}/api/auth/refresh`, {}, - { withCredentials: true } + { + withCredentials: true, + headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {} + } ); const { token, expiresIn } = response.data;