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};
//# sourceMappingURL=conclusionApi-DX2Gwh6C.js.map
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-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>
<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 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Ensure proper icon rendering and layout -->
<style>
/* Ensure Lucide icons render properly */
svg {
display: inline-block;
vertical-align: middle;
}
/* Fix for icon alignment in buttons */
button svg {
flex-shrink: 0;
}
/* 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>
<head>
<meta charset="UTF-8" />
<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 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-BFWo0Bu2.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
<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>
<body>
<div id="root"></div>
</body>
</html>

29
package-lock.json generated
View File

@ -42,6 +42,7 @@
"web-push": "^3.6.7",
"winston": "^3.17.0",
"winston-loki": "^6.1.3",
"xss": "^1.0.15",
"zod": "^3.24.1"
},
"devDependencies": {
@ -5488,6 +5489,12 @@
"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": {
"version": "4.0.1",
"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": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -56,6 +56,7 @@
"web-push": "^3.6.7",
"winston": "^3.17.0",
"winston-loki": "^6.1.3",
"xss": "^1.0.15",
"zod": "^3.24.1"
},
"devDependencies": {
@ -92,4 +93,4 @@
"node": ">=22.0.0",
"npm": ">=10.0.0"
}
}
}

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
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();
// Initialize database connection
@ -43,61 +102,6 @@ if (process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production')
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
app.use(express.json({ 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 { clearConfigCache } from '@services/configReader.service';
import { User, UserRole } from '@models/User';
import { sanitizeHtml } from '@utils/sanitizer';
/**
* 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({
holidayDate,
holidayName,
description,
description: description ? sanitizeHtml(description) : description,
holidayType: holidayType || HolidayType.ORGANIZATIONAL,
isRecurring: isRecurring || false,
recurrenceRule,
@ -145,6 +146,9 @@ export const updateHoliday = async (req: Request, res: Response): Promise<void>
const { holidayId } = req.params;
const updates = req.body;
if (updates.description) {
updates.description = sanitizeHtml(updates.description);
}
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 { configValue } = req.body;
let { configValue } = req.body;
if (configValue === undefined) {
res.status(400).json({
@ -399,6 +403,12 @@ export const updateConfiguration = async (req: Request, res: Response): Promise<
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
const result = await sequelize.query(`
UPDATE admin_configurations
@ -425,14 +435,14 @@ export const updateConfiguration = async (req: Request, res: Response): Promise<
// Clear config cache so new values are used immediately
clearConfigCache();
// If working hours config was updated, also clear working hours cache
const workingHoursKeys = ['WORK_START_HOUR', 'WORK_END_HOUR', 'WORK_START_DAY', 'WORK_END_DAY'];
if (workingHoursKeys.includes(configKey)) {
await clearWorkingHoursCache();
logger.info(`[Admin] Working hours configuration '${configKey}' updated - cache cleared and reloaded`);
}
// If AI config was updated, reinitialize AI service
const aiConfigKeys = ['AI_ENABLED'];
if (aiConfigKeys.includes(configKey)) {
@ -479,7 +489,7 @@ export const resetConfiguration = async (req: Request, res: Response): Promise<v
// Clear config cache so reset values are used immediately
clearConfigCache();
// If working hours config was reset, also clear working hours cache
const workingHoursKeys = ['WORK_START_HOUR', 'WORK_END_HOUR', 'WORK_START_DAY', 'WORK_END_DAY'];
if (workingHoursKeys.includes(configKey)) {
@ -521,7 +531,7 @@ export const updateUserRole = async (req: Request, res: Response): Promise<void>
try {
const { userId } = req.params;
const { role } = req.body;
// Validate role
const validRoles: UserRole[] = ['USER', 'MANAGEMENT', 'ADMIN'];
if (!role || !validRoles.includes(role)) {
@ -531,7 +541,7 @@ export const updateUserRole = async (req: Request, res: Response): Promise<void>
});
return;
}
// Find user
const user = await User.findByPk(userId);
if (!user) {
@ -541,10 +551,10 @@ export const updateUserRole = async (req: Request, res: Response): Promise<void>
});
return;
}
// Store old role for logging
const oldRole = user.role;
// Prevent self-demotion from ADMIN (safety check)
const adminUser = req.user;
if (adminUser?.userId === userId && role !== 'ADMIN') {
@ -554,13 +564,13 @@ export const updateUserRole = async (req: Request, res: Response): Promise<void>
});
return;
}
// Update role
user.role = role;
await user.save();
logger.info(`✅ User role updated by ${adminUser?.email}: ${user.email} - ${oldRole}${role}`);
res.json({
success: true,
message: `User role updated from ${oldRole} to ${role}`,
@ -597,13 +607,13 @@ export const updateUserRole = async (req: Request, res: Response): Promise<void>
export const getUsersByRole = async (req: Request, res: Response): Promise<void> => {
try {
const { role, page = '1', limit = '10' } = req.query;
const pageNum = parseInt(page as string) || 1;
const limitNum = Math.min(parseInt(limit as string) || 10, 100); // Max 100 per page
const offset = (pageNum - 1) * limitNum;
const whereClause: any = { isActive: true };
// Handle role filtering
if (role && role !== 'ALL' && role !== 'ELEVATED') {
const validRoles: UserRole[] = ['USER', 'MANAGEMENT', 'ADMIN'];
@ -620,11 +630,11 @@ export const getUsersByRole = async (req: Request, res: Response): Promise<void>
whereClause.role = { [Op.in]: ['ADMIN', 'MANAGEMENT'] };
}
// If role === 'ALL', don't filter by role (show all users)
// Get total count for pagination
const totalUsers = await User.count({ where: whereClause });
const totalPages = Math.ceil(totalUsers / limitNum);
// Get paginated users
const users = await User.findAll({
where: whereClause,
@ -649,7 +659,7 @@ export const getUsersByRole = async (req: Request, res: Response): Promise<void>
limit: limitNum,
offset: offset
});
// Get role summary (across all users, not just current page)
const roleStats = await sequelize.query(`
SELECT
@ -667,13 +677,13 @@ export const getUsersByRole = async (req: Request, res: Response): Promise<void>
`, {
type: QueryTypes.SELECT
});
const summary = {
ADMIN: parseInt((roleStats.find((s: any) => s.role === 'ADMIN') as any)?.count || '0'),
MANAGEMENT: parseInt((roleStats.find((s: any) => s.role === 'MANAGEMENT') as any)?.count || '0'),
USER: parseInt((roleStats.find((s: any) => s.role === 'USER') as any)?.count || '0')
};
res.json({
success: true,
data: {
@ -725,7 +735,7 @@ export const getRoleStatistics = async (req: Request, res: Response): Promise<vo
`, {
type: QueryTypes.SELECT
});
res.json({
success: true,
data: {
@ -782,7 +792,7 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
if (!user) {
// User doesn't exist, need to fetch from Okta and create
logger.info(`[Admin] User ${email} not found in database, fetching from Okta...`);
// Import UserService to fetch full profile from Okta
const { UserService } = await import('@services/user.service');
const userService = new UserService();
@ -790,7 +800,7 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
try {
// Fetch full user profile from Okta Users API (includes manager, jobTitle, etc.)
const oktaUserData = await userService.fetchAndExtractOktaUserByEmail(email);
if (!oktaUserData) {
res.status(404).json({
success: false,
@ -836,7 +846,7 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
try {
// Fetch full user profile from Okta Users API to sync manager and other fields
const oktaUserData = await userService.fetchAndExtractOktaUserByEmail(email);
if (oktaUserData) {
// Sync all fields from Okta including the new role using centralized method
const updated = await userService.createOrUpdateUser({
@ -845,7 +855,7 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
isActive: true, // Ensure user is active
});
user = updated;
logger.info(`[Admin] Synced user ${email} from Okta (manager: ${oktaUserData.manager || 'N/A'}) and updated role from ${previousRole} to ${role}`);
} else {
// Okta user not found, just update role

View File

@ -22,18 +22,18 @@ export class AuthController {
try {
// Validate request body
const validatedData = validateSSOCallback(req.body);
const result = await this.authService.handleSSOCallback(validatedData as any);
// Log login activity
const requestMeta = getRequestMetadata(req);
await activityService.log({
requestId: SYSTEM_EVENT_REQUEST_ID, // Special UUID for system events
type: 'login',
user: {
userId: result.user.userId,
user: {
userId: result.user.userId,
name: result.user.displayName || result.user.email,
email: result.user.email
email: result.user.email
},
timestamp: new Date().toISOString(),
action: 'User Login',
@ -49,7 +49,7 @@ export class AuthController {
category: 'AUTHENTICATION',
severity: 'INFO'
});
ResponseHandler.success(res, {
user: result.user,
accessToken: result.accessToken,
@ -69,7 +69,7 @@ export class AuthController {
async getCurrentUser(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const user = await this.authService.getUserProfile(req.user.userId);
if (!user) {
ResponseHandler.notFound(res, 'User not found');
return;
@ -109,7 +109,7 @@ export class AuthController {
try {
// Try to get refresh token from request body first, then from cookies
let refreshToken: string | undefined;
if (req.body?.refreshToken) {
const validated = validateRefreshToken(req.body);
refreshToken = validated.refreshToken;
@ -117,30 +117,30 @@ export class AuthController {
// Fallback to cookie if available (requires cookie-parser middleware)
refreshToken = (req as any).cookies.refreshToken;
}
if (!refreshToken) {
res.status(400).json({
success: false,
res.status(400).json({
success: false,
error: 'Refresh token is required in request body or cookies',
message: 'Request body validation failed',
timestamp: new Date().toISOString()
});
return;
}
const newAccessToken = await this.authService.refreshAccessToken(refreshToken);
// Set new access token in cookie if using cookie-based auth
const isProduction = process.env.NODE_ENV === 'production';
const cookieOptions = {
httpOnly: true,
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
};
res.cookie('accessToken', newAccessToken, cookieOptions);
// SECURITY: In production, don't return token in response body
// Token is securely stored in httpOnly cookie
if (isProduction) {
@ -173,21 +173,21 @@ export class AuthController {
state: req.body?.state ? 'PRESENT' : 'MISSING',
},
});
const { code, redirectUri } = validateTokenExchange(req.body);
logger.info('Tanflow token exchange validation passed', { redirectUri });
const result = await this.authService.exchangeTanflowCodeForTokens(code, redirectUri);
// Log login activity
const requestMeta = getRequestMetadata(req);
await activityService.log({
requestId: SYSTEM_EVENT_REQUEST_ID,
type: 'login',
user: {
userId: result.user.userId,
user: {
userId: result.user.userId,
name: result.user.displayName || result.user.email,
email: result.user.email
email: result.user.email
},
timestamp: new Date().toISOString(),
action: 'User Login',
@ -203,20 +203,20 @@ export class AuthController {
category: 'AUTHENTICATION',
severity: 'INFO'
});
// Set tokens in httpOnly cookies (production) or return in body (development)
const isProduction = process.env.NODE_ENV === 'production';
const cookieOptions = {
httpOnly: true,
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
path: '/',
};
res.cookie('accessToken', result.accessToken, cookieOptions);
res.cookie('refreshToken', result.refreshToken, cookieOptions);
// In production, don't return tokens in response body (security)
// In development, include tokens for cross-port setup
if (isProduction) {
@ -246,26 +246,26 @@ export class AuthController {
async refreshTanflowToken(req: Request, res: Response): Promise<void> {
try {
const refreshToken = req.body?.refreshToken;
if (!refreshToken) {
ResponseHandler.error(res, 'Refresh token is required', 400, 'Refresh token is required in request body');
return;
}
const newAccessToken = await this.authService.refreshTanflowToken(refreshToken);
// Set new access token in cookie
const isProduction = process.env.NODE_ENV === 'production';
const cookieOptions = {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? ('none' as const) : ('lax' as const),
sameSite: isProduction ? ('lax' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000,
path: '/',
};
res.cookie('accessToken', newAccessToken, cookieOptions);
if (isProduction) {
ResponseHandler.success(res, {
message: 'Token refreshed successfully'
@ -290,11 +290,11 @@ export class AuthController {
*/
async logout(req: Request, res: Response): Promise<void> {
const isProduction = process.env.NODE_ENV === 'production';
// Helper function to clear cookies with all possible option combinations
const clearCookiesCompletely = () => {
const cookieNames = ['accessToken', 'refreshToken'];
// Get the EXACT options used when setting cookies (from exchangeToken)
// These MUST match exactly: httpOnly, secure, sameSite, path
const cookieOptions = {
@ -371,7 +371,7 @@ export class AuthController {
// User might be null if token was invalid/expired
const userId = req.user?.userId || 'unknown';
const email = req.user?.email || 'unknown';
logger.info('User logout initiated', {
userId,
email,
@ -393,14 +393,14 @@ export class AuthController {
} catch (error) {
logger.error('Logout failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Even on error, try to clear cookies as last resort
try {
clearCookiesCompletely();
} catch (cookieError) {
logger.error('Error clearing cookies in catch block:', cookieError);
}
ResponseHandler.error(res, 'Logout failed', 500, errorMessage);
}
}
@ -439,18 +439,18 @@ export class AuthController {
});
const { username, password } = validatePasswordLogin(req.body);
const result = await this.authService.authenticateWithPassword(username, password);
// Log login activity
const requestMeta = getRequestMetadata(req);
await activityService.log({
requestId: SYSTEM_EVENT_REQUEST_ID,
type: 'login',
user: {
userId: result.user.userId,
user: {
userId: result.user.userId,
name: result.user.displayName || result.user.email,
email: result.user.email
email: result.user.email
},
timestamp: new Date().toISOString(),
action: 'User Login',
@ -466,23 +466,23 @@ export class AuthController {
category: 'AUTHENTICATION',
severity: 'INFO'
});
// Set cookies for web clients
const isProduction = process.env.NODE_ENV === 'production';
const cookieOptions = {
httpOnly: true,
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
};
res.cookie('accessToken', result.accessToken, cookieOptions);
const refreshCookieOptions = {
...cookieOptions,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
};
res.cookie('refreshToken', result.refreshToken, refreshCookieOptions);
logger.info('Password login successful', {
@ -516,21 +516,21 @@ export class AuthController {
},
headers: req.headers,
});
const { code, redirectUri } = validateTokenExchange(req.body);
logger.info('Token exchange validation passed', { redirectUri });
const result = await this.authService.exchangeCodeForTokens(code, redirectUri);
// Log login activity
const requestMeta = getRequestMetadata(req);
await activityService.log({
requestId: SYSTEM_EVENT_REQUEST_ID, // Special UUID for system events
type: 'login',
user: {
userId: result.user.userId,
user: {
userId: result.user.userId,
name: result.user.displayName || result.user.email,
email: result.user.email
email: result.user.email
},
timestamp: new Date().toISOString(),
action: 'User Login',
@ -546,35 +546,35 @@ export class AuthController {
category: 'AUTHENTICATION',
severity: 'INFO'
});
// Set cookies with httpOnly flag for security
const isProduction = process.env.NODE_ENV === 'production';
const cookieOptions = {
httpOnly: true,
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
};
res.cookie('accessToken', result.accessToken, cookieOptions);
const refreshCookieOptions = {
...cookieOptions,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days for refresh token
};
res.cookie('refreshToken', result.refreshToken, refreshCookieOptions);
// Ensure Content-Type is set to JSON
res.setHeader('Content-Type', 'application/json');
logger.info('Sending token exchange response', {
hasUser: !!result.user,
hasAccessToken: !!result.accessToken,
hasRefreshToken: !!result.refreshToken,
isProduction,
});
// SECURITY: In production, don't return tokens in response body
// Tokens are securely stored in httpOnly cookies
if (isProduction) {

View File

@ -4,6 +4,7 @@ import { aiService } from '@services/ai.service';
import { activityService } from '@services/activity.service';
import logger from '@utils/logger';
import { getRequestMetadata } from '@utils/requestUtils';
import { sanitizeHtml } from '@utils/sanitizer';
export class ConclusionController {
/**
@ -41,19 +42,19 @@ export class ConclusionController {
const { getConfigValue } = await import('../services/configReader.service');
const aiEnabled = (await getConfigValue('AI_ENABLED', 'true'))?.toLowerCase() === 'true';
const remarkGenerationEnabled = (await getConfigValue('AI_REMARK_GENERATION_ENABLED', 'true'))?.toLowerCase() === 'true';
if (!aiEnabled) {
logger.warn(`[Conclusion] AI features disabled in admin config for request ${requestId}`);
return res.status(400).json({
return res.status(400).json({
error: 'AI features disabled',
message: 'AI features are currently disabled by administrator. Please write the conclusion manually.',
canContinueManually: true
});
}
if (!remarkGenerationEnabled) {
logger.warn(`[Conclusion] AI remark generation disabled in admin config for request ${requestId}`);
return res.status(400).json({
return res.status(400).json({
error: 'AI remark generation disabled',
message: 'AI-powered conclusion generation is currently disabled by administrator. Please write the conclusion manually.',
canContinueManually: true
@ -63,7 +64,7 @@ export class ConclusionController {
// Check if AI service is available
if (!aiService.isAvailable()) {
logger.warn(`[Conclusion] AI service unavailable for request ${requestId}`);
return res.status(503).json({
return res.status(503).json({
error: 'AI service not available',
message: 'AI features are currently unavailable. Please verify Vertex AI configuration and service account credentials, or write the conclusion manually.',
canContinueManually: true
@ -100,8 +101,8 @@ export class ConclusionController {
requestNumber: (request as any).requestNumber,
priority: (request as any).priority,
approvalFlow: approvalLevels.map((level: any) => {
const tatPercentage = level.tatPercentageUsed !== undefined && level.tatPercentageUsed !== null
? Number(level.tatPercentageUsed)
const tatPercentage = level.tatPercentageUsed !== undefined && level.tatPercentageUsed !== null
? Number(level.tatPercentageUsed)
: (level.elapsedHours && level.tatHours ? (Number(level.elapsedHours) / Number(level.tatHours)) * 100 : 0);
return {
levelNumber: level.levelNumber,
@ -147,7 +148,7 @@ export class ConclusionController {
approvalSummary: {
totalLevels: approvalLevels.length,
approvedLevels: approvalLevels.filter((l: any) => l.status === 'APPROVED').length,
averageTatUsage: approvalLevels.reduce((sum: number, l: any) =>
averageTatUsage: approvalLevels.reduce((sum: number, l: any) =>
sum + Number(l.tatPercentageUsed || 0), 0) / (approvalLevels.length || 1)
},
documentSummary: {
@ -202,13 +203,13 @@ export class ConclusionController {
});
} catch (error: any) {
logger.error('[Conclusion] Error generating conclusion:', error);
// Provide helpful error messages
const isConfigError = error.message?.includes('not configured') ||
error.message?.includes('not available') ||
error.message?.includes('not initialized');
return res.status(isConfigError ? 503 : 500).json({
const isConfigError = error.message?.includes('not configured') ||
error.message?.includes('not available') ||
error.message?.includes('not initialized');
return res.status(isConfigError ? 503 : 500).json({
error: isConfigError ? 'AI service not configured' : 'Failed to generate conclusion',
message: error.message || 'An unexpected error occurred',
canContinueManually: true // User can still write manual conclusion
@ -249,9 +250,10 @@ export class ConclusionController {
// Update conclusion
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
const sanitizedRemark = sanitizeHtml(finalRemark);
await conclusion.update({
finalRemark: finalRemark,
finalRemark: sanitizedRemark,
editedBy: userId,
isEdited: wasEdited,
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount
@ -283,14 +285,16 @@ export class ConclusionController {
return res.status(400).json({ error: 'Final remark is required' });
}
const sanitizedRemark = sanitizeHtml(finalRemark);
// Fetch request
const request = await WorkflowRequest.findOne({
const request = await WorkflowRequest.findOne({
where: { requestId },
include: [
{ association: 'initiator', attributes: ['userId', 'displayName', 'email'] }
]
});
if (!request) {
return res.status(404).json({ error: 'Request not found' });
}
@ -307,7 +311,7 @@ export class ConclusionController {
// Find or create conclusion
let conclusion = await ConclusionRemark.findOne({ where: { requestId } });
if (!conclusion) {
// Create if doesn't exist (manual conclusion without AI)
conclusion = await ConclusionRemark.create({
@ -315,7 +319,7 @@ export class ConclusionController {
aiGeneratedRemark: null,
aiModelUsed: null,
aiConfidenceScore: null,
finalRemark: finalRemark,
finalRemark: sanitizedRemark,
editedBy: userId,
isEdited: false,
editCount: 0,
@ -328,9 +332,9 @@ export class ConclusionController {
} else {
// Update existing conclusion
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
await conclusion.update({
finalRemark: finalRemark,
finalRemark: sanitizedRemark,
editedBy: userId,
isEdited: wasEdited,
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount,
@ -341,7 +345,7 @@ export class ConclusionController {
// Update request status to CLOSED
await request.update({
status: 'CLOSED',
conclusionRemark: finalRemark,
conclusionRemark: sanitizedRemark,
closureDate: new Date()
} as any);

View File

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

View File

@ -12,6 +12,7 @@ import { gcsStorageService } from './gcsStorage.service';
import logger from '@utils/logger';
import fs from 'fs';
import path from 'path';
import { sanitizeHtml } from '@utils/sanitizer';
export class WorkNoteService {
async list(requestId: string) {
@ -26,9 +27,9 @@ export class WorkNoteService {
const attachments = await WorkNoteAttachment.findAll({
where: { noteId }
});
const noteData = (note as any).toJSON();
const mappedAttachments = attachments.map((a: any) => {
const attData = typeof a.toJSON === 'function' ? a.toJSON() : a;
return {
@ -42,7 +43,7 @@ export class WorkNoteService {
uploadedAt: attData.uploadedAt || attData.uploaded_at
};
});
return {
noteId: noteData.noteId || noteData.note_id,
requestId: noteData.requestId || noteData.request_id,
@ -79,22 +80,22 @@ export class WorkNoteService {
async create(requestId: string, user: { userId: string; name?: string; role?: string }, payload: { message: string; isPriority?: boolean; parentNoteId?: string | null; mentionedUsers?: string[] | null; }, files?: Array<{ path?: string | null; buffer?: Buffer; originalname: string; mimetype: string; size: number }>, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<any> {
logger.info('[WorkNote] Creating note:', { requestId, user, messageLength: payload.message?.length });
const note = await WorkNote.create({
requestId,
userId: user.userId,
userName: user.name || null,
userRole: user.role || null, // Store participant type (INITIATOR/APPROVER/SPECTATOR)
message: payload.message,
message: sanitizeHtml(payload.message),
isPriority: !!payload.isPriority,
parentNoteId: payload.parentNoteId || null,
mentionedUsers: payload.mentionedUsers || null,
hasAttachment: files && files.length > 0 ? true : false
} as any);
logger.info('[WorkNote] Created note:', {
noteId: (note as any).noteId,
userId: (note as any).userId,
logger.info('[WorkNote] Created note:', {
noteId: (note as any).noteId,
userId: (note as any).userId,
userName: (note as any).userName,
userRole: (note as any).userRole
});
@ -104,11 +105,11 @@ export class WorkNoteService {
// Get request number for folder structure
const workflow = await WorkflowRequest.findOne({ where: { requestId } });
const requestNumber = workflow ? ((workflow as any).requestNumber || (workflow as any).request_number) : null;
for (const f of files) {
// Read file buffer if path exists, otherwise use provided buffer
const fileBuffer = f.buffer || (f.path ? fs.readFileSync(f.path) : Buffer.from(''));
// Upload with automatic fallback to local storage
// If requestNumber is not available, use a default structure
const effectiveRequestNumber = requestNumber || 'UNKNOWN';
@ -119,10 +120,10 @@ export class WorkNoteService {
requestNumber: effectiveRequestNumber,
fileType: 'attachments'
});
const storageUrl = uploadResult.storageUrl;
const gcsFilePath = uploadResult.filePath;
// Clean up local temporary file if it exists (from multer disk storage)
if (f.path && fs.existsSync(f.path)) {
try {
@ -131,7 +132,7 @@ export class WorkNoteService {
logger.warn('[WorkNote] Failed to delete local temporary file:', unlinkError);
}
}
const attachment = await WorkNoteAttachment.create({
noteId: (note as any).noteId,
fileName: f.originalname,
@ -141,7 +142,7 @@ export class WorkNoteService {
storageUrl: storageUrl, // Store GCS URL or local URL
isDownloadable: true
} as any);
attachments.push({
attachmentId: (attachment as any).attachmentId,
fileName: (attachment as any).fileName,
@ -163,7 +164,7 @@ export class WorkNoteService {
// Get all participants (spectators)
const spectators = await Participant.findAll({
where: {
where: {
requestId,
participantType: 'SPECTATOR'
},
@ -389,9 +390,9 @@ export class WorkNoteService {
const workflow = await WorkflowRequest.findOne({ where: { requestId } });
const requestNumber = (workflow as any)?.requestNumber || requestId;
const requestTitle = (workflow as any)?.title || 'Request';
logger.info(`[WorkNote] Sending mention notifications to ${payload.mentionedUsers.length} users`);
await notificationService.sendToUsers(
payload.mentionedUsers,
{
@ -403,7 +404,7 @@ export class WorkNoteService {
type: 'mention'
}
);
logger.info(`[WorkNote] Mention notifications sent successfully`);
} catch (notifyError) {
logger.error('[WorkNote] Failed to send mention notifications:', notifyError);
@ -418,7 +419,7 @@ export class WorkNoteService {
const attachment = await WorkNoteAttachment.findOne({
where: { attachmentId }
});
if (!attachment) {
throw new Error('Attachment not found');
}
@ -430,7 +431,7 @@ export class WorkNoteService {
// Check if it's a GCS URL
const isGcsUrl = storageUrl && (storageUrl.startsWith('https://storage.googleapis.com') || storageUrl.startsWith('gs://'));
return {
filePath: filePath,
storageUrl: storageUrl,

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;
};