lolly/internal/cache/purge.go
xfy 179090fa34 fix(security): 修复 2 个 CRITICAL + 6 个 HIGH 安全与代码质量问题
安全修复:
- ConnLimiter Acquire() TOCTOU 竞态: atomic.AddInt64 替代 loadInt64+addInt64
- Cache Purge token 时序侧信道: 改用 subtle.ConstantTimeCompare
- Lua Cosocket SSRF: 新增 ip_guard 两层 IP 检查(字面量+解析后),拒绝私有/回环地址
- X-Accel-Redirect 路径遍历: urlpath.Clean + 前缀拒绝(/internal/ /admin/)
- CRLF 注入: containsCRLF 校验变量展开后的 header 值,logging.Warn 可观测
- Proxy URI 注入: bytes.ContainsAny 检查 path 中的 @\r\n 危险字符

代码质量:
- disk_cache.go Set() 7 处静默 return 改为 logging.Error 日志记录
- config.go 从 2392 行拆分为 9 个按域文件(config/server/proxy/security/ssl/cache/performance/monitoring/variable)

验证: go build + vet + golangci-lint(0 issues) + test(83.2% 无回归) + race detector 全部通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 10:13:47 +08:00

332 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package cache 提供文件缓存和代理缓存功能,支持 LRU 淘汰和缓存锁防击穿。
//
// 该文件实现了缓存清理 API用于主动清理代理缓存。
//
// 主要功能:
// - 精确路径清理:删除指定路径的缓存条目
// - 通配符模式清理:按模式批量删除缓存条目
// - IP 白名单访问控制
// - Token 认证支持
//
// 作者xfy
package cache
import (
"crypto/subtle"
"encoding/json"
"hash/fnv"
"net"
"strings"
"github.com/valyala/fasthttp"
"rua.plus/lolly/internal/config"
"rua.plus/lolly/internal/netutil"
)
// PurgeAPI 缓存清理 API 处理器。
//
// 提供 HTTP API 用于主动清理代理缓存,支持精确路径和通配符模式清理。
//
// 注意事项:
// - 所有方法均为并发安全
// - 支持 IP 白名单和 Token 认证
// - 仅处理 POST 请求
type PurgeAPI struct {
cache *ProxyCache
auth config.CacheAPIAuthConfig
path string
allowed []net.IPNet
}
// PurgeRequest 清理请求结构。
type PurgeRequest struct {
// Path 精确路径
Path string `json:"path,omitempty"`
// Pattern 通配符模式(支持 * 通配符)
Pattern string `json:"pattern,omitempty"`
// Method HTTP 方法,默认 "GET"
Method string `json:"method,omitempty"`
}
// PurgeResponse 清理响应结构。
type PurgeResponse struct {
// Deleted 被删除的缓存条目数
Deleted int `json:"deleted"`
}
// PurgeErrorResponse 错误响应结构。
type PurgeErrorResponse struct {
// Error 错误信息
Error string `json:"error"`
}
// NewPurgeAPI 创建缓存清理 API 处理器。
//
// 参数:
// - cache: 代理缓存实例
// - cfg: 缓存 API 配置
//
// 返回值:
// - *PurgeAPI: 配置好的处理器
// - error: IP 解析失败时返回非 nil 错误
func NewPurgeAPI(cache *ProxyCache, cfg *config.CacheAPIConfig) (*PurgeAPI, error) {
p := &PurgeAPI{
cache: cache,
auth: cfg.Auth,
path: cfg.Path,
}
// 解析允许的 IP 列表
for _, cidr := range cfg.Allow {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
// 尝试作为单个 IP 解析
ip := net.ParseIP(cidr)
if ip == nil {
return nil, err
}
// 转换为 CIDR 格式
if ip.To4() != nil {
_, network, _ = net.ParseCIDR(cidr + "/32")
} else {
_, network, _ = net.ParseCIDR(cidr + "/128")
}
}
if network != nil {
p.allowed = append(p.allowed, *network)
}
}
return p, nil
}
// Path 返回 API 端点路径。
func (p *PurgeAPI) Path() string {
if p.path == "" {
return "/_cache/purge"
}
return p.path
}
// ServeHTTP 处理缓存清理请求。
//
// 仅处理 POST 请求,支持精确路径和通配符模式清理。
// 返回 JSON 格式的响应。
func (p *PurgeAPI) ServeHTTP(ctx *fasthttp.RequestCtx) {
// 仅允许 POST 方法
if string(ctx.Method()) != "POST" {
p.sendError(ctx, fasthttp.StatusMethodNotAllowed, "method not allowed")
return
}
// 检查 IP 访问权限
if !p.checkAccess(ctx) {
p.sendError(ctx, fasthttp.StatusForbidden, "forbidden")
return
}
// 检查认证
if !p.checkAuth(ctx) {
p.sendError(ctx, fasthttp.StatusUnauthorized, "unauthorized")
return
}
// 解析请求体
var req PurgeRequest
if err := json.Unmarshal(ctx.PostBody(), &req); err != nil {
p.sendError(ctx, fasthttp.StatusBadRequest, "invalid request body")
return
}
// 执行清理
deleted := 0
if req.Path != "" {
deleted = p.purgeByPath(req.Path)
} else if req.Pattern != "" {
deleted = p.purgeByPattern(req.Pattern)
} else {
p.sendError(ctx, fasthttp.StatusBadRequest, "missing path or pattern")
return
}
// 返回响应
ctx.SetContentType("application/json; charset=utf-8")
ctx.SetStatusCode(fasthttp.StatusOK)
_ = json.NewEncoder(ctx).Encode(PurgeResponse{Deleted: deleted})
}
// checkAccess 检查客户端 IP 是否在允许列表中。
func (p *PurgeAPI) checkAccess(ctx *fasthttp.RequestCtx) bool {
// 如果没有配置允许列表,允许所有访问
if len(p.allowed) == 0 {
return true
}
clientIP := p.getClientIP(ctx)
if clientIP == nil {
return false
}
// 检查是否在允许列表中
for _, network := range p.allowed {
if network.Contains(clientIP) {
return true
}
}
return false
}
// checkAuth 检查认证。
func (p *PurgeAPI) checkAuth(ctx *fasthttp.RequestCtx) bool {
// 无需认证
if p.auth.Type == "" || p.auth.Type == "none" {
return true
}
// Token 认证
if p.auth.Type == "token" {
// 从 Authorization header 获取 token
authHeader := ctx.Request.Header.Peek("Authorization")
if len(authHeader) == 0 {
return false
}
// 支持 Bearer token 格式
authStr := string(authHeader)
if token, ok := strings.CutPrefix(authStr, "Bearer "); ok {
return subtle.ConstantTimeCompare([]byte(token), []byte(p.auth.Token)) == 1
}
// 也支持直接传递 token
return subtle.ConstantTimeCompare([]byte(authStr), []byte(p.auth.Token)) == 1
}
return false
}
// getClientIP 从请求上下文提取客户端 IP。
func (p *PurgeAPI) getClientIP(ctx *fasthttp.RequestCtx) net.IP {
return netutil.ExtractClientIPNet(ctx)
}
// purgeByPath 按精确路径清理缓存。
func (p *PurgeAPI) purgeByPath(path string) int {
if p.cache == nil {
return 0
}
// 计算缓存键的哈希值
hashKey := hashPath(path)
// 尝试删除
p.cache.mu.Lock()
defer p.cache.mu.Unlock()
if _, ok := p.cache.entries[hashKey]; ok {
delete(p.cache.entries, hashKey)
return 1
}
return 0
}
// purgeByPattern 按通配符模式清理缓存。
func (p *PurgeAPI) purgeByPattern(pattern string) int {
if p.cache == nil {
return 0
}
p.cache.mu.Lock()
defer p.cache.mu.Unlock()
deleted := 0
for hashKey, entry := range p.cache.entries {
if matchPattern(pattern, entry.OrigKey) {
delete(p.cache.entries, hashKey)
deleted++
}
}
return deleted
}
// HashPathWithMethod 使用 FNV-64a 计算缓存键的哈希值。
// method 为空时默认使用 "GET"。
func HashPathWithMethod(path string, method string) uint64 {
if method == "" {
method = "GET"
}
key := method + ":" + path
h := fnv.New64a()
h.Write([]byte(key))
return h.Sum64()
}
// hashPath 使用 FNV-64a 计算路径的哈希值。
// 与代理层 buildCacheKeyHash 使用相同的算法,确保一致性。
// 注意:代理层的 key 格式为 "METHOD:URI"purge 时默认使用 GET 方法。
func hashPath(path string) uint64 {
return HashPathWithMethod(path, "GET")
}
// MatchPattern 检查路径是否匹配通配符模式。
//
// 支持以下匹配模式:
// - "*":匹配所有路径
// - 以 "*" 结尾:前缀匹配(如 "/api/*" 匹配 "/api/xxx"
// - 以 "/" 结尾:目录前缀匹配
// - 中间通配符:"/api/*/users" 匹配 "/api/v1/users"
// - 其他:精确匹配
//
// 参数:
// - pattern: 匹配模式,支持通配符
// - path: 待检查的路径
//
// 返回值:
// - bool: true 表示匹配false 表示不匹配
func MatchPattern(pattern, path string) bool {
// 特殊情况:* 匹配所有
if pattern == "*" {
return true
}
// 目录前缀匹配pattern 以 / 结尾)
if strings.HasSuffix(pattern, "/") {
return strings.HasPrefix(path, pattern)
}
// 检查是否有通配符
if !strings.Contains(pattern, "*") {
return path == pattern
}
// 简单的前缀匹配:/api/users/* 匹配 /api/users/123
if prefix, ok := strings.CutSuffix(pattern, "*"); ok {
return strings.HasPrefix(path, prefix)
}
// 中间通配符:/api/*/users 匹配 /api/v1/users
parts := strings.Split(pattern, "*")
if len(parts) == 2 {
return strings.HasPrefix(path, parts[0]) && strings.HasSuffix(path, parts[1])
}
// 复杂模式不支持,返回 false
return false
}
// matchPattern 是 MatchPattern 的内部别名,保持向后兼容。
func matchPattern(pattern, path string) bool {
return MatchPattern(pattern, path)
}
// sendError 发送错误响应。
func (p *PurgeAPI) sendError(ctx *fasthttp.RequestCtx, status int, errMsg string) {
ctx.SetContentType("application/json; charset=utf-8")
ctx.SetStatusCode(status)
_ = json.NewEncoder(ctx).Encode(PurgeErrorResponse{Error: errMsg})
}