From 647f8c7e54c9397323cbcc9b5a9bdd0fca90601a Mon Sep 17 00:00:00 2001 From: Monika Jakhar Date: Sat, 6 Jun 2026 14:38:35 +0530 Subject: [PATCH 1/9] security: remove root JS/config.js and strip GROQ_API_KEY from client bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added JS/config.js to .gitignore (root-level file was not protected) - Removed GROQ_API_KEY from the config object written to client/JS/config.js by server.js on startup — the key must never reach the browser - API key will only be consumed server-side via the upcoming AI proxy route (C1, C2, C4) --- .gitignore | 1 + server/server.js | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 4f5aa95..68b6d66 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env*.local .env client/JS/config.js +JS/config.js node_modules dist public diff --git a/server/server.js b/server/server.js index 2065c22..d6a63bd 100644 --- a/server/server.js +++ b/server/server.js @@ -13,8 +13,7 @@ try { APPWRITE_PROFILES_COLLECTION_ID: '${process.env.APPWRITE_PROFILES_COLLECTION_ID || "profiles"}', APPWRITE_SHARED_NOTES_COLLECTION_ID: '${process.env.APPWRITE_SHARED_NOTES_COLLECTION_ID || "shared_notes"}', SUPABASE_URL: '${process.env.SUPABASE_URL || ""}', - SUPABASE_ANON_KEY: '${process.env.SUPABASE_ANON_KEY || ""}', - GROQ_API_KEY: '${process.env.GROQ_API_KEY || ""}' + SUPABASE_ANON_KEY: '${process.env.SUPABASE_ANON_KEY || ""}' }; export default config;`; From 8ad4e775b83678a7f327825c5713b26331145e76 Mon Sep 17 00:00:00 2001 From: Monika Jakhar Date: Sat, 6 Jun 2026 14:39:21 +0530 Subject: [PATCH 2/9] security: move Groq AI calls to server-side proxy (C4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created server/routes/ai.js: authenticated POST /api/ai/generate endpoint that reads GROQ_API_KEY from process.env and proxies to Groq API - Mounted /api/ai route in server.js - Rewrote client/JS/geminiAPI.js to call /api/ai/generate instead of contacting Groq directly — the API key never reaches the browser - Proxy enforces: authentication check, prompt validation (non-empty, max 10k chars), graceful error handling without leaking internals --- client/JS/geminiAPI.js | 70 ++++++++++++------------------------------ server/routes/ai.js | 66 +++++++++++++++++++++++++++++++++++++++ server/server.js | 1 + 3 files changed, 87 insertions(+), 50 deletions(-) create mode 100644 server/routes/ai.js diff --git a/client/JS/geminiAPI.js b/client/JS/geminiAPI.js index 29f909e..755ac9a 100644 --- a/client/JS/geminiAPI.js +++ b/client/JS/geminiAPI.js @@ -1,68 +1,38 @@ -import config from './config.js'; +/** + * geminiAPI.js + * Calls the server-side AI proxy (/api/ai/generate) which forwards the request + * to Groq securely. The GROQ_API_KEY never touches the browser. + */ /** - * Calls the Groq API (OpenAI compatible) to generate content. - * Keeps the original function name to avoid breaking imports in other files. + * Generates text via the server-side AI proxy. * @param {string} prompt The user's prompt. * @returns {Promise} The generated text. */ export async function generateTextWithGemini(prompt) { - const { GROQ_API_KEY: API_KEY } = config; - const API_URL = "https://api.groq.com/openai/v1/chat/completions"; - - if (!API_KEY || API_KEY === '' || API_KEY === 'YOUR_GROQ_API_KEY') { - const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; - const message = isLocal - ? "Please add GROQ_API_KEY to your .env file and run 'npm run build'." - : "Deployment Error: GROQ_API_KEY is missing. Please add it to your deployment platform's Environment Variables."; - - return Promise.resolve(` -[AI Assistant]: ${message} -You can get a key from console.groq.com. -`); - } - try { - const response = await fetch(API_URL, { + const response = await fetch('/api/ai/generate', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${API_KEY}` - }, - body: JSON.stringify({ - model: "llama-3.3-70b-versatile", - messages: [ - { - role: "user", - content: prompt - } - ], - temperature: 0.7, - max_tokens: 2048 - }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }) }); + if (response.status === 401) { + return '[AI Assistant]: You need to be logged in to use the AI assistant.'; + } + if (!response.ok) { - const errorBody = await response.json(); - console.error('Groq API request failed:', errorBody); - throw new Error(`API request failed with status ${response.status}: ${errorBody.error?.message || 'Unknown error'}`); + const errData = await response.json().catch(() => ({})); + const message = errData.error || `Server error (${response.status})`; + console.error('[geminiAPI] Proxy error:', message); + return `[AI Assistant]: ${message}`; } const data = await response.json(); - - if (data.choices && data.choices.length > 0 && data.choices[0].message) { - return data.choices[0].message.content; - } else { - console.warn("Groq API response was successful, but no content was found.", data); - return "I'm sorry, I couldn't generate a response. Please try again."; - } + return data.text ?? '[AI Assistant]: No response received.'; } catch (error) { - console.error('Error calling Groq API:', error); - return ` -[AI Assistant]: There was an error contacting the Groq service. -Please check the console for more details. -Error: ${error.message} - `; + console.error('[geminiAPI] Network error:', error.message); + return '[AI Assistant]: Could not connect to the AI service. Please check your connection.'; } } diff --git a/server/routes/ai.js b/server/routes/ai.js new file mode 100644 index 0000000..6ce2d9a --- /dev/null +++ b/server/routes/ai.js @@ -0,0 +1,66 @@ +const express = require('express'); +const router = express.Router(); + +// Middleware to ensure user is logged in +const ensureAuth = (req, res, next) => { + if (req.isAuthenticated()) { + return next(); + } + res.status(401).json({ msg: 'Unauthorized' }); +}; + +/** + * @desc Server-side proxy for Groq AI generation. + * The GROQ_API_KEY never leaves the server environment. + * @route POST /api/ai/generate + * @access Private (requires authentication) + */ +router.post('/generate', ensureAuth, async (req, res) => { + const { prompt } = req.body; + + if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) { + return res.status(400).json({ error: 'A non-empty prompt string is required.' }); + } + + if (prompt.length > 10000) { + return res.status(400).json({ error: 'Prompt exceeds maximum length of 10,000 characters.' }); + } + + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) { + console.error('[AI Proxy] GROQ_API_KEY is not configured on the server.'); + return res.status(503).json({ error: 'AI service is not configured. Contact the administrator.' }); + } + + try { + const response = await fetch('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: 'llama-3.3-70b-versatile', + messages: [{ role: 'user', content: prompt.trim() }], + temperature: 0.7, + max_tokens: 2048 + }) + }); + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + console.error('[AI Proxy] Groq API error:', response.status, errBody); + return res.status(502).json({ error: 'AI service returned an error. Please try again.' }); + } + + const data = await response.json(); + const text = data?.choices?.[0]?.message?.content ?? ''; + return res.json({ text }); + + } catch (err) { + console.error('[AI Proxy] Request failed:', err.message); + return res.status(500).json({ error: 'AI service is temporarily unavailable.' }); + } +}); + +module.exports = router; diff --git a/server/server.js b/server/server.js index d6a63bd..9749234 100644 --- a/server/server.js +++ b/server/server.js @@ -80,6 +80,7 @@ require('./config/passport')(passport); app.use('/api/auth', require('./routes/auth')); app.use('/api/notes', require('./routes/notes')); app.use('/api/folders', require('./routes/folders')); +app.use('/api/ai', require('./routes/ai')); // Serve Static Frontend Assets app.use(express.static(path.join(__dirname, '../client'))); From 78e51024fa518b9f84b334728efec2e6f685e348 Mon Sep 17 00:00:00 2001 From: Monika Jakhar Date: Sat, 6 Jun 2026 14:40:35 +0530 Subject: [PATCH 3/9] security: fix mass assignment, error leakage and add input validation (H1-H3, M5) --- package-lock.json | 109 ++++++++++++++++++++++----------------- package.json | 2 + server/routes/folders.js | 45 ++++++++++------ server/routes/notes.js | 69 +++++++++++++++++++------ 4 files changed, 147 insertions(+), 78 deletions(-) diff --git a/package-lock.json b/package-lock.json index 120f95f..aaaacab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-rate-limit": "^8.5.2", "express-session": "^1.18.0", + "express-validator": "^7.3.2", "helmet": "^7.1.0", "mongoose": "^8.3.2", "morgan": "^1.10.0", @@ -79,6 +81,7 @@ "integrity": "sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -768,9 +771,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -788,9 +788,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -808,9 +805,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -828,9 +822,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -848,9 +839,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -868,9 +856,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -888,9 +873,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -908,9 +890,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -928,9 +907,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -954,9 +930,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -980,9 +953,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1006,9 +976,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1032,9 +999,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1058,9 +1022,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1084,9 +1045,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1110,9 +1068,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2205,11 +2160,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-session": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "~0.7.2", "cookie-signature": "~1.0.7", @@ -2252,6 +2226,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/express-validator": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", + "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.18.1", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/express/node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2537,6 +2524,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2645,6 +2641,12 @@ "node": ">=18" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.2.4", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", @@ -3856,6 +3858,7 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -3905,6 +3908,15 @@ "node": ">= 0.4.0" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -3959,6 +3971,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, diff --git a/package.json b/package.json index b29ad5f..fa04d6a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-rate-limit": "^8.5.2", "express-session": "^1.18.0", + "express-validator": "^7.3.2", "helmet": "^7.1.0", "mongoose": "^8.3.2", "morgan": "^1.10.0", diff --git a/server/routes/folders.js b/server/routes/folders.js index 30b8f38..7d448f3 100644 --- a/server/routes/folders.js +++ b/server/routes/folders.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const Folder = require('../models/Folder'); +const { body, validationResult } = require('express-validator'); const ensureAuth = (req, res, next) => { if (req.isAuthenticated()) { @@ -16,25 +17,38 @@ router.get('/', ensureAuth, async (req, res) => { const folders = await Folder.find({ userId: req.user.id }); res.json(folders); } catch (err) { - res.status(500).send('Server Error'); + console.error('Error fetching folders:', err); + res.status(500).json({ error: 'Internal server error' }); // H3: no details leaked } }); // @desc Create a folder // @route POST /api/folders -router.post('/', ensureAuth, async (req, res) => { - try { - const newFolder = new Folder({ - name: req.body.name, - id: req.body.id, // Capture client UUID - userId: req.user.id - }); - const folder = await newFolder.save(); - res.json(folder); - } catch (err) { - res.status(500).send('Server Error'); +router.post('/', + ensureAuth, + body('name').isString().trim().isLength({ min: 1, max: 255 }).withMessage('Folder name must be 1–255 characters'), + body('id').optional().isString().isLength({ max: 128 }), + async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + try { + // M5 / H1: Explicitly pick only the allowed fields + const newFolder = new Folder({ + name: req.body.name.trim(), + id: typeof req.body.id === 'string' ? req.body.id.slice(0, 128) : undefined, + userId: req.user.id + }); + const folder = await newFolder.save(); + res.json(folder); + } catch (err) { + console.error('Error creating folder:', err); + res.status(500).json({ error: 'Internal server error' }); // H3 + } } -}); +); // @desc Delete a folder // @route DELETE /api/folders/:id @@ -42,12 +56,13 @@ router.delete('/:id', ensureAuth, async (req, res) => { try { const folder = await Folder.findById(req.params.id); if (!folder) return res.status(404).json({ msg: 'Folder not found' }); - if (folder.userId.toString() !== req.user.id) return res.status(401).json({ msg: 'Not authorized' }); + if (folder.userId.toString() !== req.user.id) return res.status(403).json({ msg: 'Forbidden' }); await Folder.findByIdAndDelete(req.params.id); res.json({ msg: 'Folder removed' }); } catch (err) { - res.status(500).send('Server Error'); + console.error('Error deleting folder:', err); + res.status(500).json({ error: 'Internal server error' }); // H3 } }); diff --git a/server/routes/notes.js b/server/routes/notes.js index b79e49c..6f9b92f 100644 --- a/server/routes/notes.js +++ b/server/routes/notes.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const Note = require('../models/Note'); +const { body, validationResult } = require('express-validator'); // Middleware to ensure user is logged in const ensureAuth = (req, res, next) => { @@ -10,6 +11,29 @@ const ensureAuth = (req, res, next) => { res.status(401).json({ msg: 'Unauthorized' }); }; +// Allowed fields for note creation and update — prevents mass assignment (H1, H2) +const extractNoteFields = (body) => ({ + title: typeof body.title === 'string' ? body.title.slice(0, 500) : '', + content: typeof body.content === 'string' ? body.content.slice(0, 500000) : '', + tags: Array.isArray(body.tags) ? body.tags.filter(t => typeof t === 'string').slice(0, 50) : [], + folderId: typeof body.folderId === 'string' ? body.folderId : null, + theme: typeof body.theme === 'string' ? body.theme.slice(0, 100) : 'classic-blue', + editorPattern: typeof body.editorPattern === 'string' ? body.editorPattern.slice(0, 100) : 'plain', + isFavorite: typeof body.isFavorite === 'boolean' ? body.isFavorite : false, + isArchived: typeof body.isArchived === 'boolean' ? body.isArchived : false, + lectureTranscript: typeof body.lectureTranscript === 'string' ? body.lectureTranscript.slice(0, 100000) : '', + glossaryTerms: Array.isArray(body.glossaryTerms) ? body.glossaryTerms.slice(0, 500) : [], + aiConcepts: Array.isArray(body.aiConcepts) ? body.aiConcepts.slice(0, 100) : [], + aiDeadlines: Array.isArray(body.aiDeadlines) ? body.aiDeadlines.slice(0, 100) : [], +}); + +// Input validators (M5) +const noteValidators = [ + body('title').optional().isString().isLength({ max: 500 }).withMessage('Title must be under 500 characters'), + body('content').optional().isString().isLength({ max: 500000 }).withMessage('Content too large'), + body('tags').optional().isArray({ max: 50 }).withMessage('Too many tags'), +]; + // @desc Get all notes for current user // @route GET /api/notes router.get('/', ensureAuth, async (req, res) => { @@ -18,39 +42,54 @@ router.get('/', ensureAuth, async (req, res) => { res.json(notes); } catch (err) { console.error('Error fetching notes:', err); - res.status(500).json({ error: 'Server Error', details: err.message }); + res.status(500).json({ error: 'Internal server error' }); // H3: no details leaked } }); // @desc Create a note // @route POST /api/notes -router.post('/', ensureAuth, async (req, res) => { +router.post('/', ensureAuth, noteValidators, async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + try { - const newNote = new Note({ - ...req.body, - userId: req.user.id - }); + const fields = extractNoteFields(req.body); // H1: whitelist only allowed fields + const newNote = new Note({ ...fields, userId: req.user.id }); const note = await newNote.save(); res.json(note); } catch (err) { console.error('Error creating note:', err); - res.status(500).json({ error: 'Failed to create note', details: err.message }); + res.status(500).json({ error: 'Internal server error' }); // H3 } }); // @desc Update a note // @route PUT /api/notes/:id -router.put('/:id', ensureAuth, async (req, res) => { +router.put('/:id', ensureAuth, noteValidators, async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + try { - let note = await Note.findById(req.params.id); + const note = await Note.findById(req.params.id); if (!note) return res.status(404).json({ msg: 'Note not found' }); - if (note.userId.toString() !== req.user.id) return res.status(401).json({ msg: 'Not authorized' }); + if (note.userId.toString() !== req.user.id) return res.status(403).json({ msg: 'Forbidden' }); - note = await Note.findByIdAndUpdate(req.params.id, { $set: req.body, updatedAt: Date.now() }, { new: true }); - res.json(note); + const fields = extractNoteFields(req.body); // H2: whitelist prevents userId override + fields.updatedAt = Date.now(); + + const updated = await Note.findByIdAndUpdate( + req.params.id, + { $set: fields }, + { new: true } + ); + res.json(updated); } catch (err) { console.error('Error updating note:', err); - res.status(500).json({ error: 'Failed to update note', details: err.message }); + res.status(500).json({ error: 'Internal server error' }); // H3 } }); @@ -60,13 +99,13 @@ router.delete('/:id', ensureAuth, async (req, res) => { try { const note = await Note.findById(req.params.id); if (!note) return res.status(404).json({ msg: 'Note not found' }); - if (note.userId.toString() !== req.user.id) return res.status(401).json({ msg: 'Not authorized' }); + if (note.userId.toString() !== req.user.id) return res.status(403).json({ msg: 'Forbidden' }); await Note.findByIdAndDelete(req.params.id); res.json({ msg: 'Note removed' }); } catch (err) { console.error('Error deleting note:', err); - res.status(500).json({ error: 'Failed to delete note', details: err.message }); + res.status(500).json({ error: 'Internal server error' }); // H3 } }); From cce810f7f3bdd5313ab68be619946291c9320fcc Mon Sep 17 00:00:00 2001 From: Monika Jakhar Date: Sat, 6 Jun 2026 14:41:20 +0530 Subject: [PATCH 4/9] security: add rate limiting, restrict CORS, CSRF protection, harden session (H4, H5, M3, M4) --- server/server.js | 113 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 12 deletions(-) diff --git a/server/server.js b/server/server.js index 9749234..1a246f0 100644 --- a/server/server.js +++ b/server/server.js @@ -2,7 +2,11 @@ require('dotenv').config(); const path = require('path'); const fs = require('fs'); -// Dynamically generate client/JS/config.js on startup to ensure front-end parity with local .env keys +// --------------------------------------------------------------------------- +// Dynamically generate client/JS/config.js on startup. +// SECURITY: GROQ_API_KEY is intentionally excluded — it is consumed server-side +// only via the /api/ai/generate proxy route and must never reach the browser. +// --------------------------------------------------------------------------- try { const configContent = `const config = { APPWRITE_ENDPOINT: '${process.env.APPWRITE_ENDPOINT || "https://cloud.appwrite.io/v1"}', @@ -36,35 +40,120 @@ const morgan = require('morgan'); const session = require('express-session'); const MongoStore = require('connect-mongo'); const passport = require('passport'); +const rateLimit = require('express-rate-limit'); const app = express(); app.enable('trust proxy'); const PORT = process.env.PORT || 3000; -// Connect to MongoDB Atlas +// --------------------------------------------------------------------------- +// Security: CORS — only allow requests from our own origin (H5, M3) +// --------------------------------------------------------------------------- +const allowedOrigins = process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()) + : ['http://localhost:3000', 'http://127.0.0.1:3000']; + +app.use(cors({ + origin: (origin, callback) => { + // Allow same-origin requests (no Origin header) and whitelisted origins + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error(`CORS policy does not allow origin: ${origin}`)); + } + }, + credentials: true +})); + +// --------------------------------------------------------------------------- +// Security: Rate Limiting (H4) +// General limiter: 200 requests per 15 minutes per IP +// --------------------------------------------------------------------------- +const generalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 200, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later.' } +}); + +// Stricter limiter for auth routes: 20 attempts per 15 minutes +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many authentication attempts, please try again later.' } +}); + +// AI limiter: 30 requests per minute (prevents API cost abuse) +const aiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'AI rate limit exceeded. Please wait before making more requests.' } +}); + +app.use('/api/', generalLimiter); +app.use('/api/auth/', authLimiter); +app.use('/api/ai/', aiLimiter); + +// --------------------------------------------------------------------------- +// Security: CSRF protection via Origin/Referer check (H5) +// For state-changing methods (POST, PUT, DELETE, PATCH), verify the request +// originates from our own domain. +// --------------------------------------------------------------------------- +app.use((req, res, next) => { + const safeMethods = ['GET', 'HEAD', 'OPTIONS']; + if (safeMethods.includes(req.method)) return next(); + + // Skip CSRF check for OAuth callbacks (they are GET redirects from providers) + if (req.path.startsWith('/api/auth/')) return next(); + + const origin = req.headers['origin'] || req.headers['referer']; + if (!origin) { + // No origin header — could be a same-origin request or a non-browser client. + // Allow it only if it carries a valid session (ensureAuth on routes handles that). + return next(); + } + + const isAllowed = allowedOrigins.some(o => origin.startsWith(o)); + if (!isAllowed) { + return res.status(403).json({ error: 'CSRF check failed: origin not allowed.' }); + } + next(); +}); + +// Connect to MongoDB mongoose.connect(process.env.MONGODB_URI) - .then(() => console.log('Connected to MongoDB Atlas')) + .then(() => console.log('Connected to MongoDB')) .catch(err => console.error('MongoDB connection error:', err)); // Middleware app.use(helmet({ contentSecurityPolicy: false, // Disabled for development simplicity })); -app.use(cors()); app.use(morgan('dev')); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.json({ limit: '2mb' })); // Cap request body size +app.use(express.urlencoded({ extended: true, limit: '2mb' })); -// Sessions +// --------------------------------------------------------------------------- +// Sessions (M4: SameSite upgraded to 'strict' to block CSRF) +// --------------------------------------------------------------------------- +const isProduction = process.env.NODE_ENV === 'production'; app.use(session({ - secret: process.env.SESSION_SECRET || 'fallback_secret', + secret: process.env.SESSION_SECRET || (() => { + console.warn('[SECURITY] SESSION_SECRET not set — using insecure fallback. Set it in .env!'); + return 'insecure_fallback_change_me'; + })(), resave: false, saveUninitialized: false, store: MongoStore.create({ mongoUrl: process.env.MONGODB_URI }), cookie: { - secure: false, // Must be false for localhost (HTTP) - httpOnly: true, - sameSite: 'lax', + secure: isProduction, // HTTPS-only in production + httpOnly: true, // Not accessible via JS + sameSite: 'strict', // M4/H5: upgraded from 'lax' to block CSRF maxAge: 1000 * 60 * 60 * 24 // 1 day } })); @@ -85,7 +174,7 @@ app.use('/api/ai', require('./routes/ai')); // Serve Static Frontend Assets app.use(express.static(path.join(__dirname, '../client'))); -// Fallback for SPA (if applicable) +// Fallback for SPA app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../client/index.html')); }); From f4f2f7a5dc53c6fddb914649ad49b64d36ad8eea Mon Sep 17 00:00:00 2001 From: Monika Jakhar Date: Sat, 6 Jun 2026 14:41:39 +0530 Subject: [PATCH 5/9] security: sanitize /api/auth/user response - strip googleId, githubId, _id (M1) --- server/routes/auth.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/routes/auth.js b/server/routes/auth.js index 43ccfa1..adef215 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -37,11 +37,13 @@ router.get('/logout', (req, res, next) => { }); }); -// @desc Get current user +// @desc Get current user (public fields only — no OAuth provider IDs or internals) // @route GET /api/auth/user router.get('/user', (req, res) => { if (req.isAuthenticated()) { - res.json(req.user); + // M1: Only expose safe, non-sensitive fields. Never expose googleId, githubId, _id, __v. + const { username, email, avatarUrl, createdAt } = req.user; + res.json({ username, email, avatarUrl, createdAt }); } else { res.status(401).json({ msg: 'Not authenticated' }); } From 7d35d38baeb32326e6723e08d965172d9a5bc090 Mon Sep 17 00:00:00 2001 From: Monika Jakhar Date: Sat, 6 Jun 2026 14:42:36 +0530 Subject: [PATCH 6/9] security: add DOMPurify XSS sanitization for note content and AI output (H6, L2) --- client/JS/aiAssistant.js | 4 +++- client/JS/renderer.js | 7 ++++++- client/app.html | 2 ++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/client/JS/aiAssistant.js b/client/JS/aiAssistant.js index ada2432..ce5a9ef 100644 --- a/client/JS/aiAssistant.js +++ b/client/JS/aiAssistant.js @@ -42,7 +42,9 @@ export function wireAIAssistant(state, callbacks) { document.execCommand('insertText', false, text); } else { // Escape HTML entities in each paragraph to prevent XSS - const safeHtml = paragraphs.map(p => escapeHtml(p)).join('
'); + // L2: Sanitize AI-generated HTML to prevent adversarial model output from injecting scripts + const purify = (typeof DOMPurify !== 'undefined') ? DOMPurify : { sanitize: (s) => s }; + const safeHtml = paragraphs.map(p => purify.sanitize(escapeHtml(p))).join('
'); document.execCommand('insertHTML', false, safeHtml); } } catch (e) { diff --git a/client/JS/renderer.js b/client/JS/renderer.js index 3e3fc4c..466315d 100644 --- a/client/JS/renderer.js +++ b/client/JS/renderer.js @@ -47,7 +47,12 @@ export function renderActiveNote(note, removeTagFromActiveNote) { if (titleInput) titleInput.value = note.title || ""; if (contentInput) { - contentInput.innerHTML = note.content || ""; + // H6: Sanitize HTML content before inserting into the DOM to prevent stored XSS. + // DOMPurify is loaded globally in app.html before this module runs. + const sanitize = (html) => (typeof DOMPurify !== 'undefined') + ? DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }) + : html; + contentInput.innerHTML = sanitize(note.content || ""); // Apply editor pattern contentInput.setAttribute("data-pattern", note.editorPattern || "plain"); } diff --git a/client/app.html b/client/app.html index 32079f8..22a7253 100644 --- a/client/app.html +++ b/client/app.html @@ -1044,6 +1044,8 @@

Enter Details

+ + - +