From 931914e0d20d9a0b9e78058a7e68009ec498758f Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Sat, 27 Jun 2026 15:26:58 +0530 Subject: [PATCH] feat(security): implement comprehensive browser security hardening to improve Lighthouse Best Practices --- app/package-lock.json | 17 +++--- app/vercel.json | 39 ++++++++++++++ app/vite.config.ts | 26 ++++++++- backend/package-lock.json | 13 +++++ backend/package.json | 1 + backend/src/server.ts | 53 ++++++++++++++++-- backend/tests/securityHeaders.test.ts | 78 +++++++++++++++++++++++++++ 7 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 backend/tests/securityHeaders.test.ts diff --git a/app/package-lock.json b/app/package-lock.json index c1cfffa..42839d7 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -3928,13 +3928,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -4042,9 +4045,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001762", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", - "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { diff --git a/app/vercel.json b/app/vercel.json index 0d199b2..244b4f9 100644 --- a/app/vercel.json +++ b/app/vercel.json @@ -1,5 +1,44 @@ { "rewrites": [ { "source": "/(.*)", "destination": "/index.html" } + ], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'unsafe-eval' https://accounts.google.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; connect-src 'self' ws: wss: https://algoforge-2-0.onrender.com http://localhost:5000 https://accounts.google.com https://www.googleapis.com https://cdn.jsdelivr.net https://fonts.googleapis.com https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: blob: https://lh3.googleusercontent.com https://*.googleusercontent.com https://cdn.jsdelivr.net; frame-src 'self' https://accounts.google.com; child-src 'self' blob: https://accounts.google.com; worker-src 'self' blob: https://cdn.jsdelivr.net; form-action 'self'; frame-ancestors 'self';" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin-allow-popups" + }, + { + "key": "Cross-Origin-Resource-Policy", + "value": "same-origin" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000; includeSubDomains; preload" + } + ] + } ] } diff --git a/app/vite.config.ts b/app/vite.config.ts index e58ed7f..3d5c1a1 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -5,6 +5,30 @@ import { defineConfig } from "vite" // https://vite.dev/config/ export default defineConfig(() => ({ base: '/', + server: { + headers: { + 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-eval' https://accounts.google.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; connect-src 'self' ws: wss: https://algoforge-2-0.onrender.com http://localhost:5000 https://accounts.google.com https://www.googleapis.com https://cdn.jsdelivr.net https://fonts.googleapis.com https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: blob: https://lh3.googleusercontent.com https://*.googleusercontent.com https://cdn.jsdelivr.net; frame-src 'self' https://accounts.google.com; child-src 'self' blob: https://accounts.google.com; worker-src 'self' blob: https://cdn.jsdelivr.net; form-action 'self'; frame-ancestors 'self';", + 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'SAMEORIGIN', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload', + } + }, + preview: { + headers: { + 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-eval' https://accounts.google.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; connect-src 'self' ws: wss: https://algoforge-2-0.onrender.com http://localhost:5000 https://accounts.google.com https://www.googleapis.com https://cdn.jsdelivr.net https://fonts.googleapis.com https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: blob: https://lh3.googleusercontent.com https://*.googleusercontent.com https://cdn.jsdelivr.net; frame-src 'self' https://accounts.google.com; child-src 'self' blob: https://accounts.google.com; worker-src 'self' blob: https://cdn.jsdelivr.net; form-action 'self'; frame-ancestors 'self';", + 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'SAMEORIGIN', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload', + } + }, plugins: [ react(), ], @@ -15,7 +39,7 @@ export default defineConfig(() => ({ }, define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify( - process.env.VITE_API_BASE_URL || 'https://algoforge-2-0.onrender.com' + process.env.VITE_API_BASE_URL || (process.env.VERCEL ? 'https://algoforge-2-0.onrender.com' : '') ), }, build: { diff --git a/backend/package-lock.json b/backend/package-lock.json index 8df4899..03b2478 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "express": "^4.19.2", "express-rate-limit": "^8.5.2", "google-auth-library": "^10.5.0", + "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", "mongodb": "^7.2.0", "prisma": "^6.4.1" @@ -2168,6 +2169,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index ec2bcc2..2f21197 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "express": "^4.19.2", "express-rate-limit": "^8.5.2", "google-auth-library": "^10.5.0", + "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", "mongodb": "^7.2.0", "prisma": "^6.4.1" diff --git a/backend/src/server.ts b/backend/src/server.ts index 5c16df5..7733232 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,7 @@ import express, { Express, Request, Response } from 'express'; import dotenv from 'dotenv'; import cors from 'cors'; +import helmet from 'helmet'; import connectDB from './config/db'; dotenv.config(); @@ -18,6 +19,7 @@ const allowedOrigins = [ 'https://algo-forge-2-0.vercel.app', 'https://algoforge-2-0.onrender.com', 'http://localhost:5173', + 'http://localhost:4173', 'http://localhost:3000', process.env.CLIENT_URL, ].filter(Boolean) as string[]; @@ -35,6 +37,46 @@ app.use(cors({ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] })); + +// Remove Express fingerprint header +app.disable('x-powered-by'); + +// Security headers config +app.use(helmet({ + contentSecurityPolicy: { + useDefaults: false, + directives: { + defaultSrc: ["'none'"], + scriptSrc: ["'none'"], + connectSrc: ["'self'"], + imgSrc: ["'self'"], + styleSrc: ["'none'"], + frameAncestors: ["'none'"], + formAction: ["'none'"], + } + }, + crossOriginOpenerPolicy: { policy: "same-origin-allow-popups" }, + crossOriginResourcePolicy: { policy: "cross-origin" }, + referrerPolicy: { policy: "strict-origin-when-cross-origin" }, + noSniff: true, + frameguard: { action: 'deny' }, + hsts: process.env.NODE_ENV === 'production' ? { + maxAge: 31536000, + includeSubDomains: true, + preload: true + } : false, + xssFilter: true, +})); + +// Permissions-Policy header configuration +app.use((req, res, next) => { + res.setHeader( + 'Permissions-Policy', + 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()' + ); + next(); +}); + app.use(express.json()); // Database Connection @@ -70,6 +112,11 @@ app.get('/api/health', (req: Request, res: Response) => { res.json({ status: 'ok', message: 'Server is healthy' }); }); -app.listen(port, () => { - console.log(`[server]: Server is running at http://localhost:${port}`); -}); +let server: any; +if (process.env.NODE_ENV !== 'test') { + server = app.listen(port, () => { + console.log(`[server]: Server is running at http://localhost:${port}`); + }); +} + +export { app, server }; diff --git a/backend/tests/securityHeaders.test.ts b/backend/tests/securityHeaders.test.ts new file mode 100644 index 0000000..c1f83e6 --- /dev/null +++ b/backend/tests/securityHeaders.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import http from 'http'; + +// Mock database connection config to avoid real MongoDB connections during test runs +vi.mock('../src/config/db', () => ({ + __esModule: true, + prisma: { + user: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + } + }, + default: vi.fn(), +})); + +import { app } from '../src/server'; + +describe('Security Headers Integration Test', () => { + let server: http.Server; + let url: string; + + beforeAll(() => { + return new Promise((resolve) => { + // Spin up the app on an ephemeral dynamic port + server = app.listen(0, () => { + const address = server.address(); + const port = typeof address === 'string' ? 0 : address?.port; + url = `http://localhost:${port}`; + resolve(); + }); + }); + }); + + afterAll(() => { + return new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + it('should set appropriate security headers on /api/health', async () => { + const response = await fetch(`${url}/api/health`); + expect(response.status).toBe(200); + + const headers = response.headers; + + // Express default fingerprint header X-Powered-By must be removed + expect(headers.get('x-powered-by')).toBeNull(); + + // Content-Security-Policy (CSP) custom directives + const csp = headers.get('content-security-policy'); + expect(csp).not.toBeNull(); + expect(csp).toContain("default-src 'none'"); + expect(csp).toContain("frame-ancestors 'none'"); + + // Cross-Origin-Opener-Policy (COOP) + expect(headers.get('cross-origin-opener-policy')).toBe('same-origin-allow-popups'); + + // Cross-Origin-Resource-Policy (CORP) + expect(headers.get('cross-origin-resource-policy')).toBe('cross-origin'); + + // Referrer-Policy + expect(headers.get('referrer-policy')).toBe('strict-origin-when-cross-origin'); + + // X-Content-Type-Options + expect(headers.get('x-content-type-options')).toBe('nosniff'); + + // X-Frame-Options + expect(headers.get('x-frame-options')).toBe('DENY'); + + // Permissions-Policy + const permissions = headers.get('permissions-policy'); + expect(permissions).not.toBeNull(); + expect(permissions).toContain('accelerometer=()'); + expect(permissions).toContain('camera=()'); + expect(permissions).toContain('microphone=()'); + }); +});