fix(security): harden HTTP API service against multiple vulnerabilities

1. Path traversal in /api/v1/media/ — use path.resolve() and verify
   resolved path stays within media base directory
2. DoS via unlimited POST body — add 10MB size limit to parseBody()
3. Default no-auth — reject all requests when httpApiToken is not
   configured instead of silently allowing everything
4. Overly permissive CORS — restrict Access-Control-Allow-Origin from
   wildcard (*) to localhost/127.0.0.1 only
5. Timing attack on token comparison — use crypto.timingSafeEqual()
   instead of === for token verification
6. Unsafe default bind address — revert httpApiHost default from
   0.0.0.0 back to 127.0.0.1 to prevent network exposure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
huanghe
2026-04-06 14:05:56 +08:00
parent 209b91bfef
commit 62395b275d
2 changed files with 42 additions and 11 deletions

View File

@@ -6,6 +6,7 @@ import * as http from 'http'
import * as fs from 'fs'
import * as path from 'path'
import { URL } from 'url'
import { timingSafeEqual } from 'crypto'
import { chatService, Message } from './chatService'
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
@@ -268,9 +269,19 @@ class HttpService {
*/
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
if (req.method !== 'POST') return {}
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
return new Promise((resolve) => {
let body = ''
req.on('data', chunk => { body += chunk.toString() })
let bodySize = 0
req.on('data', chunk => {
bodySize += chunk.length
if (bodySize > MAX_BODY_SIZE) {
req.destroy()
resolve({})
return
}
body += chunk.toString()
})
req.on('end', () => {
try {
resolve(JSON.parse(body))
@@ -285,30 +296,44 @@ class HttpService {
/**
* 鉴权拦截器
*/
private safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a)
const bufB = Buffer.from(b)
if (bufA.length !== bufB.length) return false
return timingSafeEqual(bufA, bufB)
}
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
if (!expectedToken) return true
if (!expectedToken) {
// token 未配置时拒绝所有请求,防止未授权访问
console.warn('[HttpService] Access denied: httpApiToken not configured')
return false
}
const authHeader = req.headers.authorization
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7).trim()
if (token === expectedToken) return true
if (this.safeEqual(token, expectedToken)) return true
}
const queryToken = url.searchParams.get('access_token')
if (queryToken && queryToken.trim() === expectedToken) return true
if (queryToken && this.safeEqual(queryToken.trim(), expectedToken)) return true
const bodyToken = body['access_token']
return !!(bodyToken && String(bodyToken).trim() === expectedToken);
return !!(bodyToken && this.safeEqual(String(bodyToken).trim(), expectedToken))
}
/**
* 处理 HTTP 请求 (重构后)
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
res.setHeader('Access-Control-Allow-Origin', '*')
// 仅允许本地来源的跨域请求
const origin = req.headers.origin || ''
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
@@ -431,9 +456,15 @@ class HttpService {
}
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
const mediaBasePath = this.getApiMediaExportPath()
const mediaBasePath = path.resolve(this.getApiMediaExportPath())
const relativePath = pathname.replace('/api/v1/media/', '')
const fullPath = path.join(mediaBasePath, relativePath)
const fullPath = path.resolve(mediaBasePath, relativePath)
// 防止路径穿越攻击
if (!fullPath.startsWith(mediaBasePath + path.sep) && fullPath !== mediaBasePath) {
this.sendError(res, 403, 'Forbidden')
return
}
if (!fs.existsSync(fullPath)) {
this.sendError(res, 404, 'Media not found')