feat: 邮件监控 4 项改进 + POP3 地址可配置 + SSRF 加固
改进内容: 1. POP3 备用服务器/端口从硬编码改为可配置(types + settings-manager + fetchPOP3Emails 参数化 host/port + 两处调用点传配置值 + 设置页字段), 默认 pophz.qiye.163.com:995 保持向后兼容 2. 扫描历史固定保留最近 50 条(saveScanHistory 按 id 排序自动裁剪) 3. 补齐审计缺口:test-mail、scan-history DELETE、test-wechat 失败/异常分支 4. 运行状态"错误"从历史累计(10000+)改为反映最近一次 tick 状态 安全加固: - settings VALIDATORS 增加 isValidHost 主机名格式校验(imap/pop3/smtp 三处), 拒绝 URL/端口路径/空格等载荷,收窄 SSRF 面(纵深防御) - 审计 entityType 命名统一(monitor_mail / monitor_wechat) 经 6 Task + 独立审查(零 CRITICAL/HIGH/MEDIUM),2 处逻辑经内存 sqlite 单测验证。 自动安全审查:SSRF 已加固,scan_history DELETE IDOR 为误报(无用户输入)。
This commit is contained in:
parent
5152d7d4f2
commit
28c37f1069
|
|
@ -32,7 +32,7 @@ interface WebhookConfig {
|
|||
|
||||
interface MonitorConfig {
|
||||
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
|
||||
mail: { address: string; imap_server: string; imap_port: number; smtp_server: string; smtp_port: number; password: string }
|
||||
mail: { address: string; imap_server: string; imap_port: number; pop3_server: string; pop3_port: number; smtp_server: string; smtp_port: number; password: string }
|
||||
filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } }
|
||||
wechat: { webhooks: WebhookConfig[] }
|
||||
}
|
||||
|
|
@ -694,6 +694,14 @@ export default function MonitorSettingsPage() {
|
|||
<label className="block text-sm font-medium mb-1">IMAP 端口</label>
|
||||
<input type="number" value={config.mail.imap_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_port: parseInt(e.target.value) || 993 } })} className={inputClass('mail.imap_port')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">POP3 备用服务器</label>
|
||||
<input type="text" value={config.mail.pop3_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, pop3_server: e.target.value } })} className={inputClass('mail.pop3_server')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">POP3 备用端口</label>
|
||||
<input type="number" value={config.mail.pop3_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, pop3_port: parseInt(e.target.value) || 995 } })} className={inputClass('mail.pop3_port')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">SMTP 服务器</label>
|
||||
<input type="text" value={config.mail.smtp_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_server: e.target.value } })} className={inputClass('mail.smtp_server')} />
|
||||
|
|
|
|||
|
|
@ -315,7 +315,7 @@ async function scanEmails(
|
|||
console.log(`[Scan] IMAP 返回空 (uid=${uid}),启用 POP3 fallback`)
|
||||
pop3Attempted = true // 无论成败都置位,避免每封空邮件都重连
|
||||
try {
|
||||
pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length)
|
||||
pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length, config.mail.pop3_server, config.mail.pop3_port)
|
||||
} catch (pop3Err) {
|
||||
console.error('[Scan] POP3 fallback 失败:', pop3Err instanceof Error ? pop3Err.message : pop3Err)
|
||||
}
|
||||
|
|
@ -637,6 +637,10 @@ function saveScanHistory(scanState: ScanResult, userId: number): void {
|
|||
JSON.stringify(scanState.details),
|
||||
userId
|
||||
)
|
||||
// 保留最近 50 条扫描历史,删除更早的(用 id 单调排序,避免 created_at 同秒重复)
|
||||
db.prepare(`DELETE FROM scan_history WHERE id NOT IN (
|
||||
SELECT id FROM scan_history ORDER BY id DESC LIMIT 50
|
||||
)`).run()
|
||||
console.log(`[Scan] 扫描历史已保存: 状态=${scanState.status}, 导入=${scanState.stats.imported}`)
|
||||
} catch (err) {
|
||||
console.error('[Scan] 保存扫描历史失败:', err)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
|||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
interface ScanHistoryItem {
|
||||
id: number
|
||||
|
|
@ -84,6 +85,8 @@ export async function DELETE(request: NextRequest) {
|
|||
WHERE created_at < datetime('now', '+8 hours', ? || ' days')
|
||||
`).run(-keepDays)
|
||||
|
||||
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'delete', entityType: 'scan_history', details: { keepDays, deleted: result.changes }, ipAddress: getClientIP(request) })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deleted: result.changes,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ import { hasPermission } from '@/lib/permissions'
|
|||
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
// 主机名格式校验(纵深防御):只允许合法主机名/IP 字符,拒绝 URL/路径/空格等,
|
||||
// 防止误配或配置被篡改后连接到任意主机(SSRF 面收窄)
|
||||
const isValidHost = (v: unknown): boolean =>
|
||||
typeof v === 'string' && v.length > 0 && v.length <= 253 && /^[a-zA-Z0-9.-]+$/.test(v)
|
||||
|
||||
const VALIDATORS: Record<string, (v: unknown) => boolean> = {
|
||||
'monitor.enabled': (v) => typeof v === 'boolean',
|
||||
'monitor.interval_seconds': (v) => typeof v === 'number' && v >= 10 && v <= 3600,
|
||||
|
|
@ -13,7 +18,11 @@ const VALIDATORS: Record<string, (v: unknown) => boolean> = {
|
|||
'monitor.silent_start_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
|
||||
'monitor.silent_end_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
|
||||
'mail.address': (v) => typeof v === 'string' && v.includes('@'),
|
||||
'mail.imap_server': isValidHost,
|
||||
'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||
'mail.pop3_server': isValidHost,
|
||||
'mail.pop3_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||
'mail.smtp_server': isValidHost,
|
||||
'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||
'wechat.webhooks': (v) => Array.isArray(v) && v.length > 0,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,18 @@ export async function GET(request: NextRequest) {
|
|||
const todayStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} 00:00:00`
|
||||
const todayProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ?").get(todayStart) as { c: number }).c
|
||||
const totalProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created'").get() as { c: number }).c
|
||||
const errorCount = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'error'").get() as { c: number }).c
|
||||
// 最近一次 tick 的错误状态:action='error' → 1;tick_complete → 该 tick 的 errors 数;无记录 → 0
|
||||
const lastLog = db.prepare(
|
||||
"SELECT action, details FROM monitor_logs WHERE action IN ('error', 'tick_complete') ORDER BY id DESC LIMIT 1"
|
||||
).get() as { action: string; details: string | null } | undefined
|
||||
let errorCount = 0
|
||||
if (lastLog) {
|
||||
if (lastLog.action === 'error') {
|
||||
errorCount = 1
|
||||
} else {
|
||||
try { errorCount = JSON.parse(lastLog.details || '{}').errors || 0 } catch { errorCount = 0 }
|
||||
}
|
||||
}
|
||||
const ongoingFaultsCount = (db.prepare("SELECT COUNT(*) as c FROM fault_records WHERE status = 'ongoing'").get() as { c: number }).c
|
||||
|
||||
// 获取最近的活动(tick_complete 或 error)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
|||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { ImapFlow } from 'imapflow'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
|
@ -21,8 +22,10 @@ export async function POST(request: NextRequest) {
|
|||
const lock = await client.getMailboxLock('INBOX')
|
||||
lock.release()
|
||||
await client.logout()
|
||||
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'test', entityType: 'monitor_mail', details: { target: 'imap', success: true, server: config?.mail?.imap_server || 'unknown' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: true, message: 'IMAP 连接成功,INBOX 选择正常' })
|
||||
} catch (e) {
|
||||
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'test', entityType: 'monitor_mail', details: { target: 'imap', success: false, server: config?.mail?.imap_server || 'unknown' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '连接失败' })
|
||||
} finally {
|
||||
try { await client.logout() } catch {}
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@ export async function POST(request: NextRequest) {
|
|||
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送成功' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: true, message: '测试消息发送成功' })
|
||||
}
|
||||
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送失败', error: result.errmsg || '发送失败' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: false, error: result.errmsg || '发送失败' })
|
||||
} catch (e) {
|
||||
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送异常', error: e instanceof Error ? e.message : '发送失败' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '发送失败' })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export class MailMonitor {
|
|||
if (!source && !pop3Attempted) {
|
||||
pop3Attempted = true
|
||||
try {
|
||||
pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length)
|
||||
pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length, config.mail.pop3_server, config.mail.pop3_port)
|
||||
} catch (e) {
|
||||
console.error(`[Worker] POP3 fallback 失败: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ export function extractMessageIdFromRaw(buf: Buffer): string | null {
|
|||
export async function fetchPOP3Emails(
|
||||
mailAddress: string,
|
||||
mailPassword: string,
|
||||
count: number
|
||||
count: number,
|
||||
host: string,
|
||||
port: number,
|
||||
): Promise<Map<string, Buffer>> {
|
||||
const POP3_HOST = 'pophz.qiye.163.com'
|
||||
const POP3_PORT = 995
|
||||
const POP3_CMD_TIMEOUT = 30000
|
||||
const POP3_MAX_RESPONSE = 50 * 1024 * 1024 // 单封邮件响应上限 50MB,防内存耗尽
|
||||
|
||||
|
|
@ -38,9 +38,9 @@ export async function fetchPOP3Emails(
|
|||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`[Scan] POP3: 连接 ${POP3_HOST}:${POP3_PORT}, 获取最近 ${count} 封`)
|
||||
console.log(`[Scan] POP3: 连接 ${host}:${port}, 获取最近 ${count} 封`)
|
||||
// 网易企业邮箱持有受信任 CA 证书,启用完整证书 + SNI 校验
|
||||
const socket = tls.connect(POP3_PORT, POP3_HOST, { servername: POP3_HOST })
|
||||
const socket = tls.connect(port, host, { servername: host })
|
||||
|
||||
let settled = false
|
||||
let cmdResolve: ((v: Buffer) => void) | null = null
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const DEFAULT_WEBHOOK: WebhookConfig = {
|
|||
|
||||
const DEFAULT_CONFIG: MonitorConfig = {
|
||||
monitor: { enabled: false, interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
||||
mail: { address: 'gxp@qx002575.com', imap_server: 'imaphz.qiye.163.com', imap_port: 993, smtp_server: 'smtphz.qiye.163.com', smtp_port: 465, password: '' },
|
||||
mail: { address: 'gxp@qx002575.com', imap_server: 'imaphz.qiye.163.com', imap_port: 993, pop3_server: 'pophz.qiye.163.com', pop3_port: 995, smtp_server: 'smtphz.qiye.163.com', smtp_port: 465, password: '' },
|
||||
filter: {
|
||||
subject_keywords: ['服务器故障单'],
|
||||
sender_email: 'tencent_dcops@tencent.com',
|
||||
|
|
@ -110,6 +110,8 @@ export function getMonitorConfig(): MonitorConfig {
|
|||
'mail.address': (v) => { config.mail.address = v },
|
||||
'mail.imap_server': (v) => { config.mail.imap_server = v },
|
||||
'mail.imap_port': (v) => { config.mail.imap_port = parseInt(v) || 993 },
|
||||
'mail.pop3_server': (v) => { config.mail.pop3_server = v },
|
||||
'mail.pop3_port': (v) => { config.mail.pop3_port = parseInt(v) || 995 },
|
||||
'mail.smtp_server': (v) => { config.mail.smtp_server = v },
|
||||
'mail.smtp_port': (v) => { config.mail.smtp_port = parseInt(v) || 465 },
|
||||
'mail.password': (v) => { config.mail.password = v ? decryptValue(v) : '' },
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ export interface MonitorConfig {
|
|||
address: string
|
||||
imap_server: string
|
||||
imap_port: number
|
||||
pop3_server: string
|
||||
pop3_port: number
|
||||
smtp_server: string
|
||||
smtp_port: number
|
||||
password: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue