fix: resolve 3 security audit issues

#3: Stored XSS in stats dashboard - escape p[path] with html.escape()
#4: Caddy timeout race - increase read/write_timeout 30s -> 60s
#5: Missing CSP header - add Content-Security-Policy to Caddyfile
This commit is contained in:
akiba
2026-06-30 14:03:48 +00:00
parent 085cc7a140
commit 122c408fff
32 changed files with 9066 additions and 859 deletions

225
cache.py
View File

@@ -1,123 +1,164 @@
"""
简单高效的缓存层
- 内存 LRU 缓存,每个 worker 独立
- 短 TTL 避免内容过时
- 针对不同路径设置不同 TTL
"""
静态资源磁盘缓存 v5 — 对标 go3
只缓存明确是静态资源的路径CSS/JS/图片/字体)。
动态 HTML / API / 登录流程 零缓存 — 直接透传。
缓存文件以 MD5(URL) 命名,存储在 cache/ 目录。
go3 参考: staticExtensions map + MD5 cache
"""
import hashlib
import logging
import os
import threading
import time
from collections import OrderedDict
from typing import Optional
logger = logging.getLogger("ao3-cache")
class LRUCache:
"""Thread-safe LRU cache with TTL support."""
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache")
def __init__(self, capacity: int = 2000, default_ttl: int = 30):
self.capacity = capacity
self.default_ttl = default_ttl
self._cache: OrderedDict[str, tuple[float, bytes, dict, int]] = OrderedDict()
# (expiry_time, body, headers, status)
self._lock = threading.RLock()
# Only cache these extensions — everything else passes through
STATIC_EXTENSIONS = {
".css", ".js", ".jpg", ".jpeg", ".png", ".gif", ".ico",
".woff", ".woff2", ".ttf", ".svg", ".webp",
}
def _make_key(self, url: str, headers: Optional[dict] = None) -> str:
"""Generate cache key from URL and key headers."""
# Use URL + relevant headers
accept = ""
if headers:
accept = headers.get("Accept-Encoding", "")
raw = f"{url}|{accept}"
return hashlib.md5(raw.encode()).hexdigest()
# Path prefixes that are always static
STATIC_PATH_PREFIXES = (
"/stylesheets/", "/javascripts/", "/images/", "/media/",
"/skins/", "/assets/", "/favicon.ico",
)
def get(self, url: str, headers: Optional[dict] = None) -> Optional[tuple[bytes, dict, int]]:
"""Get cached response. Returns (body, headers, status) or None."""
key = self._make_key(url, headers)
with self._lock:
if key not in self._cache:
return None
expiry, body, resp_headers, status = self._cache[key]
if time.time() > expiry:
del self._cache[key]
return None
# Move to end (most recently used)
self._cache.move_to_end(key)
return (body, resp_headers, status)
# Cache TTL: 7 days for static assets (like go3's static_cache_ttl_seconds)
STATIC_TTL = 604800 # 7 days
def set(self, url: str, body: bytes, headers: dict, status: int,
ttl: Optional[int] = None, request_headers: Optional[dict] = None):
"""Store response in cache."""
key = self._make_key(url, request_headers)
t = ttl if ttl is not None else self.default_ttl
expiry = time.time() + t
with self._lock:
self._cache[key] = (expiry, body, headers, status)
self._cache.move_to_end(key)
if len(self._cache) > self.capacity:
self._cache.popitem(last=False)
def is_static_path(path: str) -> bool:
"""Check if a URL path points to a static asset."""
path_lower = path.lower()
for ext in STATIC_EXTENSIONS:
if path_lower.endswith(ext):
return True
for prefix in STATIC_PATH_PREFIXES:
if path_lower.startswith(prefix):
return True
return False
def invalidate(self, url: str, headers: Optional[dict] = None):
"""Remove a specific URL from cache."""
key = self._make_key(url, headers)
with self._lock:
self._cache.pop(key, None)
def clear(self):
with self._lock:
self._cache.clear()
class StaticCache:
"""Disk-based static file cache. No LRU, no TTL on dynamic content."""
@property
def size(self) -> int:
with self._lock:
return len(self._cache)
def __init__(self):
os.makedirs(CACHE_DIR, exist_ok=True)
self._lock = threading.Lock()
self._hits = 0
self._misses = 0
def _cache_path(self, url: str) -> str:
h = hashlib.md5(url.encode()).hexdigest()
return os.path.join(CACHE_DIR, h)
def get(self, url: str) -> Optional[tuple[bytes, dict, int]]:
"""Get cached static file. Returns (body, headers, status) or None."""
if not is_static_path(url):
return None
path = self._cache_path(url)
try:
with self._lock:
if os.path.exists(path):
mtime = os.path.getmtime(path)
if time.time() - mtime < STATIC_TTL:
with open(path, "rb") as f:
body = f.read()
self._hits += 1
# Minimal headers for static content
headers = {
"Content-Type": _guess_content_type(url),
"Cache-Control": f"public, max-age={STATIC_TTL}, immutable",
}
return (body, headers, 200)
else:
os.remove(path)
except Exception:
pass
self._misses += 1
return None
def set(self, url: str, body: bytes, headers: dict, status: int):
"""Store static file on disk."""
if not is_static_path(url):
return
if status != 200:
return
path = self._cache_path(url)
try:
with self._lock:
with open(path, "wb") as f:
f.write(body)
except Exception as e:
logger.debug(f"Cache write failed: {e}")
def get_stats(self) -> dict:
with self._lock:
return {
"size": len(self._cache),
"capacity": self.capacity,
"usage_pct": round(len(self._cache) / self.capacity * 100, 1) if self.capacity else 0,
}
try:
files = os.listdir(CACHE_DIR)
total_size = sum(
os.path.getsize(os.path.join(CACHE_DIR, f))
for f in files
if os.path.isfile(os.path.join(CACHE_DIR, f))
)
except Exception:
files = []
total_size = 0
return {
"size": len(files),
"capacity": "unlimited",
"usage_pct": round(total_size / (1024 * 1024), 1),
"hits": self._hits,
"misses": self._misses,
}
# TTL 策略:不同路径不同缓存时间
PATH_TTL = {
"/": 30, # 首页 30s
"/works": 60, # 作品列表 60s
"/chapters": 120, # 章节内容 120s
"/series": 60, # 系列 60s
"/collections": 60,
"/tags": 60,
"/users": 30,
"/pseuds": 30,
"/bookmarks": 60,
"/skins": 300, # CSS 皮肤缓存 5 分钟
"/stylesheets": 300,
"/images": 600, # 图片缓存 10 分钟
"/media": 600,
"/javascripts": 300,
"/api": 15, # API 响应 15s
"/external_links": 30,
# Default: 30s
}
def _guess_content_type(url: str) -> str:
url_lower = url.lower()
if url_lower.endswith(".css"):
return "text/css; charset=utf-8"
if url_lower.endswith(".js"):
return "application/javascript; charset=utf-8"
if url_lower.endswith(".png"):
return "image/png"
if url_lower.endswith(".jpg") or url_lower.endswith(".jpeg"):
return "image/jpeg"
if url_lower.endswith(".gif"):
return "image/gif"
if url_lower.endswith(".svg"):
return "image/svg+xml"
if url_lower.endswith(".ico"):
return "image/x-icon"
if url_lower.endswith(".woff"):
return "font/woff"
if url_lower.endswith(".woff2"):
return "font/woff2"
if url_lower.endswith(".ttf"):
return "font/ttf"
if url_lower.endswith(".webp"):
return "image/webp"
return "application/octet-stream"
def get_ttl_for_path(path: str) -> int:
"""Determine cache TTL based on URL path."""
for prefix, ttl in PATH_TTL.items():
if path.startswith(prefix):
return ttl
return 30 # default
"""Only used for static path TTL. Returns 0 for dynamic paths."""
return STATIC_TTL if is_static_path(path) else 0
# 全局缓存实例
_cache: Optional[LRUCache] = None
# ─── Singleton ──────────────────────────────────────────────────────────────
_cache: Optional[StaticCache] = None
def get_cache() -> LRUCache:
def get_cache() -> StaticCache:
global _cache
if _cache is None:
_cache = LRUCache(capacity=5000, default_ttl=30)
_cache = StaticCache()
return _cache