refactor: optimize configuration and tool implementations
- Standardized error handling across tool implementations - Improved return type consistency for tool execution results - Simplified configuration parsing and type definitions - Enhanced type safety for various configuration schemas - Cleaned up and normalized tool response structures - Updated SSE and event subscription tool implementations
This commit is contained in:
@@ -1,136 +1,150 @@
|
||||
import { TokenManager } from '../index';
|
||||
import { SECURITY_CONFIG } from '../../config/security.config';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { jest } from '@jest/globals';
|
||||
import { TokenManager } from "../index";
|
||||
import { SECURITY_CONFIG } from "../../config/security.config";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { jest } from "@jest/globals";
|
||||
|
||||
describe('TokenManager', () => {
|
||||
const validSecret = 'test_secret_key_that_is_at_least_32_chars_long';
|
||||
const testIp = '127.0.0.1';
|
||||
describe("TokenManager", () => {
|
||||
const validSecret = "test_secret_key_that_is_at_least_32_chars_long";
|
||||
const testIp = "127.0.0.1";
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = validSecret;
|
||||
jest.clearAllMocks();
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = validSecret;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.JWT_SECRET;
|
||||
});
|
||||
|
||||
describe("Token Validation", () => {
|
||||
it("should validate a properly formatted token", () => {
|
||||
const payload = { userId: "123", role: "user" };
|
||||
const token = jwt.sign(payload, validSecret);
|
||||
const result = TokenManager.validateToken(token, testIp);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.JWT_SECRET;
|
||||
it("should reject an invalid token", () => {
|
||||
const result = TokenManager.validateToken("invalid_token", testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Token length below minimum requirement");
|
||||
});
|
||||
|
||||
describe('Token Validation', () => {
|
||||
it('should validate a properly formatted token', () => {
|
||||
const payload = { userId: '123', role: 'user' };
|
||||
const token = jwt.sign(payload, validSecret);
|
||||
const result = TokenManager.validateToken(token, testIp);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject an invalid token', () => {
|
||||
const result = TokenManager.validateToken('invalid_token', testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Token length below minimum requirement');
|
||||
});
|
||||
|
||||
it('should reject a token that is too short', () => {
|
||||
const result = TokenManager.validateToken('short', testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Token length below minimum requirement');
|
||||
});
|
||||
|
||||
it('should reject an expired token', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
userId: '123',
|
||||
role: 'user',
|
||||
iat: now - 7200, // 2 hours ago
|
||||
exp: now - 3600 // expired 1 hour ago
|
||||
};
|
||||
const token = jwt.sign(payload, validSecret);
|
||||
const result = TokenManager.validateToken(token, testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Token has expired');
|
||||
});
|
||||
|
||||
it('should implement rate limiting for failed attempts', async () => {
|
||||
// Simulate multiple failed attempts
|
||||
for (let i = 0; i < SECURITY_CONFIG.MAX_FAILED_ATTEMPTS; i++) {
|
||||
const result = TokenManager.validateToken('invalid_token', testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
}
|
||||
|
||||
// Next attempt should be blocked by rate limiting
|
||||
const result = TokenManager.validateToken('invalid_token', testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Too many failed attempts. Please try again later.');
|
||||
|
||||
// Wait for rate limit to expire
|
||||
await new Promise(resolve => setTimeout(resolve, SECURITY_CONFIG.LOCKOUT_DURATION + 100));
|
||||
|
||||
// Should be able to try again
|
||||
const validPayload = { userId: '123', role: 'user' };
|
||||
const validToken = jwt.sign(validPayload, validSecret);
|
||||
const finalResult = TokenManager.validateToken(validToken, testIp);
|
||||
expect(finalResult.valid).toBe(true);
|
||||
});
|
||||
it("should reject a token that is too short", () => {
|
||||
const result = TokenManager.validateToken("short", testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Token length below minimum requirement");
|
||||
});
|
||||
|
||||
describe('Token Generation', () => {
|
||||
it('should generate a valid JWT token', () => {
|
||||
const payload = { userId: '123', role: 'user' };
|
||||
const token = TokenManager.generateToken(payload);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe('string');
|
||||
|
||||
// Verify the token can be decoded
|
||||
const decoded = jwt.verify(token, validSecret) as any;
|
||||
expect(decoded.userId).toBe(payload.userId);
|
||||
expect(decoded.role).toBe(payload.role);
|
||||
});
|
||||
|
||||
it('should include required claims in generated tokens', () => {
|
||||
const payload = { userId: '123' };
|
||||
const token = TokenManager.generateToken(payload);
|
||||
const decoded = jwt.verify(token, validSecret) as any;
|
||||
|
||||
expect(decoded.iat).toBeDefined();
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.exp - decoded.iat).toBe(
|
||||
Math.floor(24 * 60 * 60) // 24 hours in seconds
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when JWT secret is not configured', () => {
|
||||
delete process.env.JWT_SECRET;
|
||||
const payload = { userId: '123' };
|
||||
expect(() => TokenManager.generateToken(payload)).toThrow('JWT secret not configured');
|
||||
});
|
||||
it("should reject an expired token", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
userId: "123",
|
||||
role: "user",
|
||||
iat: now - 7200, // 2 hours ago
|
||||
exp: now - 3600, // expired 1 hour ago
|
||||
};
|
||||
const token = jwt.sign(payload, validSecret);
|
||||
const result = TokenManager.validateToken(token, testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Token has expired");
|
||||
});
|
||||
|
||||
describe('Token Encryption', () => {
|
||||
const encryptionKey = 'encryption_key_that_is_at_least_32_chars_long';
|
||||
it("should implement rate limiting for failed attempts", async () => {
|
||||
// Simulate multiple failed attempts
|
||||
for (let i = 0; i < SECURITY_CONFIG.MAX_FAILED_ATTEMPTS; i++) {
|
||||
const result = TokenManager.validateToken("invalid_token", testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
}
|
||||
|
||||
it('should encrypt and decrypt a token successfully', () => {
|
||||
const originalToken = 'test_token_to_encrypt';
|
||||
const encrypted = TokenManager.encryptToken(originalToken, encryptionKey);
|
||||
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
|
||||
expect(decrypted).toBe(originalToken);
|
||||
});
|
||||
// Next attempt should be blocked by rate limiting
|
||||
const result = TokenManager.validateToken("invalid_token", testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe(
|
||||
"Too many failed attempts. Please try again later.",
|
||||
);
|
||||
|
||||
it('should throw error for invalid encryption inputs', () => {
|
||||
expect(() => TokenManager.encryptToken('', encryptionKey)).toThrow('Invalid token');
|
||||
expect(() => TokenManager.encryptToken('valid_token', '')).toThrow('Invalid encryption key');
|
||||
});
|
||||
// Wait for rate limit to expire
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, SECURITY_CONFIG.LOCKOUT_DURATION + 100),
|
||||
);
|
||||
|
||||
it('should throw error for invalid decryption inputs', () => {
|
||||
expect(() => TokenManager.decryptToken('', encryptionKey)).toThrow('Invalid encrypted token');
|
||||
expect(() => TokenManager.decryptToken('invalid:format', encryptionKey)).toThrow('Invalid encrypted token format');
|
||||
});
|
||||
|
||||
it('should generate different ciphertexts for same plaintext', () => {
|
||||
const token = 'test_token';
|
||||
const encrypted1 = TokenManager.encryptToken(token, encryptionKey);
|
||||
const encrypted2 = TokenManager.encryptToken(token, encryptionKey);
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
});
|
||||
// Should be able to try again
|
||||
const validPayload = { userId: "123", role: "user" };
|
||||
const validToken = jwt.sign(validPayload, validSecret);
|
||||
const finalResult = TokenManager.validateToken(validToken, testIp);
|
||||
expect(finalResult.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Generation", () => {
|
||||
it("should generate a valid JWT token", () => {
|
||||
const payload = { userId: "123", role: "user" };
|
||||
const token = TokenManager.generateToken(payload);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
|
||||
// Verify the token can be decoded
|
||||
const decoded = jwt.verify(token, validSecret) as any;
|
||||
expect(decoded.userId).toBe(payload.userId);
|
||||
expect(decoded.role).toBe(payload.role);
|
||||
});
|
||||
|
||||
it("should include required claims in generated tokens", () => {
|
||||
const payload = { userId: "123" };
|
||||
const token = TokenManager.generateToken(payload);
|
||||
const decoded = jwt.verify(token, validSecret) as any;
|
||||
|
||||
expect(decoded.iat).toBeDefined();
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.exp - decoded.iat).toBe(
|
||||
Math.floor(24 * 60 * 60), // 24 hours in seconds
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when JWT secret is not configured", () => {
|
||||
delete process.env.JWT_SECRET;
|
||||
const payload = { userId: "123" };
|
||||
expect(() => TokenManager.generateToken(payload)).toThrow(
|
||||
"JWT secret not configured",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Encryption", () => {
|
||||
const encryptionKey = "encryption_key_that_is_at_least_32_chars_long";
|
||||
|
||||
it("should encrypt and decrypt a token successfully", () => {
|
||||
const originalToken = "test_token_to_encrypt";
|
||||
const encrypted = TokenManager.encryptToken(originalToken, encryptionKey);
|
||||
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
|
||||
expect(decrypted).toBe(originalToken);
|
||||
});
|
||||
|
||||
it("should throw error for invalid encryption inputs", () => {
|
||||
expect(() => TokenManager.encryptToken("", encryptionKey)).toThrow(
|
||||
"Invalid token",
|
||||
);
|
||||
expect(() => TokenManager.encryptToken("valid_token", "")).toThrow(
|
||||
"Invalid encryption key",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for invalid decryption inputs", () => {
|
||||
expect(() => TokenManager.decryptToken("", encryptionKey)).toThrow(
|
||||
"Invalid encrypted token",
|
||||
);
|
||||
expect(() =>
|
||||
TokenManager.decryptToken("invalid:format", encryptionKey),
|
||||
).toThrow("Invalid encrypted token format");
|
||||
});
|
||||
|
||||
it("should generate different ciphertexts for same plaintext", () => {
|
||||
const token = "test_token";
|
||||
const encrypted1 = TokenManager.encryptToken(token, encryptionKey);
|
||||
const encrypted2 = TokenManager.encryptToken(token, encryptionKey);
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import { HelmetOptions } from 'helmet';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from "crypto";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import helmet from "helmet";
|
||||
import { HelmetOptions } from "helmet";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
// Security configuration
|
||||
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
||||
@@ -12,370 +12,400 @@ const TOKEN_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// Rate limiting middleware
|
||||
export const rateLimiter = rateLimit({
|
||||
windowMs: RATE_LIMIT_WINDOW,
|
||||
max: RATE_LIMIT_MAX,
|
||||
message: 'Too many requests from this IP, please try again later'
|
||||
windowMs: RATE_LIMIT_WINDOW,
|
||||
max: RATE_LIMIT_MAX,
|
||||
message: "Too many requests from this IP, please try again later",
|
||||
});
|
||||
|
||||
// Security configuration
|
||||
const helmetConfig: HelmetOptions = {
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'", 'wss:', 'https:']
|
||||
}
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
connectSrc: ["'self'", "wss:", "https:"],
|
||||
},
|
||||
dnsPrefetchControl: true,
|
||||
frameguard: true,
|
||||
hidePoweredBy: true,
|
||||
hsts: true,
|
||||
ieNoOpen: true,
|
||||
noSniff: true,
|
||||
referrerPolicy: {
|
||||
policy: ['no-referrer', 'strict-origin-when-cross-origin']
|
||||
}
|
||||
},
|
||||
dnsPrefetchControl: true,
|
||||
frameguard: true,
|
||||
hidePoweredBy: true,
|
||||
hsts: true,
|
||||
ieNoOpen: true,
|
||||
noSniff: true,
|
||||
referrerPolicy: {
|
||||
policy: ["no-referrer", "strict-origin-when-cross-origin"],
|
||||
},
|
||||
};
|
||||
|
||||
// Security headers middleware
|
||||
export const securityHeaders = helmet(helmetConfig);
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 16;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
// Security configuration
|
||||
const SECURITY_CONFIG = {
|
||||
TOKEN_EXPIRY: 24 * 60 * 60 * 1000, // 24 hours
|
||||
MAX_TOKEN_AGE: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
MIN_TOKEN_LENGTH: 32,
|
||||
MAX_FAILED_ATTEMPTS: 5,
|
||||
LOCKOUT_DURATION: 15 * 60 * 1000, // 15 minutes
|
||||
TOKEN_EXPIRY: 24 * 60 * 60 * 1000, // 24 hours
|
||||
MAX_TOKEN_AGE: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
MIN_TOKEN_LENGTH: 32,
|
||||
MAX_FAILED_ATTEMPTS: 5,
|
||||
LOCKOUT_DURATION: 15 * 60 * 1000, // 15 minutes
|
||||
};
|
||||
|
||||
// Track failed authentication attempts
|
||||
const failedAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||
const failedAttempts = new Map<
|
||||
string,
|
||||
{ count: number; lastAttempt: number }
|
||||
>();
|
||||
|
||||
export class TokenManager {
|
||||
/**
|
||||
* Encrypts a token using AES-256-GCM
|
||||
*/
|
||||
static encryptToken(token: string, key: string): string {
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
if (!key || typeof key !== 'string' || key.length < 32) {
|
||||
throw new Error('Invalid encryption key');
|
||||
}
|
||||
|
||||
try {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key.slice(0, 32), iv);
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(token, 'utf8'),
|
||||
cipher.final()
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Format: algorithm:iv:tag:encrypted
|
||||
return `${ALGORITHM}:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to encrypt token');
|
||||
}
|
||||
/**
|
||||
* Encrypts a token using AES-256-GCM
|
||||
*/
|
||||
static encryptToken(token: string, key: string): string {
|
||||
if (!token || typeof token !== "string") {
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
if (!key || typeof key !== "string" || key.length < 32) {
|
||||
throw new Error("Invalid encryption key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a token using AES-256-GCM
|
||||
*/
|
||||
static decryptToken(encryptedToken: string, key: string): string {
|
||||
if (!encryptedToken || typeof encryptedToken !== 'string') {
|
||||
throw new Error('Invalid encrypted token');
|
||||
}
|
||||
if (!key || typeof key !== 'string' || key.length < 32) {
|
||||
throw new Error('Invalid encryption key');
|
||||
}
|
||||
try {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key.slice(0, 32), iv);
|
||||
|
||||
try {
|
||||
const [algorithm, ivBase64, tagBase64, encryptedBase64] = encryptedToken.split(':');
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(token, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
if (algorithm !== ALGORITHM || !ivBase64 || !tagBase64 || !encryptedBase64) {
|
||||
throw new Error('Invalid encrypted token format');
|
||||
}
|
||||
// Format: algorithm:iv:tag:encrypted
|
||||
return `${ALGORITHM}:${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
|
||||
} catch (error) {
|
||||
throw new Error("Failed to encrypt token");
|
||||
}
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
const tag = Buffer.from(tagBase64, 'base64');
|
||||
const encrypted = Buffer.from(encryptedBase64, 'base64');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key.slice(0, 32), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]).toString('utf8');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Invalid encrypted token format') {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Invalid encrypted token');
|
||||
}
|
||||
/**
|
||||
* Decrypts a token using AES-256-GCM
|
||||
*/
|
||||
static decryptToken(encryptedToken: string, key: string): string {
|
||||
if (!encryptedToken || typeof encryptedToken !== "string") {
|
||||
throw new Error("Invalid encrypted token");
|
||||
}
|
||||
if (!key || typeof key !== "string" || key.length < 32) {
|
||||
throw new Error("Invalid encryption key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a JWT token with enhanced security checks
|
||||
*/
|
||||
static validateToken(token: string | undefined | null, ip?: string): { valid: boolean; error?: string } {
|
||||
// Check basic token format
|
||||
if (!token || typeof token !== 'string') {
|
||||
return { valid: false, error: 'Invalid token format' };
|
||||
}
|
||||
try {
|
||||
const [algorithm, ivBase64, tagBase64, encryptedBase64] =
|
||||
encryptedToken.split(":");
|
||||
|
||||
// Check for token length
|
||||
if (token.length < SECURITY_CONFIG.MIN_TOKEN_LENGTH) {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
return { valid: false, error: 'Token length below minimum requirement' };
|
||||
}
|
||||
if (
|
||||
algorithm !== ALGORITHM ||
|
||||
!ivBase64 ||
|
||||
!tagBase64 ||
|
||||
!encryptedBase64
|
||||
) {
|
||||
throw new Error("Invalid encrypted token format");
|
||||
}
|
||||
|
||||
// Check for rate limiting
|
||||
if (ip && this.isRateLimited(ip)) {
|
||||
return { valid: false, error: 'Too many failed attempts. Please try again later.' };
|
||||
}
|
||||
const iv = Buffer.from(ivBase64, "base64");
|
||||
const tag = Buffer.from(tagBase64, "base64");
|
||||
const encrypted = Buffer.from(encryptedBase64, "base64");
|
||||
|
||||
// Get JWT secret
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
return { valid: false, error: 'JWT secret not configured' };
|
||||
}
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key.slice(0, 32), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
try {
|
||||
// Verify token signature and decode
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
clockTolerance: 0, // No clock skew tolerance
|
||||
ignoreExpiration: false // Always check expiration
|
||||
}) as jwt.JwtPayload;
|
||||
return Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === "Invalid encrypted token format"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Invalid encrypted token");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify token structure
|
||||
if (!decoded || typeof decoded !== 'object') {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
return { valid: false, error: 'Invalid token structure' };
|
||||
}
|
||||
|
||||
// Check required claims
|
||||
if (!decoded.exp || !decoded.iat) {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
return { valid: false, error: 'Token missing required claims' };
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Check expiration
|
||||
if (decoded.exp <= now) {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
return { valid: false, error: 'Token has expired' };
|
||||
}
|
||||
|
||||
// Check token age
|
||||
const tokenAge = (now - decoded.iat) * 1000;
|
||||
if (tokenAge > SECURITY_CONFIG.MAX_TOKEN_AGE) {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
return { valid: false, error: 'Token exceeds maximum age limit' };
|
||||
}
|
||||
|
||||
// Reset failed attempts on successful validation
|
||||
if (ip) {
|
||||
failedAttempts.delete(ip);
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
return { valid: false, error: 'Token has expired' };
|
||||
}
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
return { valid: false, error: 'Invalid token signature' };
|
||||
}
|
||||
return { valid: false, error: 'Token validation failed' };
|
||||
}
|
||||
/**
|
||||
* Validates a JWT token with enhanced security checks
|
||||
*/
|
||||
static validateToken(
|
||||
token: string | undefined | null,
|
||||
ip?: string,
|
||||
): { valid: boolean; error?: string } {
|
||||
// Check basic token format
|
||||
if (!token || typeof token !== "string") {
|
||||
return { valid: false, error: "Invalid token format" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a failed authentication attempt for rate limiting
|
||||
*/
|
||||
private static recordFailedAttempt(ip?: string): void {
|
||||
if (!ip) return;
|
||||
|
||||
const attempt = failedAttempts.get(ip) || { count: 0, lastAttempt: Date.now() };
|
||||
attempt.count++;
|
||||
attempt.lastAttempt = Date.now();
|
||||
failedAttempts.set(ip, attempt);
|
||||
// Check for token length
|
||||
if (token.length < SECURITY_CONFIG.MIN_TOKEN_LENGTH) {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
return { valid: false, error: "Token length below minimum requirement" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an IP is rate limited due to too many failed attempts
|
||||
*/
|
||||
private static isRateLimited(ip: string): boolean {
|
||||
const attempt = failedAttempts.get(ip);
|
||||
if (!attempt) return false;
|
||||
|
||||
// Reset if lockout duration has passed
|
||||
if (Date.now() - attempt.lastAttempt >= SECURITY_CONFIG.LOCKOUT_DURATION) {
|
||||
failedAttempts.delete(ip);
|
||||
return false;
|
||||
}
|
||||
|
||||
return attempt.count >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS;
|
||||
// Check for rate limiting
|
||||
if (ip && this.isRateLimited(ip)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Too many failed attempts. Please try again later.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new JWT token
|
||||
*/
|
||||
static generateToken(payload: Record<string, any>): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('JWT secret not configured');
|
||||
}
|
||||
|
||||
// Add required claims
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const tokenPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + Math.floor(TOKEN_EXPIRY / 1000)
|
||||
};
|
||||
|
||||
return jwt.sign(tokenPayload, secret, {
|
||||
algorithm: 'HS256'
|
||||
});
|
||||
// Get JWT secret
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
return { valid: false, error: "JWT secret not configured" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify token signature and decode
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ["HS256"],
|
||||
clockTolerance: 0, // No clock skew tolerance
|
||||
ignoreExpiration: false, // Always check expiration
|
||||
}) as jwt.JwtPayload;
|
||||
|
||||
// Verify token structure
|
||||
if (!decoded || typeof decoded !== "object") {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
return { valid: false, error: "Invalid token structure" };
|
||||
}
|
||||
|
||||
// Check required claims
|
||||
if (!decoded.exp || !decoded.iat) {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
return { valid: false, error: "Token missing required claims" };
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Check expiration
|
||||
if (decoded.exp <= now) {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
return { valid: false, error: "Token has expired" };
|
||||
}
|
||||
|
||||
// Check token age
|
||||
const tokenAge = (now - decoded.iat) * 1000;
|
||||
if (tokenAge > SECURITY_CONFIG.MAX_TOKEN_AGE) {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
return { valid: false, error: "Token exceeds maximum age limit" };
|
||||
}
|
||||
|
||||
// Reset failed attempts on successful validation
|
||||
if (ip) {
|
||||
failedAttempts.delete(ip);
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
if (ip) this.recordFailedAttempt(ip);
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
return { valid: false, error: "Token has expired" };
|
||||
}
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
return { valid: false, error: "Invalid token signature" };
|
||||
}
|
||||
return { valid: false, error: "Token validation failed" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a failed authentication attempt for rate limiting
|
||||
*/
|
||||
private static recordFailedAttempt(ip?: string): void {
|
||||
if (!ip) return;
|
||||
|
||||
const attempt = failedAttempts.get(ip) || {
|
||||
count: 0,
|
||||
lastAttempt: Date.now(),
|
||||
};
|
||||
attempt.count++;
|
||||
attempt.lastAttempt = Date.now();
|
||||
failedAttempts.set(ip, attempt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an IP is rate limited due to too many failed attempts
|
||||
*/
|
||||
private static isRateLimited(ip: string): boolean {
|
||||
const attempt = failedAttempts.get(ip);
|
||||
if (!attempt) return false;
|
||||
|
||||
// Reset if lockout duration has passed
|
||||
if (Date.now() - attempt.lastAttempt >= SECURITY_CONFIG.LOCKOUT_DURATION) {
|
||||
failedAttempts.delete(ip);
|
||||
return false;
|
||||
}
|
||||
|
||||
return attempt.count >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new JWT token
|
||||
*/
|
||||
static generateToken(payload: Record<string, any>): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("JWT secret not configured");
|
||||
}
|
||||
|
||||
// Add required claims
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const tokenPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + Math.floor(TOKEN_EXPIRY / 1000),
|
||||
};
|
||||
|
||||
return jwt.sign(tokenPayload, secret, {
|
||||
algorithm: "HS256",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Request validation middleware
|
||||
export function validateRequest(req: Request, res: Response, next: NextFunction): Response | void {
|
||||
// Skip validation for health and MCP schema endpoints
|
||||
if (req.path === '/health' || req.path === '/mcp') {
|
||||
return next();
|
||||
export function validateRequest(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Response | void {
|
||||
// Skip validation for health and MCP schema endpoints
|
||||
if (req.path === "/health" || req.path === "/mcp") {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Validate content type for non-GET requests
|
||||
if (["POST", "PUT", "PATCH"].includes(req.method)) {
|
||||
const contentType = req.headers["content-type"] || "";
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
return res.status(415).json({
|
||||
success: false,
|
||||
message: "Unsupported Media Type",
|
||||
error: "Content-Type must be application/json",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
error: "Missing or invalid authorization header",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const validationResult = TokenManager.validateToken(token, req.ip);
|
||||
if (!validationResult.valid) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
error: validationResult.error || "Invalid token",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate request body for non-GET requests
|
||||
if (["POST", "PUT", "PATCH"].includes(req.method)) {
|
||||
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Bad Request",
|
||||
error: "Invalid request body structure",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate content type for non-GET requests
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
return res.status(415).json({
|
||||
success: false,
|
||||
message: 'Unsupported Media Type',
|
||||
error: 'Content-Type must be application/json',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
// Check request body size
|
||||
const contentLength = parseInt(req.headers["content-length"] || "0", 10);
|
||||
const maxSize = 1024 * 1024; // 1MB limit
|
||||
if (contentLength > maxSize) {
|
||||
return res.status(413).json({
|
||||
success: false,
|
||||
message: "Payload Too Large",
|
||||
error: `Request body must not exceed ${maxSize} bytes`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
error: 'Missing or invalid authorization header',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const validationResult = TokenManager.validateToken(token, req.ip);
|
||||
if (!validationResult.valid) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
error: validationResult.error || 'Invalid token',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Validate request body for non-GET requests
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
||||
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Bad Request',
|
||||
error: 'Invalid request body structure',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Check request body size
|
||||
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
||||
const maxSize = 1024 * 1024; // 1MB limit
|
||||
if (contentLength > maxSize) {
|
||||
return res.status(413).json({
|
||||
success: false,
|
||||
message: 'Payload Too Large',
|
||||
error: `Request body must not exceed ${maxSize} bytes`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
next();
|
||||
}
|
||||
|
||||
// Input sanitization middleware
|
||||
export function sanitizeInput(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.body) {
|
||||
return next();
|
||||
}
|
||||
if (!req.body) {
|
||||
return next();
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
// Remove HTML tags and scripts more thoroughly
|
||||
return value
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') // Remove script tags and content
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '') // Remove style tags and content
|
||||
.replace(/<[^>]+>/g, '') // Remove remaining HTML tags
|
||||
.replace(/javascript:/gi, '') // Remove javascript: protocol
|
||||
.replace(/on\w+\s*=\s*(?:".*?"|'.*?'|[^"'>\s]+)/gi, '') // Remove event handlers
|
||||
.trim();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => sanitizeValue(item));
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
sanitized[key] = sanitizeValue(val);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
return value;
|
||||
function sanitizeValue(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
// Remove HTML tags and scripts more thoroughly
|
||||
return value
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "") // Remove script tags and content
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "") // Remove style tags and content
|
||||
.replace(/<[^>]+>/g, "") // Remove remaining HTML tags
|
||||
.replace(/javascript:/gi, "") // Remove javascript: protocol
|
||||
.replace(/on\w+\s*=\s*(?:".*?"|'.*?'|[^"'>\s]+)/gi, "") // Remove event handlers
|
||||
.trim();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeValue(item));
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
sanitized[key] = sanitizeValue(val);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
req.body = sanitizeValue(req.body);
|
||||
next();
|
||||
req.body = sanitizeValue(req.body);
|
||||
next();
|
||||
}
|
||||
|
||||
// Error handling middleware
|
||||
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
error: "Internal Server Error",
|
||||
message: process.env.NODE_ENV === "development" ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Export security middleware chain
|
||||
export const securityMiddleware = [
|
||||
helmet(helmetConfig),
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100
|
||||
}),
|
||||
validateRequest,
|
||||
sanitizeInput,
|
||||
errorHandler
|
||||
];
|
||||
helmet(helmetConfig),
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
}),
|
||||
validateRequest,
|
||||
sanitizeInput,
|
||||
errorHandler,
|
||||
];
|
||||
|
||||
@@ -1,109 +1,143 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { validateRequest, sanitizeInput } from '../../src/security/middleware';
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { validateRequest, sanitizeInput } from "../../src/security/middleware";
|
||||
|
||||
type MockRequest = {
|
||||
headers: {
|
||||
'content-type'?: string;
|
||||
authorization?: string;
|
||||
};
|
||||
body?: any;
|
||||
is: jest.MockInstance<string | false | null, [type: string | string[]]>;
|
||||
headers: {
|
||||
"content-type"?: string;
|
||||
authorization?: string;
|
||||
};
|
||||
body?: any;
|
||||
is: jest.MockInstance<string | false | null, [type: string | string[]]>;
|
||||
};
|
||||
|
||||
type MockResponse = {
|
||||
status: jest.MockInstance<MockResponse, [code: number]>;
|
||||
json: jest.MockInstance<MockResponse, [body: any]>;
|
||||
setHeader: jest.MockInstance<MockResponse, [name: string, value: string]>;
|
||||
status: jest.MockInstance<MockResponse, [code: number]>;
|
||||
json: jest.MockInstance<MockResponse, [body: any]>;
|
||||
setHeader: jest.MockInstance<MockResponse, [name: string, value: string]>;
|
||||
};
|
||||
|
||||
describe('Security Middleware', () => {
|
||||
let mockRequest: MockRequest;
|
||||
let mockResponse: MockResponse;
|
||||
let nextFunction: jest.Mock;
|
||||
describe("Security Middleware", () => {
|
||||
let mockRequest: MockRequest;
|
||||
let mockResponse: MockResponse;
|
||||
let nextFunction: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
headers: {},
|
||||
body: {},
|
||||
is: jest.fn<string | false | null, [string | string[]]>().mockReturnValue('json')
|
||||
};
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
headers: {},
|
||||
body: {},
|
||||
is: jest
|
||||
.fn<string | false | null, [string | string[]]>()
|
||||
.mockReturnValue("json"),
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
status: jest.fn<MockResponse, [number]>().mockReturnThis(),
|
||||
json: jest.fn<MockResponse, [any]>().mockReturnThis(),
|
||||
setHeader: jest.fn<MockResponse, [string, string]>().mockReturnThis()
|
||||
};
|
||||
mockResponse = {
|
||||
status: jest.fn<MockResponse, [number]>().mockReturnThis(),
|
||||
json: jest.fn<MockResponse, [any]>().mockReturnThis(),
|
||||
setHeader: jest.fn<MockResponse, [string, string]>().mockReturnThis(),
|
||||
};
|
||||
|
||||
nextFunction = jest.fn();
|
||||
nextFunction = jest.fn();
|
||||
});
|
||||
|
||||
describe("validateRequest", () => {
|
||||
it("should pass valid requests", () => {
|
||||
mockRequest.headers.authorization = "Bearer valid-token";
|
||||
validateRequest(
|
||||
mockRequest as unknown as Request,
|
||||
mockResponse as unknown as Response,
|
||||
nextFunction,
|
||||
);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('validateRequest', () => {
|
||||
it('should pass valid requests', () => {
|
||||
mockRequest.headers.authorization = 'Bearer valid-token';
|
||||
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject requests without authorization header', () => {
|
||||
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
error: expect.stringContaining('authorization')
|
||||
}));
|
||||
});
|
||||
|
||||
it('should reject requests with invalid authorization format', () => {
|
||||
mockRequest.headers.authorization = 'invalid-format';
|
||||
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
error: expect.stringContaining('Bearer')
|
||||
}));
|
||||
});
|
||||
it("should reject requests without authorization header", () => {
|
||||
validateRequest(
|
||||
mockRequest as unknown as Request,
|
||||
mockResponse as unknown as Response,
|
||||
nextFunction,
|
||||
);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining("authorization"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('sanitizeInput', () => {
|
||||
it('should pass requests without body', () => {
|
||||
delete mockRequest.body;
|
||||
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sanitize HTML in request body', () => {
|
||||
mockRequest.body = {
|
||||
text: '<script>alert("xss")</script>Hello',
|
||||
nested: {
|
||||
html: '<img src="x" onerror="alert(1)">World'
|
||||
}
|
||||
};
|
||||
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||
expect(mockRequest.body.text).toBe('Hello');
|
||||
expect(mockRequest.body.nested.html).toBe('World');
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle non-object bodies', () => {
|
||||
mockRequest.body = '<p>text</p>';
|
||||
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||
expect(mockRequest.body).toBe('text');
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve non-string values', () => {
|
||||
mockRequest.body = {
|
||||
number: 42,
|
||||
boolean: true,
|
||||
null: null,
|
||||
array: [1, 2, 3]
|
||||
};
|
||||
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||
expect(mockRequest.body).toEqual({
|
||||
number: 42,
|
||||
boolean: true,
|
||||
null: null,
|
||||
array: [1, 2, 3]
|
||||
});
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
it("should reject requests with invalid authorization format", () => {
|
||||
mockRequest.headers.authorization = "invalid-format";
|
||||
validateRequest(
|
||||
mockRequest as unknown as Request,
|
||||
mockResponse as unknown as Response,
|
||||
nextFunction,
|
||||
);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining("Bearer"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeInput", () => {
|
||||
it("should pass requests without body", () => {
|
||||
delete mockRequest.body;
|
||||
sanitizeInput(
|
||||
mockRequest as unknown as Request,
|
||||
mockResponse as unknown as Response,
|
||||
nextFunction,
|
||||
);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should sanitize HTML in request body", () => {
|
||||
mockRequest.body = {
|
||||
text: '<script>alert("xss")</script>Hello',
|
||||
nested: {
|
||||
html: '<img src="x" onerror="alert(1)">World',
|
||||
},
|
||||
};
|
||||
sanitizeInput(
|
||||
mockRequest as unknown as Request,
|
||||
mockResponse as unknown as Response,
|
||||
nextFunction,
|
||||
);
|
||||
expect(mockRequest.body.text).toBe("Hello");
|
||||
expect(mockRequest.body.nested.html).toBe("World");
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle non-object bodies", () => {
|
||||
mockRequest.body = "<p>text</p>";
|
||||
sanitizeInput(
|
||||
mockRequest as unknown as Request,
|
||||
mockResponse as unknown as Response,
|
||||
nextFunction,
|
||||
);
|
||||
expect(mockRequest.body).toBe("text");
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should preserve non-string values", () => {
|
||||
mockRequest.body = {
|
||||
number: 42,
|
||||
boolean: true,
|
||||
null: null,
|
||||
array: [1, 2, 3],
|
||||
};
|
||||
sanitizeInput(
|
||||
mockRequest as unknown as Request,
|
||||
mockResponse as unknown as Response,
|
||||
nextFunction,
|
||||
);
|
||||
expect(mockRequest.body).toEqual({
|
||||
number: 42,
|
||||
boolean: true,
|
||||
null: null,
|
||||
array: [1, 2, 3],
|
||||
});
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user