mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-07 15:08:41 +00:00
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:
@@ -130,7 +130,7 @@ export class ConfigService {
|
|||||||
httpApiToken: '',
|
httpApiToken: '',
|
||||||
httpApiEnabled: false,
|
httpApiEnabled: false,
|
||||||
httpApiPort: 5031,
|
httpApiPort: 5031,
|
||||||
httpApiHost: '0.0.0.0',
|
httpApiHost: '127.0.0.1',
|
||||||
messagePushEnabled: false,
|
messagePushEnabled: false,
|
||||||
windowCloseBehavior: 'ask',
|
windowCloseBehavior: 'ask',
|
||||||
quoteLayout: 'quote-top',
|
quoteLayout: 'quote-top',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as http from 'http'
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { URL } from 'url'
|
import { URL } from 'url'
|
||||||
|
import { timingSafeEqual } from 'crypto'
|
||||||
import { chatService, Message } from './chatService'
|
import { chatService, Message } from './chatService'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
@@ -268,9 +269,19 @@ class HttpService {
|
|||||||
*/
|
*/
|
||||||
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
|
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
|
||||||
if (req.method !== 'POST') return {}
|
if (req.method !== 'POST') return {}
|
||||||
|
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let body = ''
|
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', () => {
|
req.on('end', () => {
|
||||||
try {
|
try {
|
||||||
resolve(JSON.parse(body))
|
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 {
|
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
|
||||||
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
|
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
|
const authHeader = req.headers.authorization
|
||||||
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
|
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
|
||||||
const token = authHeader.substring(7).trim()
|
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')
|
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']
|
const bodyToken = body['access_token']
|
||||||
return !!(bodyToken && String(bodyToken).trim() === expectedToken);
|
return !!(bodyToken && this.safeEqual(String(bodyToken).trim(), expectedToken))
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 HTTP 请求 (重构后)
|
* 处理 HTTP 请求 (重构后)
|
||||||
*/
|
*/
|
||||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
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-Methods', 'GET, POST, DELETE, OPTIONS')
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||||
|
|
||||||
@@ -431,9 +456,15 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
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 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)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
this.sendError(res, 404, 'Media not found')
|
this.sendError(res, 404, 'Media not found')
|
||||||
|
|||||||
Reference in New Issue
Block a user