added input validation and security hardening

This commit is contained in:
2026-02-02 14:39:50 -07:00
parent d520c4ae41
commit 84803a353e
3 changed files with 225 additions and 73 deletions

View File

@@ -219,8 +219,13 @@ function createAuthRouter(db, logAudit) {
}
});
// Clean up expired sessions (can be called periodically)
// Clean up expired sessions (admin only)
router.post('/cleanup-sessions', async (req, res) => {
// Basic auth check - require a valid session to call this
const sessionId = req.cookies?.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
await new Promise((resolve, reject) => {
db.run(

View File

@@ -27,20 +27,89 @@ const CORS_ORIGINS = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
: ['http://localhost:3000'];
// ========== SECURITY HELPERS ==========
// Allowed file extensions for document uploads (documents only, no executables)
const ALLOWED_EXTENSIONS = new Set([
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
'.txt', '.csv', '.log',
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.odt', '.ods', '.odp',
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
'.zip', '.gz', '.tar', '.7z'
]);
// Allowed MIME type prefixes
const ALLOWED_MIME_PREFIXES = [
'image/', 'text/', 'application/pdf',
'application/msword', 'application/vnd.openxmlformats',
'application/vnd.ms-', 'application/vnd.oasis.opendocument',
'application/rtf', 'application/json', 'application/xml',
'application/zip', 'application/gzip', 'application/x-7z',
'application/x-tar', 'application/octet-stream'
];
// Sanitize a single path segment (cveId, vendor, filename) to prevent traversal
function sanitizePathSegment(segment) {
if (!segment || typeof segment !== 'string') return '';
// Remove path separators, null bytes, and .. sequences
return segment
.replace(/\0/g, '')
.replace(/\.\./g, '')
.replace(/[\/\\]/g, '')
.trim();
}
// Validate that a resolved path is within the uploads directory
function isPathWithinUploads(targetPath) {
const uploadsRoot = path.resolve('uploads');
const resolved = path.resolve(targetPath);
return resolved.startsWith(uploadsRoot + path.sep) || resolved === uploadsRoot;
}
// Validate CVE ID format
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
function isValidCveId(cveId) {
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
}
// Allowed enum values
const VALID_SEVERITIES = ['Critical', 'High', 'Medium', 'Low'];
const VALID_STATUSES = ['Open', 'Addressed', 'In Progress', 'Resolved'];
const VALID_DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other'];
// Validate vendor name - printable chars, reasonable length
function isValidVendor(vendor) {
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
}
// Log all incoming requests
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
next();
});
// Security headers
app.use((req, res, next) => {
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();
});
// Middleware
app.use(cors({
origin: CORS_ORIGINS,
credentials: true
}));
app.use(express.json());
app.use(express.json({ limit: '1mb' }));
app.use(cookieParser());
app.use('/uploads', express.static('uploads'));
app.use('/uploads', express.static('uploads', {
dotfiles: 'deny',
index: false
}));
// Database connection
const db = new sqlite3.Database('./cve_database.db', (err) => {
@@ -71,12 +140,28 @@ const storage = multer.diskStorage({
},
filename: (req, file, cb) => {
const timestamp = Date.now();
cb(null, `${timestamp}-${file.originalname}`);
// Sanitize original filename - strip path components and dangerous chars
const safeName = sanitizePathSegment(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
cb(null, `${timestamp}-${safeName}`);
}
});
// File filter - reject executables and non-allowed types
function fileFilter(req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) {
return cb(new Error(`File type '${ext}' is not allowed. Allowed types: ${[...ALLOWED_EXTENSIONS].join(', ')}`));
}
const mimeAllowed = ALLOWED_MIME_PREFIXES.some(prefix => file.mimetype.startsWith(prefix));
if (!mimeAllowed) {
return cb(new Error(`MIME type '${file.mimetype}' is not allowed.`));
}
cb(null, true);
}
const upload = multer({
storage: storage,
fileFilter: fileFilter,
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
});
@@ -123,7 +208,7 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
db.all(query, params, (err, rows) => {
if (err) {
console.error('Error fetching CVEs:', err);
return res.status(500).json({ error: err.message });
return res.status(500).json({ error: 'Internal server error.' });
}
res.json(rows);
});
@@ -132,7 +217,7 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
// Get distinct CVE IDs for NVD sync (authenticated users)
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
res.json(rows.map(r => r.cve_id));
});
});
@@ -155,7 +240,7 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
db.all(query, [cveId], (err, rows) => {
if (err) {
return res.status(500).json({ error: err.message });
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
}
if (!rows || rows.length === 0) {
return res.json({
@@ -197,7 +282,7 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
db.all(query, [cveId], (err, rows) => {
if (err) {
return res.status(500).json({ error: err.message });
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
}
res.json(rows);
});
@@ -206,31 +291,39 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
console.log('=== ADD CVE REQUEST ===');
console.log('Body:', req.body);
console.log('=======================');
const { cve_id, vendor, severity, description, published_date } = req.body;
// Input validation
if (!cve_id || !isValidCveId(cve_id)) {
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
}
if (!vendor || !isValidVendor(vendor)) {
return res.status(400).json({ error: 'Vendor is required and must be under 200 characters.' });
}
if (!severity || !VALID_SEVERITIES.includes(severity)) {
return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` });
}
if (!description || typeof description !== 'string' || description.length > 10000) {
return res.status(400).json({ error: 'Description is required and must be under 10000 characters.' });
}
if (!published_date || !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) {
return res.status(400).json({ error: 'Published date is required in YYYY-MM-DD format.' });
}
const query = `
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
VALUES (?, ?, ?, ?, ?)
`;
console.log('Query:', query);
console.log('Values:', [cve_id, vendor, severity, description, published_date]);
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
if (err) {
console.error('DATABASE ERROR:', err); // Make sure this is here
// ... rest of error handling
// Check if it's a duplicate CVE_ID + Vendor combination
console.error('DATABASE ERROR:', err);
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({
error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.'
});
}
return res.status(500).json({ error: err.message });
return res.status(500).json({ error: 'Failed to create CVE entry.' });
}
logAudit(db, {
userId: req.user.id,
@@ -255,11 +348,15 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
const { cveId } = req.params;
const { status } = req.body;
if (!status || !VALID_STATUSES.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
}
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
db.run(query, [status, cveId], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
@@ -314,7 +411,8 @@ app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'),
values,
function(err) {
if (err) {
errors.push({ cve_id: entry.cve_id, error: err.message });
console.error('NVD sync update error:', err);
errors.push({ cve_id: entry.cve_id, error: 'Update failed' });
} else {
updated += this.changes;
}
@@ -348,9 +446,29 @@ app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req,
const { id } = req.params;
const { cve_id, vendor, severity, description, published_date, status } = req.body;
// Input validation for provided fields
if (cve_id !== undefined && !isValidCveId(cve_id)) {
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
}
if (vendor !== undefined && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'Vendor must be under 200 characters.' });
}
if (severity !== undefined && !VALID_SEVERITIES.includes(severity)) {
return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` });
}
if (description !== undefined && (typeof description !== 'string' || description.length > 10000)) {
return res.status(400).json({ error: 'Description must be under 10000 characters.' });
}
if (published_date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) {
return res.status(400).json({ error: 'Published date must be in YYYY-MM-DD format.' });
}
if (status !== undefined && !VALID_STATUSES.includes(status)) {
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
}
// Fetch existing row first
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, existing) => {
if (err) return res.status(500).json({ error: err.message });
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
if (!existing) return res.status(404).json({ error: 'CVE entry not found' });
const before = { cve_id: existing.cve_id, vendor: existing.vendor, severity: existing.severity, description: existing.description, published_date: existing.published_date, status: existing.status };
@@ -377,7 +495,7 @@ app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req,
values.push(id);
db.run(`UPDATE cves SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
if (updateErr) return res.status(500).json({ error: updateErr.message });
if (updateErr) { console.error(updateErr); return res.status(500).json({ error: 'Internal server error.' }); }
const after = {
cve_id: newCveId, vendor: newVendor,
@@ -404,12 +522,16 @@ app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req,
if (cveIdChanged || vendorChanged) {
// Check UNIQUE constraint
db.get('SELECT id FROM cves WHERE cve_id = ? AND vendor = ? AND id != ?', [newCveId, newVendor, id], (checkErr, conflict) => {
if (checkErr) return res.status(500).json({ error: checkErr.message });
if (checkErr) { console.error(checkErr); return res.status(500).json({ error: 'Internal server error.' }); }
if (conflict) return res.status(409).json({ error: 'A CVE entry with this CVE ID and vendor already exists.' });
// Rename document directory
const oldDir = path.join('uploads', existing.cve_id, existing.vendor);
const newDir = path.join('uploads', newCveId, newVendor);
// Rename document directory (with path traversal prevention)
const oldDir = path.join('uploads', sanitizePathSegment(existing.cve_id), sanitizePathSegment(existing.vendor));
const newDir = path.join('uploads', sanitizePathSegment(newCveId), sanitizePathSegment(newVendor));
if (!isPathWithinUploads(oldDir) || !isPathWithinUploads(newDir)) {
return res.status(400).json({ error: 'Invalid CVE ID or vendor name for file paths.' });
}
if (fs.existsSync(oldDir)) {
const newParent = path.join('uploads', newCveId);
@@ -428,7 +550,7 @@ app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req,
// Update documents table - file paths
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [existing.cve_id, existing.vendor], (docErr, docs) => {
if (docErr) return res.status(500).json({ error: docErr.message });
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
const oldPrefix = path.join('uploads', existing.cve_id, existing.vendor);
const newPrefix = path.join('uploads', newCveId, newVendor);
@@ -469,7 +591,7 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
// Get all rows for this CVE ID to know what we're deleting
db.all('SELECT * FROM cves WHERE cve_id = ?', [cveId], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
// Delete all documents from DB
@@ -478,11 +600,12 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
// Delete all CVE rows
db.run('DELETE FROM cves WHERE cve_id = ?', [cveId], function(cveErr) {
if (cveErr) return res.status(500).json({ error: cveErr.message });
if (cveErr) { console.error(cveErr); return res.status(500).json({ error: 'Internal server error.' }); }
// Remove upload directory
const cveDir = path.join('uploads', cveId);
if (fs.existsSync(cveDir)) {
// Remove upload directory (with path traversal prevention)
const safeCveId = sanitizePathSegment(cveId);
const cveDir = path.join('uploads', safeCveId);
if (safeCveId && isPathWithinUploads(cveDir) && fs.existsSync(cveDir)) {
fs.rmSync(cveDir, { recursive: true, force: true });
}
@@ -507,17 +630,17 @@ app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (re
const { id } = req.params;
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
if (err) return res.status(500).json({ error: err.message });
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
// Delete associated documents from DB
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
if (docErr) console.error('Error fetching documents:', docErr);
// Delete document files from disk
// Delete document files from disk (with path traversal prevention)
if (docs && docs.length > 0) {
docs.forEach(doc => {
if (doc.file_path && fs.existsSync(doc.file_path)) {
if (doc.file_path && isPathWithinUploads(doc.file_path) && fs.existsSync(doc.file_path)) {
fs.unlinkSync(doc.file_path);
}
});
@@ -529,17 +652,17 @@ app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (re
// Delete CVE row
db.run('DELETE FROM cves WHERE id = ?', [id], function(delErr) {
if (delErr) return res.status(500).json({ error: delErr.message });
if (delErr) { console.error(delErr); return res.status(500).json({ error: 'Internal server error.' }); }
// Clean up directories
const vendorDir = path.join('uploads', cve.cve_id, cve.vendor);
if (fs.existsSync(vendorDir)) {
fs.rmSync(vendorDir, { recursive: true, force: true });
// Clean up directories (with path traversal prevention)
const safeVendorDir = path.join('uploads', sanitizePathSegment(cve.cve_id), sanitizePathSegment(cve.vendor));
if (isPathWithinUploads(safeVendorDir) && fs.existsSync(safeVendorDir)) {
fs.rmSync(safeVendorDir, { recursive: true, force: true });
}
const cveDir = path.join('uploads', cve.cve_id);
if (fs.existsSync(cveDir)) {
const remaining = fs.readdirSync(cveDir);
if (remaining.length === 0) fs.rmdirSync(cveDir);
const safeCveDir = path.join('uploads', sanitizePathSegment(cve.cve_id));
if (isPathWithinUploads(safeCveDir) && fs.existsSync(safeCveDir)) {
const remaining = fs.readdirSync(safeCveDir);
if (remaining.length === 0) fs.rmdirSync(safeCveDir);
}
logAudit(db, {
@@ -578,7 +701,7 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
db.all(query, params, (err, rows) => {
if (err) {
return res.status(500).json({ error: err.message });
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
}
res.json(rows);
});
@@ -588,16 +711,17 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) {
console.error('MULTER ERROR:', err);
return res.status(500).json({ error: 'File upload failed: ' + err.message });
console.error('Upload error:', err.message);
// Show file validation errors to the user; hide other internal errors
if (err.message && (err.message.startsWith('File type') || err.message.startsWith('MIME type'))) {
return res.status(400).json({ error: err.message });
}
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File exceeds the 10MB size limit.' });
}
return res.status(500).json({ error: 'File upload failed.' });
}
console.log('=== UPLOAD REQUEST RECEIVED ===');
console.log('CVE ID:', req.params.cveId);
console.log('Body:', req.body);
console.log('File:', req.file);
console.log('================================');
const { cveId } = req.params;
const { type, notes, vendor } = req.body;
const file = req.file;
@@ -608,18 +732,41 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
}
if (!vendor) {
console.error('ERROR: Vendor is required');
// Clean up temp file
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
return res.status(400).json({ error: 'Vendor is required' });
}
// Validate document type
if (type && !VALID_DOC_TYPES.includes(type)) {
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
return res.status(400).json({ error: `Invalid document type. Must be one of: ${VALID_DOC_TYPES.join(', ')}` });
}
// Sanitize path segments to prevent directory traversal
const safeCveId = sanitizePathSegment(cveId);
const safeVendor = sanitizePathSegment(vendor);
const safeFilename = sanitizePathSegment(file.filename);
if (!safeCveId || !safeVendor || !safeFilename) {
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
return res.status(400).json({ error: 'Invalid CVE ID, vendor, or filename.' });
}
// Move file from temp to proper location
const finalDir = path.join('uploads', cveId, vendor);
const finalDir = path.join('uploads', safeCveId, safeVendor);
const finalPath = path.join(finalDir, safeFilename);
// Verify paths stay within uploads directory
if (!isPathWithinUploads(finalDir) || !isPathWithinUploads(finalPath)) {
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
return res.status(400).json({ error: 'Invalid file path.' });
}
if (!fs.existsSync(finalDir)) {
fs.mkdirSync(finalDir, { recursive: true });
}
const finalPath = path.join(finalDir, file.filename);
// Move file from temp to final location
fs.renameSync(file.path, finalPath);
@@ -641,12 +788,12 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
notes
], function(err) {
if (err) {
console.error('DATABASE ERROR:', err);
console.error('Document insert error:', err);
// If database insert fails, delete the file
if (fs.existsSync(finalPath)) {
fs.unlinkSync(finalPath);
}
return res.status(500).json({ error: err.message });
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
@@ -662,7 +809,6 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
message: 'Document uploaded successfully',
file: {
name: file.originalname,
path: finalPath,
size: fileSizeKB
}
});
@@ -676,16 +822,17 @@ app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, re
// First get the file path to delete the actual file
db.get('SELECT file_path FROM documents WHERE id = ?', [id], (err, row) => {
if (err) {
return res.status(500).json({ error: err.message });
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
}
if (row && fs.existsSync(row.file_path)) {
// Only delete file if path is within uploads directory
if (row && row.file_path && isPathWithinUploads(row.file_path) && fs.existsSync(row.file_path)) {
fs.unlinkSync(row.file_path);
}
db.run('DELETE FROM documents WHERE id = ?', [id], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
@@ -709,7 +856,7 @@ app.get('/api/vendors', requireAuth(db), (req, res) => {
db.all(query, [], (err, rows) => {
if (err) {
return res.status(500).json({ error: err.message });
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
}
res.json(rows.map(r => r.vendor));
});
@@ -731,7 +878,7 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
db.get(query, [], (err, row) => {
if (err) {
return res.status(500).json({ error: err.message });
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
}
res.json(row);
});

View File

@@ -221,7 +221,7 @@ export default function App() {
const handleFileUpload = async (cveId, vendor) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.pdf,.png,.jpg,.jpeg,.txt,.doc,.docx';
fileInput.accept = '.pdf,.png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.txt,.csv,.log,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.html,.htm,.xml,.json,.yaml,.yml,.zip,.gz,.tar,.7z';
fileInput.onchange = async (e) => {
const file = e.target.files[0];