Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions backend/src/__tests__/security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { sanitizeInput, detectSuspiciousPatterns } from '../middleware/sanitizer';
import { Request, Response, NextFunction } from 'express';

describe('Security Middleware', () => {
describe('detectSuspiciousPatterns', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;
let jsonMock: jest.Mock;

beforeEach(() => {
jsonMock = jest.fn();
req = {
body: {},
query: {},
params: {}
};
res = {
status: jest.fn(() => ({ json: jsonMock })),
};
next = jest.fn();
});

it('should detect XSS attempts with script tags', () => {
req.body = {
name: '<script>alert("xss")</script>'
};

detectSuspiciousPatterns(req as Request, res as Response, next);

expect(res.status).toHaveBeenCalledWith(403);
expect(jsonMock).toHaveBeenCalled();
});

it('should detect SQL injection attempts', () => {
req.body = {
id: "1' OR '1'='1"
};

detectSuspiciousPatterns(req as Request, res as Response, next);

expect(res.status).toHaveBeenCalledWith(403);
});

it('should detect NoSQL injection attempts', () => {
req.body = {
$gt: 0
};

detectSuspiciousPatterns(req as Request, res as Response, next);

expect(res.status).toHaveBeenCalledWith(403);
});

it('should let normal requests pass', () => {
req.body = {
name: 'John Doe',
email: 'john@example.com'
};

detectSuspiciousPatterns(req as Request, res as Response, next);

expect(next).toHaveBeenCalled();
});
});

describe('sanitizeInput', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;

beforeEach(() => {
req = {
body: {},
query: {},
params: {}
};
res = {};
next = jest.fn();
});

it('should strip HTML tags', () => {
req.body = {
name: '<b>John</b> Doe',
bio: '<script>malicious</script>Hello'
};

sanitizeInput(req as Request, res as Response, next);

expect(req.body.name).not.toContain('<b>');
expect(req.body.bio).not.toContain('<script>');
expect(next).toHaveBeenCalled();
});

it('should sanitize NoSQL operators in keys', () => {
req.body = {
$gt: 5,
$in: [1, 2, 3]
};

sanitizeInput(req as Request, res as Response, next);

expect(req.body).not.toHaveProperty('$gt');
expect(req.body).toHaveProperty('sanitized_gt');
expect(next).toHaveBeenCalled();
});
});
});
6 changes: 5 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ import {
ddosProtection,
botDetection,
advancedRestrictions,
requestSanitizer
requestSanitizer,
cspMiddleware,
securityHeadersMiddleware
} from './middleware/security';
import { detectSuspiciousPatterns } from './middleware/sanitizer';
// @ts-ignore
Expand Down Expand Up @@ -117,6 +119,8 @@ setSyncWebsocketEmitter((userId: string, event: string, data: any) => {

// Middleware
app.use(helmet());
app.use(cspMiddleware);
app.use(securityHeadersMiddleware);
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
Expand Down
116 changes: 50 additions & 66 deletions backend/src/middleware/sanitizer.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,52 @@
import { Request, Response, NextFunction } from 'express';
import logger from '../utils/logger';

const MAX_STRING_LENGTH = 10000;

/**
* Strips HTML tags from a string.
* @param str The string to sanitize
* Encodes HTML entities to prevent XSS attacks.
*/
const stripHtml = (str: string): string => {
const encodeHtmlEntities = (str: string): string => {
return str
.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gmi, '') // Remove script tags AND content
.replace(/<[^>]*>?/gm, '') // Remove all other tags
.trim();
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
.replace(/\//g, '&#x2F;');
};

/**
* Escapes characters that could be used for NoSQL injection.
* Specifically prevents the use of MongoDB operators starting with $.
* @param obj The object to sanitize
* Strips HTML tags from a string.
*/
const sanitizeNoSql = (obj: any): any => {
if (obj instanceof Object) {
for (const key in obj) {
if (key.startsWith('$')) {
const newKey = key.replace(/^\$/, '');
obj[newKey] = obj[key];
delete obj[key];
sanitizeNoSql(obj[newKey]);
} else {
sanitizeNoSql(obj[key]);
}
}
}
return obj;
const stripHtml = (str: string): string => {
return str
.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gmi, '')
.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gmi, '')
.replace(/<iframe\b[^>]*>([\s\S]*?)<\/iframe>/gmi, '')
.replace(/<[^>]*>?/gm, '')
.trim();
};

/**
* Escapes special characters for SQL queries to prevent SQL injection.
* Note: Parameterized queries should always be used as the primary defense.
* @param str The string to sanitize
* Sanitizes string with length validation and encoding.
*/
const escapeSql = (str: string): string => {
return str.replace(/['"\\\b\f\n\r\t\v\0]/g, (char) => {
switch (char) {
case "'": return "''";
case "\"": return "\\\"";
case "\\": return "\\\\";
case "\b": return "\\b";
case "\f": return "\\f";
case "\n": return "\\n";
case "\r": return "\\r";
case "\t": return "\\t";
case "\v": return "\\v";
case "\0": return "\\0";
default: return char;
}
});
const sanitizeString = (str: string): string => {
let sanitized = stripHtml(str);
sanitized = encodeHtmlEntities(sanitized);
sanitized = sanitized.trim();
if (sanitized.length > MAX_STRING_LENGTH) {
sanitized = sanitized.substring(0, MAX_STRING_LENGTH);
}
return sanitized;
};

/**
* Recursively sanitizes an object by stripping HTML and escaping SQL/NoSQL patterns.
* @param obj The object to sanitize
* Recursively sanitizes an object.
*/
const sanitizeRecursive = (obj: any): any => {
if (typeof obj === 'string') {
let sanitized = stripHtml(obj);
sanitized = escapeSql(sanitized);
return sanitized;
return sanitizeString(obj);
}

if (Array.isArray(obj)) {
Expand All @@ -75,8 +57,10 @@ const sanitizeRecursive = (obj: any): any => {
const sanitizedObj: any = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
// Sanitize key for NoSQL injection
const sanitizedKey = key.startsWith('$') ? key.replace(/^\$/, '') : key;
let sanitizedKey = key;
if (key.startsWith('$')) {
sanitizedKey = key.replace(/^\$/, 'sanitized_');
}
sanitizedObj[sanitizedKey] = sanitizeRecursive(obj[key]);
}
}
Expand All @@ -88,14 +72,13 @@ const sanitizeRecursive = (obj: any): any => {

/**
* Sanitizes file upload metadata.
* @param file The file object from multer
*/
const sanitizeFileMetadata = (file: any) => {
if (file.originalname) {
file.originalname = stripHtml(file.originalname).replace(/[^a-zA-Z0-9.\-_]/g, '_');
file.originalname = sanitizeString(file.originalname).replace(/[^a-zA-Z0-9.\-_]/g, '_');
}
if (file.fieldname) {
file.fieldname = stripHtml(file.fieldname);
file.fieldname = sanitizeString(file.fieldname);
}
};

Expand All @@ -104,22 +87,15 @@ const sanitizeFileMetadata = (file: any) => {
*/
export const sanitizeInput = (req: Request, res: Response, next: NextFunction) => {
try {
// Sanitize Body
if (req.body) {
req.body = sanitizeRecursive(req.body);
}

// Sanitize Query Parameters
if (req.query) {
req.query = sanitizeRecursive(req.query);
}

// Sanitize URL Parameters
if (req.params) {
req.params = sanitizeRecursive(req.params);
}

// Sanitize File Metadata
if (req.file) {
sanitizeFileMetadata(req.file);
}
Expand All @@ -132,7 +108,6 @@ export const sanitizeInput = (req: Request, res: Response, next: NextFunction) =
});
}
}

next();
} catch (error) {
logger.error('Input sanitization error:', error);
Expand All @@ -146,20 +121,29 @@ export const sanitizeInput = (req: Request, res: Response, next: NextFunction) =
export const detectSuspiciousPatterns = (req: Request, res: Response, next: NextFunction) => {
const suspiciousRegex = [
/<script\b[^>]*>([\s\S]*?)<\/script>/i,
/on\w+="[^"]*"/i,
/javascript:[^"]*/i,
/\b(SELECT|INSERT|UPDATE|DELETE|DROP|UNION)\b/i,
/('\s*--)|(--\s*')|(\/\*)|(\*\/)/i,
/\{\s*"\$/ // Potential NoSQL operator in JSON string
/<style\b[^>]*>([\s\S]*?)<\/style>/i,
/<iframe\b[^>]*>([\s\S]*?)<\/iframe>/i,
/on\w+=/i,
/javascript:/i,
/vbscript:/i,
/data:/i,
/\b(SELECT|INSERT|UPDATE|DELETE|DROP|UNION|ALTER|TRUNCATE)\b/i,
/('\s*--)|(--\s*')|(\/\*)|(\*\/)|(;\s*)/i,
/\{\s*"\$|\$\s*\{/i
];

const checkSuspicious = (obj: any): boolean => {
if (!obj) return false;
const str = JSON.stringify(obj);
return suspiciousRegex.some((regex) => regex.test(str));
};

if (checkSuspicious(req.body) || checkSuspicious(req.query) || checkSuspicious(req.params)) {
logger.warn(`Suspicious activity detected from IP: ${req.ip}`);
logger.warn(`Suspicious activity detected from IP: ${req.ip}`, {
path: req.path,
method: req.method,
userAgent: req.headers['user-agent']
});
return res.status(403).json({
success: false,
message: 'Request rejected due to suspicious patterns.',
Expand Down
23 changes: 23 additions & 0 deletions backend/src/middleware/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ import redisConfig from '../config/redis';
import * as securityService from '../services/securityService';
import { sanitizeInput } from './sanitizer';

/**
* Content Security Policy (CSP) Middleware
*/
export const cspMiddleware = (req: Request, res: Response, next: NextFunction) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' wss: ws:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
);
next();
};

/**
* Additional Security Headers Middleware
*/
export const securityHeadersMiddleware = (req: Request, res: Response, next: NextFunction) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
};

/**
* DDoS Protection Middleware
* Uses Redis to track request rates per IP and flags rapid bursts
Expand Down
Loading