diff --git a/Readme.md b/Readme.md index bd842e8..addbf20 100644 --- a/Readme.md +++ b/Readme.md @@ -224,6 +224,7 @@ Key environment variables needed: | ------------------------- | ------------------------- | | `DATABASE_URL` | MongoDB connection string | | `JWT_SECRET` | JWT signing secret | +| `CSRF_SECRET` | CSRF token signing secret | | `GROQ_API_KEY` | Groq API for AI features | | `OPENAI_API_KEY` | OpenAI for embeddings | | `PINECONE_API_KEY` | Pinecone vector database | diff --git a/client/src/main.tsx b/client/src/main.tsx index bef5202..0fa2e8d 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,9 +2,14 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { initCSRF } from './utils/api' -createRoot(document.getElementById('root')!).render( - - - , -) +// Initialize CSRF token before rendering +// Use .finally() so app renders even if CSRF fetch fails +initCSRF().finally(() => { + createRoot(document.getElementById('root')!).render( + + + , + ) +}) diff --git a/client/src/utils/api.ts b/client/src/utils/api.ts index c87720e..6560302 100644 --- a/client/src/utils/api.ts +++ b/client/src/utils/api.ts @@ -44,8 +44,23 @@ const api = axios.create({ headers: { "Content-Type": "application/json", }, + withCredentials: true, // Enable cookies for CSRF }); +// Store CSRF token +let csrfToken: string | null = null; + +// Fetch CSRF token from server +export const initCSRF = async (): Promise => { + try { + const response = await api.get("/csrf-token"); + csrfToken = response.data.csrfToken; + console.log("✅ CSRF token initialized"); + } catch (error) { + console.error("❌ Failed to fetch CSRF token:", error); + } +}; + // Request interceptor for debugging api.interceptors.request.use( (config) => { @@ -55,6 +70,15 @@ api.interceptors.request.use( config.headers.Authorization = `Bearer ${token}`; } + // Add CSRF token for state-changing requests + if ( + csrfToken && + config.method && + ["post", "put", "delete", "patch"].includes(config.method.toLowerCase()) + ) { + config.headers["x-csrf-token"] = csrfToken; + } + // Debug logging for courses API if (config.url?.includes("/courses")) { console.log("🚀 API Request (Courses):", { @@ -96,7 +120,7 @@ api.interceptors.response.use( } return response; }, - (error) => { + async (error) => { if (!isDev) { console.error("❌ API Error:", { status: error.response?.status, @@ -107,6 +131,25 @@ api.interceptors.response.use( }); } + // Handle CSRF token errors + if ( + error.response?.status === 403 && + error.response?.data?.message?.includes("CSRF") && + !error.config._retry + ) { + error.config._retry = true; + + // Refresh CSRF token + await initCSRF(); + + // Retry the original request + if (csrfToken) { + error.config.headers["x-csrf-token"] = csrfToken; + } + + return api(error.config); + } + if (error.response?.status === 401) { console.log("🔐 401 Unauthorized - Clearing auth state"); removeAuthToken(); diff --git a/server/.env.example b/server/.env.example index 0fb26ac..151212f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -4,6 +4,9 @@ DATABASE_URL=your_mongodb_connection_string # JWT JWT_SECRET=your_super_secret_jwt_key_here +# CSRF Protection +CSRF_SECRET=your_csrf_secret_key_minimum_32_characters_long + # Email Configuration (Gmail) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 diff --git a/server/index.js b/server/index.js index f5cc645..4281d4b 100644 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,7 @@ import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; +import { doubleCsrf } from "csrf-csrf"; import dotenv from "dotenv"; import { createServer } from "http"; import { Server } from "socket.io"; @@ -38,6 +39,32 @@ initRedis().catch((err) => { const app = express(); const server = createServer(app); +// CSRF Configuration +const csrfProtection = doubleCsrf({ + getSecret: () => { + if (!process.env.CSRF_SECRET) { + throw new Error("CSRF_SECRET environment variable is not configured. Please set it in your .env file."); + } + return process.env.CSRF_SECRET; + }, + cookieName: "x-csrf-token", + cookieOptions: { + httpOnly: true, + sameSite: process.env.NODE_ENV === "production" ? "strict" : "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 3600000, // 1 hour + }, + size: 64, + ignoredMethods: ["GET", "HEAD", "OPTIONS"], + getSessionIdentifier: (req) => { + // Use session ID or user ID if available, otherwise use a default + return req.session?.id || req.user?.id || "anonymous"; + }, +}); + +const generateToken = csrfProtection.generateCsrfToken; // Correct function name +const doubleCsrfProtection = csrfProtection.doubleCsrfProtection; + // Determine allowed origins based on environment const getAllowedOrigins = () => { const origins = []; @@ -78,6 +105,17 @@ app.use(express.urlencoded({ extended: true, limit: "50mb" })); // Initialize Passport app.use(passport.initialize()); +// CSRF token endpoint - clients fetch token from here +app.get("/api/csrf-token", (req, res) => { + try { + const token = generateToken(req, res); + res.json({ csrfToken: token }); + } catch (error) { + console.error("CSRF token generation error:", error); + res.status(500).json({ error: "Failed to generate CSRF token" }); + } +}); + // Debug middleware to log all requests app.use((req, res, next) => { console.log(`🔍 ${req.method} ${req.path}`); @@ -90,6 +128,33 @@ app.use((req, res, next) => { next(); }); +// Apply CSRF protection to all API routes (except specific public endpoints) +app.use("/api", (req, res, next) => { + // Skip CSRF validation for: + // 1. Safe methods (GET, HEAD, OPTIONS - already ignored by default) + // 2. CSRF token endpoint itself + // 3. Public authentication endpoints + const publicPaths = [ + "/csrf-token", + "/auth/login", + "/auth/signup", + "/auth/send-otp", + "/auth/verify-otp", + "/auth/forgot-password", + "/auth/reset-password", + "/auth/google", + "/auth/google/callback", + "/health", + ]; + + if (publicPaths.some((path) => req.path === path)) { + return next(); + } + + // Apply CSRF protection + doubleCsrfProtection(req, res, next); +}); + // Routes app.use("/api/auth", authRoutes); app.use("/api/pdfs", pdfRoutes); @@ -147,6 +212,15 @@ app.get("/api/health", async (req, res) => { // Error handling middleware app.use((err, req, res, next) => { + // Handle CSRF token errors + if (err.code === "EBADCSRFTOKEN") { + console.log("❌ CSRF validation failed:", req.method, req.path); + return res.status(403).json({ + error: "Invalid or missing CSRF token", + code: "EBADCSRFTOKEN", + }); + } + console.error("Unhandled error:", err); res.status(500).json({ error: "Internal server error" }); }); @@ -167,4 +241,4 @@ server.listen(PORT, () => { }); export default app; -export { io }; +export { io }; \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index a9c4faf..d64951d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -16,6 +16,7 @@ "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csrf-csrf": "^4.0.3", "dotenv": "^17.2.1", "express": "^4.18.2", "groq-sdk": "^0.30.0", @@ -2451,6 +2452,15 @@ "node": ">= 0.10" } }, + "node_modules/csrf-csrf": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-4.0.3.tgz", + "integrity": "sha512-DaygOzelL4Qo1pHwI9LPyZL+X2456/OzpT596kNeZGiTSqKVDOk/9PPJ+FjzZacjMUEusOHw3WJKe1RW4iUhrw==", + "license": "ISC", + "dependencies": { + "http-errors": "^2.0.0" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", diff --git a/server/package.json b/server/package.json index df81ad1..1fb2e85 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,7 @@ "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csrf-csrf": "^4.0.3", "dotenv": "^17.2.1", "express": "^4.18.2", "groq-sdk": "^0.30.0", diff --git a/server/routes/auth.js b/server/routes/auth.js index 6eb2a55..a874380 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -676,4 +676,4 @@ router.post("/change-username", async (req, res) => { } }); -export default router; +export default router; \ No newline at end of file