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:
gitadmin 2026-07-11 19:10:41 +08:00
parent 5152d7d4f2
commit 28c37f1069
11 changed files with 54 additions and 10 deletions

View File

@ -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')} />

View File

@ -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)

View File

@ -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,

View File

@ -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,
}

View File

@ -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' → 1tick_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

View File

@ -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 {}

View File

@ -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 : '发送失败' })
}
}

View File

@ -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}`)
}

View File

@ -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

View File

@ -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) : '' },

View File

@ -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