From 28c37f10694f38937bae7948065ae47a5bab32c7 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Sat, 11 Jul 2026 19:10:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=82=AE=E4=BB=B6=E7=9B=91=E6=8E=A7=20?= =?UTF-8?q?4=20=E9=A1=B9=E6=94=B9=E8=BF=9B=20+=20POP3=20=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E5=8F=AF=E9=85=8D=E7=BD=AE=20+=20SSRF=20=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改进内容: 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 为误报(无用户输入)。 --- src/app/(app)/settings/monitor/page.tsx | 10 +++++++++- src/app/api/monitor/scan-emails/route.ts | 6 +++++- src/app/api/monitor/scan-history/route.ts | 3 +++ src/app/api/monitor/settings/route.ts | 9 +++++++++ src/app/api/monitor/status/route.ts | 13 ++++++++++++- src/app/api/monitor/test-mail/route.ts | 3 +++ src/app/api/monitor/test-wechat/route.ts | 2 ++ src/lib/monitor/mail-monitor.ts | 2 +- src/lib/monitor/pop3-fetcher.ts | 10 +++++----- src/lib/monitor/settings-manager.ts | 4 +++- src/lib/monitor/types.ts | 2 ++ 11 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/app/(app)/settings/monitor/page.tsx b/src/app/(app)/settings/monitor/page.tsx index c1c1251..7cb2946 100644 --- a/src/app/(app)/settings/monitor/page.tsx +++ b/src/app/(app)/settings/monitor/page.tsx @@ -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() { setConfig({ ...config, mail: { ...config.mail, imap_port: parseInt(e.target.value) || 993 } })} className={inputClass('mail.imap_port')} /> +
+ + setConfig({ ...config, mail: { ...config.mail, pop3_server: e.target.value } })} className={inputClass('mail.pop3_server')} /> +
+
+ + setConfig({ ...config, mail: { ...config.mail, pop3_port: parseInt(e.target.value) || 995 } })} className={inputClass('mail.pop3_port')} /> +
setConfig({ ...config, mail: { ...config.mail, smtp_server: e.target.value } })} className={inputClass('mail.smtp_server')} /> diff --git a/src/app/api/monitor/scan-emails/route.ts b/src/app/api/monitor/scan-emails/route.ts index d1ae5bd..1100115 100644 --- a/src/app/api/monitor/scan-emails/route.ts +++ b/src/app/api/monitor/scan-emails/route.ts @@ -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) diff --git a/src/app/api/monitor/scan-history/route.ts b/src/app/api/monitor/scan-history/route.ts index 6196809..6fa9c43 100644 --- a/src/app/api/monitor/scan-history/route.ts +++ b/src/app/api/monitor/scan-history/route.ts @@ -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, diff --git a/src/app/api/monitor/settings/route.ts b/src/app/api/monitor/settings/route.ts index 54e088b..b9d4861 100644 --- a/src/app/api/monitor/settings/route.ts +++ b/src/app/api/monitor/settings/route.ts @@ -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 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 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, } diff --git a/src/app/api/monitor/status/route.ts b/src/app/api/monitor/status/route.ts index f811e84..8fb4fea 100644 --- a/src/app/api/monitor/status/route.ts +++ b/src/app/api/monitor/status/route.ts @@ -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) diff --git a/src/app/api/monitor/test-mail/route.ts b/src/app/api/monitor/test-mail/route.ts index 6464fcd..be3a6ce 100644 --- a/src/app/api/monitor/test-mail/route.ts +++ b/src/app/api/monitor/test-mail/route.ts @@ -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 {} diff --git a/src/app/api/monitor/test-wechat/route.ts b/src/app/api/monitor/test-wechat/route.ts index 4cabda7..8c403a3 100644 --- a/src/app/api/monitor/test-wechat/route.ts +++ b/src/app/api/monitor/test-wechat/route.ts @@ -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 : '发送失败' }) } } diff --git a/src/lib/monitor/mail-monitor.ts b/src/lib/monitor/mail-monitor.ts index 5f86b49..1dd86ab 100644 --- a/src/lib/monitor/mail-monitor.ts +++ b/src/lib/monitor/mail-monitor.ts @@ -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}`) } diff --git a/src/lib/monitor/pop3-fetcher.ts b/src/lib/monitor/pop3-fetcher.ts index fbb57c1..94abdd3 100644 --- a/src/lib/monitor/pop3-fetcher.ts +++ b/src/lib/monitor/pop3-fetcher.ts @@ -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> { - 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 diff --git a/src/lib/monitor/settings-manager.ts b/src/lib/monitor/settings-manager.ts index 812c2cd..0f9b02c 100644 --- a/src/lib/monitor/settings-manager.ts +++ b/src/lib/monitor/settings-manager.ts @@ -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) : '' }, diff --git a/src/lib/monitor/types.ts b/src/lib/monitor/types.ts index 638ec2c..2a203ef 100644 --- a/src/lib/monitor/types.ts +++ b/src/lib/monitor/types.ts @@ -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