diff --git a/backend/middleware/security.js b/backend/middleware/security.js index 5266d1f..e0749cf 100644 --- a/backend/middleware/security.js +++ b/backend/middleware/security.js @@ -116,10 +116,17 @@ function apiKeyGuard(req, res, next) { if (p === '/api/health' || p.startsWith('/api/install')) return next(); const key = getApiKey(); if (!key) return next(); - if (req.headers['x-api-key'] !== key) return res.status(401).json({ error: '无效的 API 密钥' }); + if (!req.headers['x-api-key'] || req.headers['x-api-key'].length !== key.length) return res.status(401).json({ error: '无效的 API 密钥' }); + if (!timingSafeEqual(req.headers['x-api-key'], key)) return res.status(401).json({ error: '无效的 API 密钥' }); next(); } +function timingSafeEqual(a, b) { + let diff = a.length ^ b.length; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + function methodGuard(allowed) { return (req, res, next) => { if (!allowed.includes(req.method)) { diff --git a/backend/server.js b/backend/server.js index 04591e4..710e2a6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -69,6 +69,12 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' })); app.use(sanitizeBody); app.use(generalLimiter); app.use('/api', apiKeyGuard); +app.use('/api', (req, res, next) => { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + next(); +}); const staticOpts = { maxAge: 0, diff --git a/backend/webhook.js b/backend/webhook.js index eb98811..5156a6a 100644 --- a/backend/webhook.js +++ b/backend/webhook.js @@ -2,12 +2,29 @@ const { query, getRow } = require('./db'); const EVENT_LABELS = { ticket_created:'工单创建', ticket_claimed:'工单认领', ticket_transferred:'工单转交', ticket_updated:'工单更新' }; +const dns = require('dns').promises; +const { URL } = require('url'); + +async function isPrivateUrl(urlStr) { + try { + const u = new URL(urlStr); + if (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '0.0.0.0') return true; + if (u.hostname.startsWith('192.168.') || u.hostname.startsWith('10.') || u.hostname.startsWith('172.16.')) return true; + const addrs = await dns.resolve4(u.hostname).catch(() => []); + for (const addr of addrs) { + if (addr === '127.0.0.1' || addr.startsWith('192.168.') || addr.startsWith('10.') || addr.startsWith('172.16.') || addr.startsWith('0.')) return true; + } + return false; + } catch { return true; } +} + async function sendWebhook(event, data) { try { const configs = await query("SELECT * FROM notification_configs WHERE type = 'webhook' AND active = 1"); for (const cfg of configs) { const events = cfg.events || 'all'; if (events !== 'all' && !events.split(',').map(s=>s.trim()).includes(event)) continue; + if (cfg.webhook_url && await isPrivateUrl(cfg.webhook_url)) { console.error('[Webhook] blocked internal URL:', cfg.name); continue; } const embed = buildEmbed(event, data); try { const ctrl = new AbortController();