fix: address multiple code audit findings

- CORS: replace wildcard with explicit origin list from CORS_ORIGINS env
- Auth: enforce strong defaults, JWT blacklist (RevokedToken model), login rate limiting
- Auth: validate password length before bcrypt (72-byte limit)
- Scheduler: single-threaded worker to mitigate SQLite write contention
- Scheduler: graceful shutdown (wait=True)
- Snapshots: add prune_snapshots() with configurable retention count
- Storage: isolate localStorage keys via VITE_APP_KEY prefix
- Config: add cors_origins, login_rate_limit, snapshot_retention_count settings
This commit is contained in:
SmartUp Developer
2026-05-17 10:52:18 +08:00
parent a42ecf7bcc
commit ad16618406
25 changed files with 792 additions and 165 deletions
+62 -6
View File
@@ -1,18 +1,63 @@
from fastapi import APIRouter, Depends, HTTPException, status
from datetime import datetime, timezone
from threading import Lock
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db
from app.models.admin_user import AdminUser
from app.models.revoked_token import RevokedToken
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo
from app.utils.auth import verify_password, create_access_token, get_current_user
from app.utils.auth import bearer_scheme, create_access_token, decode_token_payload, get_current_user, verify_password
router = APIRouter(prefix="/api/auth", tags=["auth"])
_login_attempts: dict[tuple[str, str], list[float]] = {}
_login_attempts_lock = Lock()
def _login_key(request: Request, email: str) -> tuple[str, str]:
forwarded = request.headers.get("x-forwarded-for", "").split(",", 1)[0].strip()
ip = forwarded or (request.client.host if request.client else "unknown")
return ip, email.lower()
def _check_login_limit(key: tuple[str, str]) -> None:
settings = get_settings()
now = datetime.now(timezone.utc).timestamp()
cutoff = now - settings.login_rate_limit_window_seconds
with _login_attempts_lock:
attempts = [item for item in _login_attempts.get(key, []) if item >= cutoff]
if len(attempts) >= settings.login_rate_limit_attempts:
_login_attempts[key] = attempts
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="登录尝试过多,请稍后再试")
_login_attempts[key] = attempts
def _record_login_failure(key: tuple[str, str]) -> None:
with _login_attempts_lock:
_login_attempts.setdefault(key, []).append(datetime.now(timezone.utc).timestamp())
def _clear_login_failures(key: tuple[str, str]) -> None:
with _login_attempts_lock:
_login_attempts.pop(key, None)
@router.post("/login", response_model=TokenResponse)
def login(req: LoginRequest, db: Session = Depends(get_db)):
def login(req: LoginRequest, request: Request, db: Session = Depends(get_db)):
key = _login_key(request, req.email)
_check_login_limit(key)
user = db.query(AdminUser).filter(AdminUser.email == req.email).first()
if not user or not verify_password(req.password, user.password_hash):
try:
password_ok = bool(user and verify_password(req.password, user.password_hash))
except ValueError:
password_ok = False
if not password_ok:
_record_login_failure(key)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
_clear_login_failures(key)
token = create_access_token(user.email)
return TokenResponse(access_token=token)
@@ -23,6 +68,17 @@ def me(current_user: AdminUser = Depends(get_current_user)):
@router.post("/logout")
def logout():
# JWT is stateless — client discards token
def logout(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
_current_user: AdminUser = Depends(get_current_user),
):
payload = decode_token_payload(credentials.credentials)
jti = payload.get("jti") if payload else None
exp = payload.get("exp") if payload else None
if jti and exp and not db.query(RevokedToken).filter(RevokedToken.jti == jti).first():
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
db.add(RevokedToken(jti=jti, expires_at=expires_at))
db.query(RevokedToken).filter(RevokedToken.expires_at < datetime.now(timezone.utc)).delete(synchronize_session=False)
db.commit()
return {"message": "logged out"}
+34 -10
View File
@@ -41,6 +41,10 @@ class BrowserSessionResponse(BaseModel):
title: str
class BrowserSelectionResponse(BaseModel):
text: str
class BrowserEvent(BaseModel):
type: Literal["click", "dblclick", "mousemove", "mousedown", "mouseup", "type", "key", "scroll", "reload", "back", "forward", "resize"]
x: Optional[float] = None
@@ -119,6 +123,14 @@ async def send_event(session_id: str, body: BrowserEvent, _=Depends(get_current_
raise _error_from_browser(exc)
@router.get("/{session_id}/selection", response_model=BrowserSelectionResponse)
async def get_selection(session_id: str, _=Depends(get_current_user)):
try:
return BrowserSelectionResponse(text=await browser_sessions.selected_text(session_id))
except Exception as exc:
raise _error_from_browser(exc)
@router.delete("/{session_id}", status_code=204)
async def close_session(session_id: str, _=Depends(get_current_user)):
await browser_sessions.close(session_id)
@@ -126,9 +138,12 @@ async def close_session(session_id: str, _=Depends(get_current_user)):
# ——— WebSocket stream ———
# Frame interval & diff detection
_WS_MIN_INTERVAL = 0.05 # 50 ms floor (≈20 fps max)
_WS_IDLE_INTERVAL = 0.15 # 150 ms when nothing changed recently
_WS_ACTIVE_INTERVAL = 0.08 # 80 ms right after a user event
_WS_MIN_INTERVAL = 0.10
_WS_IDLE_INTERVAL = 0.35
_WS_ACTIVE_INTERVAL = 0.12
_WS_BACKOFF_INTERVAL = 0.60
_WS_DEEP_IDLE_INTERVAL = 1.00
_WS_ACTIVE_WINDOW = 1.25
async def _ws_authenticate(token: Optional[str]) -> bool:
@@ -163,10 +178,11 @@ async def session_ws(
# Track when a user event arrived so we can temporarily speed up
last_event_at: float = 0.0
last_frame_hash: str = ""
unchanged_count = 0
# Task: receive events from client
async def receive_loop():
nonlocal last_event_at
nonlocal last_event_at, unchanged_count
try:
while True:
raw = await websocket.receive_text()
@@ -179,8 +195,9 @@ async def session_ws(
continue
payload: dict[str, Any] = {k: v for k, v in msg.items() if k != "type"}
try:
await browser_sessions.event(session_id, msg_type, payload)
await browser_sessions.event(session_id, msg_type, payload, include_state=False)
last_event_at = asyncio.get_event_loop().time()
unchanged_count = 0
except Exception as exc:
logger.warning("ws event error: %s", exc)
try:
@@ -194,17 +211,22 @@ async def session_ws(
# Task: push screenshots
async def push_loop():
nonlocal last_frame_hash
nonlocal last_frame_hash, unchanged_count
try:
while True:
now = asyncio.get_event_loop().time()
# Faster cadence right after a user interaction
interval = _WS_ACTIVE_INTERVAL if (now - last_event_at) < 1.0 else _WS_IDLE_INTERVAL
if (now - last_event_at) < _WS_ACTIVE_WINDOW:
interval = _WS_ACTIVE_INTERVAL
elif unchanged_count >= 9:
interval = _WS_DEEP_IDLE_INTERVAL
elif unchanged_count >= 3:
interval = _WS_BACKOFF_INTERVAL
else:
interval = _WS_IDLE_INTERVAL
try:
frame = await browser_sessions.screenshot(session_id)
except KeyError:
# Session gone
await websocket.send_json({"error": "session_not_found"})
break
except Exception as exc:
@@ -212,14 +234,16 @@ async def session_ws(
await asyncio.sleep(interval)
continue
# Only push if content changed
frame_hash = hashlib.md5(frame).hexdigest()
if frame_hash != last_frame_hash:
last_frame_hash = frame_hash
unchanged_count = 0
try:
await websocket.send_bytes(frame)
except Exception:
break
else:
unchanged_count += 1
await asyncio.sleep(max(_WS_MIN_INTERVAL, interval))
except (WebSocketDisconnect, asyncio.CancelledError):
+10
View File
@@ -154,8 +154,18 @@ def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current
try:
client.login()
groups = client.get_available_groups(u.groups_endpoint)
u.last_status = "healthy"
u.last_error = None
u.last_checked_at = datetime.now(timezone.utc)
u.consecutive_failures = 0
db.commit()
return TestResult(success=True, message=f"连接成功,获取到 {len(groups)} 个分组")
except Exception as exc:
u.last_status = "unhealthy"
u.last_error = str(exc)
u.last_checked_at = datetime.now(timezone.utc)
u.consecutive_failures = (u.consecutive_failures or 0) + 1
db.commit()
return TestResult(success=False, message="连接失败", detail=str(exc))
+2
View File
@@ -176,6 +176,8 @@ def delete_website(wid: int, db: Session = Depends(get_db), _=Depends(get_curren
row = db.query(Website).filter(Website.id == wid).first()
if not row:
raise HTTPException(404, "website not found")
db.query(WebsiteSyncLog).filter(WebsiteSyncLog.website_id == wid).delete(synchronize_session=False)
db.query(WebsiteGroupBinding).filter(WebsiteGroupBinding.website_id == wid).delete(synchronize_session=False)
db.delete(row)
db.commit()