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 {
|
interface MonitorConfig {
|
||||||
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
|
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 } }
|
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[] }
|
wechat: { webhooks: WebhookConfig[] }
|
||||||
}
|
}
|
||||||
|
|
@ -694,6 +694,14 @@ export default function MonitorSettingsPage() {
|
||||||
<label className="block text-sm font-medium mb-1">IMAP 端口</label>
|
<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')} />
|
<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>
|
||||||
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">SMTP 服务器</label>
|
<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')} />
|
<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`)
|
console.log(`[Scan] IMAP 返回空 (uid=${uid}),启用 POP3 fallback`)
|
||||||
pop3Attempted = true // 无论成败都置位,避免每封空邮件都重连
|
pop3Attempted = true // 无论成败都置位,避免每封空邮件都重连
|
||||||
try {
|
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) {
|
} catch (pop3Err) {
|
||||||
console.error('[Scan] POP3 fallback 失败:', pop3Err instanceof Error ? pop3Err.message : 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),
|
JSON.stringify(scanState.details),
|
||||||
userId
|
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}`)
|
console.log(`[Scan] 扫描历史已保存: 状态=${scanState.status}, 导入=${scanState.stats.imported}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Scan] 保存扫描历史失败:', err)
|
console.error('[Scan] 保存扫描历史失败:', err)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
interface ScanHistoryItem {
|
interface ScanHistoryItem {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -84,6 +85,8 @@ export async function DELETE(request: NextRequest) {
|
||||||
WHERE created_at < datetime('now', '+8 hours', ? || ' days')
|
WHERE created_at < datetime('now', '+8 hours', ? || ' days')
|
||||||
`).run(-keepDays)
|
`).run(-keepDays)
|
||||||
|
|
||||||
|
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'delete', entityType: 'scan_history', details: { keepDays, deleted: result.changes }, ipAddress: getClientIP(request) })
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
deleted: result.changes,
|
deleted: result.changes,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import { hasPermission } from '@/lib/permissions'
|
||||||
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
|
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
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> = {
|
const VALIDATORS: Record<string, (v: unknown) => boolean> = {
|
||||||
'monitor.enabled': (v) => typeof v === 'boolean',
|
'monitor.enabled': (v) => typeof v === 'boolean',
|
||||||
'monitor.interval_seconds': (v) => typeof v === 'number' && v >= 10 && v <= 3600,
|
'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_start_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
|
||||||
'monitor.silent_end_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.address': (v) => typeof v === 'string' && v.includes('@'),
|
||||||
|
'mail.imap_server': isValidHost,
|
||||||
'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
'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,
|
'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||||
'wechat.webhooks': (v) => Array.isArray(v) && v.length > 0,
|
'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 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 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 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
|
const ongoingFaultsCount = (db.prepare("SELECT COUNT(*) as c FROM fault_records WHERE status = 'ongoing'").get() as { c: number }).c
|
||||||
|
|
||||||
// 获取最近的活动(tick_complete 或 error)
|
// 获取最近的活动(tick_complete 或 error)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
import { ImapFlow } from 'imapflow'
|
import { ImapFlow } from 'imapflow'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|
@ -21,8 +22,10 @@ export async function POST(request: NextRequest) {
|
||||||
const lock = await client.getMailboxLock('INBOX')
|
const lock = await client.getMailboxLock('INBOX')
|
||||||
lock.release()
|
lock.release()
|
||||||
await client.logout()
|
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 选择正常' })
|
return NextResponse.json({ success: true, message: 'IMAP 连接成功,INBOX 选择正常' })
|
||||||
} catch (e) {
|
} 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 : '连接失败' })
|
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '连接失败' })
|
||||||
} finally {
|
} finally {
|
||||||
try { await client.logout() } catch {}
|
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) })
|
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送成功' }, ipAddress: getClientIP(request) })
|
||||||
return NextResponse.json({ success: true, message: '测试消息发送成功' })
|
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 || '发送失败' })
|
return NextResponse.json({ success: false, error: result.errmsg || '发送失败' })
|
||||||
} catch (e) {
|
} 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 : '发送失败' })
|
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '发送失败' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ export class MailMonitor {
|
||||||
if (!source && !pop3Attempted) {
|
if (!source && !pop3Attempted) {
|
||||||
pop3Attempted = true
|
pop3Attempted = true
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error(`[Worker] POP3 fallback 失败: ${e instanceof Error ? e.message : 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(
|
export async function fetchPOP3Emails(
|
||||||
mailAddress: string,
|
mailAddress: string,
|
||||||
mailPassword: string,
|
mailPassword: string,
|
||||||
count: number
|
count: number,
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
): Promise<Map<string, Buffer>> {
|
): Promise<Map<string, Buffer>> {
|
||||||
const POP3_HOST = 'pophz.qiye.163.com'
|
|
||||||
const POP3_PORT = 995
|
|
||||||
const POP3_CMD_TIMEOUT = 30000
|
const POP3_CMD_TIMEOUT = 30000
|
||||||
const POP3_MAX_RESPONSE = 50 * 1024 * 1024 // 单封邮件响应上限 50MB,防内存耗尽
|
const POP3_MAX_RESPONSE = 50 * 1024 * 1024 // 单封邮件响应上限 50MB,防内存耗尽
|
||||||
|
|
||||||
|
|
@ -38,9 +38,9 @@ export async function fetchPOP3Emails(
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
console.log(`[Scan] POP3: 连接 ${POP3_HOST}:${POP3_PORT}, 获取最近 ${count} 封`)
|
console.log(`[Scan] POP3: 连接 ${host}:${port}, 获取最近 ${count} 封`)
|
||||||
// 网易企业邮箱持有受信任 CA 证书,启用完整证书 + SNI 校验
|
// 网易企业邮箱持有受信任 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 settled = false
|
||||||
let cmdResolve: ((v: Buffer) => void) | null = null
|
let cmdResolve: ((v: Buffer) => void) | null = null
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ const DEFAULT_WEBHOOK: WebhookConfig = {
|
||||||
|
|
||||||
const DEFAULT_CONFIG: MonitorConfig = {
|
const DEFAULT_CONFIG: MonitorConfig = {
|
||||||
monitor: { enabled: false, interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
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: {
|
filter: {
|
||||||
subject_keywords: ['服务器故障单'],
|
subject_keywords: ['服务器故障单'],
|
||||||
sender_email: 'tencent_dcops@tencent.com',
|
sender_email: 'tencent_dcops@tencent.com',
|
||||||
|
|
@ -110,6 +110,8 @@ export function getMonitorConfig(): MonitorConfig {
|
||||||
'mail.address': (v) => { config.mail.address = v },
|
'mail.address': (v) => { config.mail.address = v },
|
||||||
'mail.imap_server': (v) => { config.mail.imap_server = v },
|
'mail.imap_server': (v) => { config.mail.imap_server = v },
|
||||||
'mail.imap_port': (v) => { config.mail.imap_port = parseInt(v) || 993 },
|
'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_server': (v) => { config.mail.smtp_server = v },
|
||||||
'mail.smtp_port': (v) => { config.mail.smtp_port = parseInt(v) || 465 },
|
'mail.smtp_port': (v) => { config.mail.smtp_port = parseInt(v) || 465 },
|
||||||
'mail.password': (v) => { config.mail.password = v ? decryptValue(v) : '' },
|
'mail.password': (v) => { config.mail.password = v ? decryptValue(v) : '' },
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ export interface MonitorConfig {
|
||||||
address: string
|
address: string
|
||||||
imap_server: string
|
imap_server: string
|
||||||
imap_port: number
|
imap_port: number
|
||||||
|
pop3_server: string
|
||||||
|
pop3_port: number
|
||||||
smtp_server: string
|
smtp_server: string
|
||||||
smtp_port: number
|
smtp_port: number
|
||||||
password: string
|
password: string
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue