changes added wih for VAPT compatibility

This commit is contained in:
laxmanhalaki 2026-02-07 14:59:17 +05:30
parent bdfda74167
commit a06a1bfeec
28 changed files with 478 additions and 981 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
import{a as s}from"./index-x1JLuWho.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-DbB0YGPu.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B1UBYWWO.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion}; import{a as s}from"./index-BFWo0Bu2.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-DyksGUTu.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
//# sourceMappingURL=conclusionApi-DX2Gwh6C.js.map //# sourceMappingURL=conclusionApi-Cixl24m2.js.map

View File

@ -1 +1 @@
{"version":3,"file":"conclusionApi-DX2Gwh6C.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n * Returns null if conclusion doesn't exist (404) instead of throwing error\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark | null> {\r\n try {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n } catch (error: any) {\r\n // Handle 404 gracefully - conclusion doesn't exist yet, which is normal\r\n if (error.response?.status === 404) {\r\n return null;\r\n }\r\n // Re-throw other errors\r\n throw error;\r\n }\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion","error","_a"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAMA,eAAsBC,EAAcJ,EAAqD,OACvF,GAAI,CAEF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB,OAASK,EAAY,CAEnB,KAAIC,EAAAD,EAAM,WAAN,YAAAC,EAAgB,UAAW,IAC7B,OAAO,KAGT,MAAMD,CACR,CACF"} {"version":3,"file":"conclusionApi-Cixl24m2.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n * Returns null if conclusion doesn't exist (404) instead of throwing error\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark | null> {\r\n try {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n } catch (error: any) {\r\n // Handle 404 gracefully - conclusion doesn't exist yet, which is normal\r\n if (error.response?.status === 404) {\r\n return null;\r\n }\r\n // Re-throw other errors\r\n throw error;\r\n }\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion","error","_a"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAMA,eAAsBC,EAAcJ,EAAqD,OACvF,GAAI,CAEF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB,OAASK,EAAY,CAEnB,KAAIC,EAAAD,EAAM,WAAN,YAAAC,EAAgB,UAAW,IAC7B,OAAO,KAGT,MAAMD,CACR,CACF"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,69 +1,31 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" />
<!-- CSP: Allows blob URLs for file previews and cross-origin API calls during development -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: https: blob:; connect-src 'self' blob: data: http://localhost:5000 http://localhost:3000 ws://localhost:5000 ws://localhost:3000 wss://localhost:5000 wss://localhost:3000; frame-src 'self' blob:; font-src 'self' https://fonts.gstatic.com data:; object-src 'none'; base-uri 'self'; form-action 'self';" />
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title>
<!-- Preload critical fonts and icons --> <head>
<link rel="preconnect" href="https://fonts.googleapis.com"> <meta charset="UTF-8" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description"
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title>
<!-- Ensure proper icon rendering and layout --> <!-- Preload critical fonts and icons -->
<style> <link rel="preconnect" href="https://fonts.googleapis.com">
/* Ensure Lucide icons render properly */ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
svg { <script type="module" crossorigin src="/assets/index-BFWo0Bu2.js"></script>
display: inline-block; <link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
vertical-align: middle; <link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
} <link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DyksGUTu.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
<link rel="stylesheet" crossorigin href="/assets/index-D5NCgjQR.css">
</head>
/* Fix for icon alignment in buttons */ <body>
button svg { <div id="root"></div>
flex-shrink: 0; </body>
}
/* Ensure proper text rendering */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Fix for mobile viewport and sidebar */
@media (max-width: 768px) {
html {
overflow-x: hidden;
}
}
/* Ensure proper sidebar toggle behavior */
.sidebar-toggle {
transition: all 0.3s ease-in-out;
}
/* Fix for icon button hover states */
button:hover svg {
transform: scale(1.05);
transition: transform 0.2s ease;
}
</style>
<script type="module" crossorigin src="/assets/index-x1JLuWho.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Bme4E5cb.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DIkYAdWy.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DbB0YGPu.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B1UBYWWO.js">
<link rel="stylesheet" crossorigin href="/assets/index-CNlPctO6.css">
</head>
<body>
<div id="root"></div>
</body>
</html> </html>

29
package-lock.json generated
View File

@ -42,6 +42,7 @@
"web-push": "^3.6.7", "web-push": "^3.6.7",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-loki": "^6.1.3", "winston-loki": "^6.1.3",
"xss": "^1.0.15",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
@ -5488,6 +5489,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/cssfilter": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
"integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@ -12120,6 +12127,28 @@
} }
} }
}, },
"node_modules/xss": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
"integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==",
"license": "MIT",
"dependencies": {
"commander": "^2.20.3",
"cssfilter": "0.0.10"
},
"bin": {
"xss": "bin/xss"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/xss/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -56,6 +56,7 @@
"web-push": "^3.6.7", "web-push": "^3.6.7",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-loki": "^6.1.3", "winston-loki": "^6.1.3",
"xss": "^1.0.15",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,74 @@
const axios = require('axios');
const BASE_URL = 'http://localhost:3000';
async function verifySecurity() {
try {
console.log('--- Verifying Security Fixes ---');
console.log('\n1. Verifying Security Headers...');
const response = await axios.get(`${BASE_URL}/health`);
const headers = response.headers;
console.log('\n1b. Verifying Security Headers on 404...');
try {
const res404 = await axios.get(`${BASE_URL}/non-existent`, { validateStatus: false });
console.log('404 Status:', res404.status);
console.log('404 CSP:', res404.headers['content-security-policy']);
console.log('\n1c. Verifying Security Headers on /assets (Redirect check)...');
const resAssets = await axios.get(`${BASE_URL}/assets`, {
validateStatus: false,
maxRedirects: 0 // Don't follow to see the first response (likely 301)
});
console.log('Assets Status:', resAssets.status);
console.log('Assets CSP:', resAssets.headers['content-security-policy']);
} catch (e) {
console.log('Error checking 404/Redirect:', e.message);
if (e.response) {
console.log('Response Status:', e.response.status);
console.log('Response CSP:', e.response.headers['content-security-policy']);
}
}
// Check CSP
const csp = headers['content-security-policy'];
console.log('CSP:', csp);
if (csp && csp.includes("frame-ancestors 'self'")) {
console.log('✅ Clickjacking Protection (frame-ancestors) is present.');
} else {
console.log('❌ Clickjacking Protection (frame-ancestors) is MISSING.');
}
// Check X-Frame-Options
const xfo = headers['x-frame-options'];
console.log('X-Frame-Options:', xfo);
if (xfo === 'SAMEORIGIN') {
console.log('✅ X-Frame-Options: SAMEORIGIN is present.');
} else {
console.log('❌ X-Frame-Options: SAMEORIGIN is MISSING.');
}
console.log('\n2. Verifying Cookie Security Flags (requires login)...');
console.log('Note: This is best verified in a real browser or by checking the code changes in auth.controller.ts.');
console.log('\n3. Verifying Sanitization Utility...');
// This is verified by the unit test if we create one, but we can also do a manual check if the server is running.
console.log('\n--- Verification Summary ---');
console.log('Content-Security-Policy: frame-ancestors added.');
console.log('X-Frame-Options: set to SAMEORIGIN.');
console.log('Cookie flags: sameSite set to lax, secure flag ensured in production.');
console.log('Sanitization: Implemented in WorkNotes, Holidays, Workflow Requests, and Conclusions.');
} catch (error) {
if (error.code === 'ECONNREFUSED') {
console.error('❌ Error: Could not connect to the backend server at', BASE_URL);
console.error('Please ensure the server is running (npm run dev).');
} else {
console.error('❌ Error during verification:', error.message);
}
}
}
verifySecurity();

View File

@ -19,6 +19,65 @@ dotenv.config();
// Secrets are now initialized in server.ts before app is imported // Secrets are now initialized in server.ts before app is imported
const app: express.Application = express(); const app: express.Application = express();
// 1. Security middleware - Manual "Gold Standard" CSP to ensure it survives 301/404/etc.
// This handles a specific Express/Helmet edge case where redirects lose headers.
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
const isDev = process.env.NODE_ENV !== 'production';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
// Build connect-src dynamically
const connectSrc = ["'self'", "blob:", "data:"];
if (isDev) {
connectSrc.push("http://localhost:3000", "http://localhost:5000", "ws://localhost:3000", "ws://localhost:5000");
if (frontendUrl.includes('localhost')) connectSrc.push(frontendUrl);
} else if (frontendUrl && frontendUrl !== '*') {
const origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
connectSrc.push(...origins);
}
// Define strict CSP directives
const directives = [
"default-src 'none'",
// sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU= is for empty <style> tags injected by libraries
"style-src 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='",
"style-src-elem 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='",
// style-src-attr 'unsafe-inline' is required for React's dynamic style prop (Progress bars, Framer Motion, layout logic)
"style-src-attr 'unsafe-inline'",
"script-src 'self'",
"script-src-elem 'self'",
"script-src-attr 'none'",
"img-src 'self' data: blob: https://*.royalenfield.com https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com",
`connect-src ${connectSrc.join(' ')}`,
"frame-src 'self' blob: data:",
"font-src 'self' https://fonts.gstatic.com data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
"worker-src 'self' blob:",
"manifest-src 'self'",
!isDev ? "upgrade-insecure-requests" : ""
].filter(Boolean).join("; ");
res.setHeader('Content-Security-Policy', directives);
next();
});
// Configure other security headers via Helmet (with CSP disabled since we set it manually)
app.use(helmet({
contentSecurityPolicy: false, // Handled manually above to ensure redirect compatibility
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" },
xFrameOptions: { action: "sameorigin" },
}));
// 2. CORS middleware - MUST be before other middleware
app.use(corsMiddleware);
// 3. Cookie parser middleware - MUST be before routes
app.use(cookieParser());
const userService = new UserService(); const userService = new UserService();
// Initialize database connection // Initialize database connection
@ -43,61 +102,6 @@ if (process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production')
app.set('trust proxy', 1); app.set('trust proxy', 1);
} }
// CORS middleware - MUST be before other middleware
app.use(corsMiddleware);
// Security middleware - Configure Helmet to work with CORS
// Get frontend URL for CSP - allow cross-origin connections in development
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
const isDevelopment = process.env.NODE_ENV !== 'production';
// Build connect-src directive - allow backend API and blob URLs
const connectSrc = ["'self'", "blob:", "data:"];
if (isDevelopment) {
// In development, allow connections to common dev ports
connectSrc.push("http://localhost:3000", "http://localhost:5000", "ws://localhost:3000", "ws://localhost:5000");
// Also allow the configured frontend URL if it's a localhost URL
if (frontendUrl.includes('localhost')) {
connectSrc.push(frontendUrl);
}
} else {
// In production, only allow the configured frontend URL
if (frontendUrl && frontendUrl !== '*') {
const frontendOrigins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
connectSrc.push(...frontendOrigins);
}
}
// Build CSP directives - conditionally include upgradeInsecureRequests
const cspDirectives: any = {
defaultSrc: ["'self'", "blob:"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
connectSrc: connectSrc,
frameSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
};
// Only add upgradeInsecureRequests in production (it forces HTTPS)
if (!isDevelopment) {
cspDirectives.upgradeInsecureRequests = [];
}
app.use(helmet({
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" },
contentSecurityPolicy: {
directives: cspDirectives,
},
}));
// Cookie parser middleware - MUST be before routes
app.use(cookieParser());
// Body parsing middleware // Body parsing middleware
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' }));

View File

@ -8,6 +8,7 @@ import logger from '@utils/logger';
import { initializeHolidaysCache, clearWorkingHoursCache } from '@utils/tatTimeUtils'; import { initializeHolidaysCache, clearWorkingHoursCache } from '@utils/tatTimeUtils';
import { clearConfigCache } from '@services/configReader.service'; import { clearConfigCache } from '@services/configReader.service';
import { User, UserRole } from '@models/User'; import { User, UserRole } from '@models/User';
import { sanitizeHtml } from '@utils/sanitizer';
/** /**
* Get all holidays (with optional year filter) * Get all holidays (with optional year filter)
@ -103,7 +104,7 @@ export const createHoliday = async (req: Request, res: Response): Promise<void>
const holiday = await holidayService.createHoliday({ const holiday = await holidayService.createHoliday({
holidayDate, holidayDate,
holidayName, holidayName,
description, description: description ? sanitizeHtml(description) : description,
holidayType: holidayType || HolidayType.ORGANIZATIONAL, holidayType: holidayType || HolidayType.ORGANIZATIONAL,
isRecurring: isRecurring || false, isRecurring: isRecurring || false,
recurrenceRule, recurrenceRule,
@ -145,6 +146,9 @@ export const updateHoliday = async (req: Request, res: Response): Promise<void>
const { holidayId } = req.params; const { holidayId } = req.params;
const updates = req.body; const updates = req.body;
if (updates.description) {
updates.description = sanitizeHtml(updates.description);
}
const holiday = await holidayService.updateHoliday(holidayId, updates, userId); const holiday = await holidayService.updateHoliday(holidayId, updates, userId);
@ -389,7 +393,7 @@ export const updateConfiguration = async (req: Request, res: Response): Promise<
} }
const { configKey } = req.params; const { configKey } = req.params;
const { configValue } = req.body; let { configValue } = req.body;
if (configValue === undefined) { if (configValue === undefined) {
res.status(400).json({ res.status(400).json({
@ -399,6 +403,12 @@ export const updateConfiguration = async (req: Request, res: Response): Promise<
return; return;
} }
// Sanitize config value if it's likely to be rendered as HTML
// We can be selective or just sanitize all strings for safety
if (typeof configValue === 'string') {
configValue = sanitizeHtml(configValue);
}
// Update configuration // Update configuration
const result = await sequelize.query(` const result = await sequelize.query(`
UPDATE admin_configurations UPDATE admin_configurations

View File

@ -135,7 +135,7 @@ export class AuthController {
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isProduction,
sameSite: isProduction ? 'none' as const : 'lax' as const, // 'none' for cross-domain in production sameSite: isProduction ? 'lax' as const : 'lax' as const, // 'lax' is safer and works on same-domain
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
}; };
@ -209,7 +209,7 @@ export class AuthController {
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isProduction,
sameSite: isProduction ? ('none' as const) : ('lax' as const), sameSite: isProduction ? ('lax' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
path: '/', path: '/',
}; };
@ -259,7 +259,7 @@ export class AuthController {
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isProduction,
sameSite: isProduction ? ('none' as const) : ('lax' as const), sameSite: isProduction ? ('lax' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000, maxAge: 24 * 60 * 60 * 1000,
path: '/', path: '/',
}; };
@ -472,7 +472,7 @@ export class AuthController {
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isProduction,
sameSite: isProduction ? 'none' as const : 'lax' as const, sameSite: isProduction ? 'lax' as const : 'lax' as const,
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
}; };
@ -552,7 +552,7 @@ export class AuthController {
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isProduction,
sameSite: isProduction ? 'none' as const : 'lax' as const, // 'none' for cross-domain in production sameSite: isProduction ? 'lax' as const : 'lax' as const, // 'lax' for same-domain
maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token
}; };

View File

@ -4,6 +4,7 @@ import { aiService } from '@services/ai.service';
import { activityService } from '@services/activity.service'; import { activityService } from '@services/activity.service';
import logger from '@utils/logger'; import logger from '@utils/logger';
import { getRequestMetadata } from '@utils/requestUtils'; import { getRequestMetadata } from '@utils/requestUtils';
import { sanitizeHtml } from '@utils/sanitizer';
export class ConclusionController { export class ConclusionController {
/** /**
@ -205,8 +206,8 @@ export class ConclusionController {
// Provide helpful error messages // Provide helpful error messages
const isConfigError = error.message?.includes('not configured') || const isConfigError = error.message?.includes('not configured') ||
error.message?.includes('not available') || error.message?.includes('not available') ||
error.message?.includes('not initialized'); error.message?.includes('not initialized');
return res.status(isConfigError ? 503 : 500).json({ return res.status(isConfigError ? 503 : 500).json({
error: isConfigError ? 'AI service not configured' : 'Failed to generate conclusion', error: isConfigError ? 'AI service not configured' : 'Failed to generate conclusion',
@ -249,9 +250,10 @@ export class ConclusionController {
// Update conclusion // Update conclusion
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark; const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
const sanitizedRemark = sanitizeHtml(finalRemark);
await conclusion.update({ await conclusion.update({
finalRemark: finalRemark, finalRemark: sanitizedRemark,
editedBy: userId, editedBy: userId,
isEdited: wasEdited, isEdited: wasEdited,
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount
@ -283,6 +285,8 @@ export class ConclusionController {
return res.status(400).json({ error: 'Final remark is required' }); return res.status(400).json({ error: 'Final remark is required' });
} }
const sanitizedRemark = sanitizeHtml(finalRemark);
// Fetch request // Fetch request
const request = await WorkflowRequest.findOne({ const request = await WorkflowRequest.findOne({
where: { requestId }, where: { requestId },
@ -315,7 +319,7 @@ export class ConclusionController {
aiGeneratedRemark: null, aiGeneratedRemark: null,
aiModelUsed: null, aiModelUsed: null,
aiConfidenceScore: null, aiConfidenceScore: null,
finalRemark: finalRemark, finalRemark: sanitizedRemark,
editedBy: userId, editedBy: userId,
isEdited: false, isEdited: false,
editCount: 0, editCount: 0,
@ -330,7 +334,7 @@ export class ConclusionController {
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark; const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
await conclusion.update({ await conclusion.update({
finalRemark: finalRemark, finalRemark: sanitizedRemark,
editedBy: userId, editedBy: userId,
isEdited: wasEdited, isEdited: wasEdited,
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount, editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount,
@ -341,7 +345,7 @@ export class ConclusionController {
// Update request status to CLOSED // Update request status to CLOSED
await request.update({ await request.update({
status: 'CLOSED', status: 'CLOSED',
conclusionRemark: finalRemark, conclusionRemark: sanitizedRemark,
closureDate: new Date() closureDate: new Date()
} as any); } as any);

View File

@ -19,6 +19,7 @@ import { notificationService } from './notification.service';
import { activityService } from './activity.service'; import { activityService } from './activity.service';
import { tatSchedulerService } from './tatScheduler.service'; import { tatSchedulerService } from './tatScheduler.service';
import { emitToRequestRoom } from '../realtime/socket'; import { emitToRequestRoom } from '../realtime/socket';
import { sanitizeHtml } from '@utils/sanitizer';
export class WorkflowService { export class WorkflowService {
/** /**
@ -2458,8 +2459,8 @@ export class WorkflowService {
requestNumber, requestNumber,
initiatorId, initiatorId,
templateType: workflowData.templateType, templateType: workflowData.templateType,
title: workflowData.title, title: workflowData.title ? sanitizeHtml(workflowData.title) : workflowData.title,
description: workflowData.description, description: workflowData.description ? sanitizeHtml(workflowData.description) : workflowData.description,
priority: workflowData.priority, priority: workflowData.priority,
currentLevel: 1, currentLevel: 1,
totalLevels: workflowData.approvalLevels.length, totalLevels: workflowData.approvalLevels.length,
@ -3109,6 +3110,14 @@ export class WorkflowService {
async updateWorkflow(requestId: string, updateData: UpdateWorkflowRequest): Promise<WorkflowRequest | null> { async updateWorkflow(requestId: string, updateData: UpdateWorkflowRequest): Promise<WorkflowRequest | null> {
try { try {
// Sanitize title and description if provided
if (updateData.title) {
updateData.title = sanitizeHtml(updateData.title);
}
if (updateData.description) {
updateData.description = sanitizeHtml(updateData.description);
}
const workflow = await this.findWorkflowByIdentifier(requestId); const workflow = await this.findWorkflowByIdentifier(requestId);
if (!workflow) return null; if (!workflow) return null;

View File

@ -12,6 +12,7 @@ import { gcsStorageService } from './gcsStorage.service';
import logger from '@utils/logger'; import logger from '@utils/logger';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { sanitizeHtml } from '@utils/sanitizer';
export class WorkNoteService { export class WorkNoteService {
async list(requestId: string) { async list(requestId: string) {
@ -85,7 +86,7 @@ export class WorkNoteService {
userId: user.userId, userId: user.userId,
userName: user.name || null, userName: user.name || null,
userRole: user.role || null, // Store participant type (INITIATOR/APPROVER/SPECTATOR) userRole: user.role || null, // Store participant type (INITIATOR/APPROVER/SPECTATOR)
message: payload.message, message: sanitizeHtml(payload.message),
isPriority: !!payload.isPriority, isPriority: !!payload.isPriority,
parentNoteId: payload.parentNoteId || null, parentNoteId: payload.parentNoteId || null,
mentionedUsers: payload.mentionedUsers || null, mentionedUsers: payload.mentionedUsers || null,

27
src/types/xss.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
declare module 'xss' {
export interface IFilterXSSOptions {
whiteList?: IWhiteList;
onTag?: (tag: string, html: string, options: any) => string;
onTagAttr?: (tag: string, name: string, value: string, isWhiteAttr: boolean) => string;
onIgnoreTag?: (tag: string, html: string, options: any) => string;
onIgnoreTagAttr?: (tag: string, name: string, value: string, isWhiteAttr: boolean) => string;
escapeHtml?: (html: string) => string;
stripIgnoreTag?: boolean;
stripIgnoreTagBody?: boolean | string[];
allowCommentTag?: boolean;
}
export interface IWhiteList {
[key: string]: string[];
}
export const whiteList: IWhiteList;
export class FilterXSS {
constructor(options?: IFilterXSSOptions);
process(html: string): string;
}
export function filterXSS(html: string, options?: IFilterXSSOptions): string;
export default filterXSS;
}

71
src/utils/sanitizer.ts Normal file
View File

@ -0,0 +1,71 @@
import { FilterXSS, whiteList } from 'xss';
/**
* Sanitizes HTML content to prevent XSS attacks while allowing safe tags and attributes.
* This is particularly important for content rendered with dangerouslySetInnerHTML.
*
* @param html The raw HTML string to sanitize
* @returns The sanitized HTML string
*/
export const sanitizeHtml = (html: string): string => {
if (!html) return '';
// Custom options can be added here if we need to allow specific tags or attributes
// For now, using default options which are quite secure
const options = {
whiteList: {
...whiteList,
// Add any specific tags or attributes required by the frontend
'span': ['style', 'class'],
'div': ['style', 'class'],
'p': ['style', 'class'],
'br': [],
'b': [],
'i': [],
'u': [],
'strong': [],
'em': [],
'ul': ['style', 'class'],
'ol': ['style', 'class'],
'li': ['style', 'class'],
'h1': ['style', 'class'],
'h2': ['style', 'class'],
'h3': ['style', 'class'],
'h4': ['style', 'class'],
'h5': ['style', 'class'],
'h6': ['style', 'class'],
'blockquote': ['style', 'class'],
}
};
const xssFilter = new FilterXSS(options);
return xssFilter.process(html);
};
/**
* Sanitizes an object by recursively sanitizing all string properties.
* Useful for sanitizing request bodies or complex nested structures.
*
* @param obj The object to sanitize
* @returns The sanitized object
*/
export const sanitizeObject = <T>(obj: T): T => {
if (!obj || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) {
return obj.map(item => sanitizeObject(item)) as any;
}
const sanitized: any = {};
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string') {
sanitized[key] = sanitizeHtml(value);
} else if (typeof value === 'object' && value !== null) {
sanitized[key] = sanitizeObject(value);
} else {
sanitized[key] = value;
}
}
return sanitized as T;
};