fix(security): P1/P2 审计修复 + JWT HttpOnly Cookie 双模认证 + 限流

- P0/P1 审计修复: 滑块验证码不再下发 x_position/成败即销毁 key、
  user-login 补失败计数+滑块门控、限流标识改 X-Real-IP、
  百度翻译 appkey 环境化、ChangeEmail/ChangePhone 补调 avalidate、
  logs/tasks.py Count(filter=Q) 修复、chat 收藏 SSRF 内网黑名单
- P1 #6/7: token_blacklist + ROTATE_REFRESH_TOKENS 开启,
  /user/token/refresh/ 挂载
- #2 JWT HttpOnly Cookie 双模认证: user/cookie_auth.py 种/清 Cookie,
  user/authentication.py CookieOrHeaderJWTAuthentication(Bearer 优先/_COOKIE 兜底),
  user/views/token.py CookieTokenRefreshView + UserLogoutAPIView(/user/logout/),
  create_standardized_response 自动对含 token 的响应种 Cookie,
  异步视图内 RefreshToken.for_user 全部 sync_to_async 包裹(修 SynchronousOnlyOperation 500),
  WS ChatConsumer 优先读 Cookie token
- P2 #11 限流: utils/rate_limit.py 固定窗口频控,
  shorturl 生成 匿名10次/分+登录60次/分, 邮箱验证码 同邮箱60s1次+同IP10次/10min,
  登录/注册验证码 错5次作废+成功即销毁防重放, 换绑邮箱/手机 同步落地,
  urls.py 补挂 shorturl 路由(此前 404)
This commit is contained in:
2026-09-08 11:28:00 +08:00
parent 2944b19e6f
commit 3618323192
22 changed files with 711 additions and 37 deletions
+56
View File
@@ -10,6 +10,9 @@ from django.core.files.base import ContentFile
from datetime import timedelta
import uuid
import os
import ipaddress
import socket
from urllib.parse import urlparse
import aiohttp
from asgiref.sync import sync_to_async
from drf_yasg.utils import swagger_auto_schema
@@ -25,6 +28,52 @@ from utils.response_codes import ResponseCode, create_standardized_response, cre
from user.models import FUser
# 安全修复:SSRF 内网黑名单。收藏表情时若用户消息里的图片地址指向内网/回环/链路本地,
# 必须直接拒绝以防 SSRF。DNS 解析也参与判断以防 DNS rebinding。
SSRF_BLOCKED_NETWORKS = [
ipaddress.ip_network('0.0.0.0/8'),
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('100.64.0.0/10'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.0.0.0/24'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('198.18.0.0/15'),
ipaddress.ip_network('224.0.0.0/4'),
ipaddress.ip_network('240.0.0.0/4'),
ipaddress.ip_network('::1/128'),
]
def _ssrf_is_safe(url: str) -> bool:
"""校验外链 URL 是否指向公网域名。"""
try:
parsed = urlparse(url)
except Exception:
return False
if parsed.scheme not in ('http', 'https'):
return False
host = parsed.hostname
if not host:
return False
try:
infos = socket.getaddrinfo(host, None)
except Exception:
return False
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except Exception:
return False
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
return False
for net in SSRF_BLOCKED_NETWORKS:
if ip in net:
return False
return True
class FriendRequestListView(APIView):
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
@@ -700,6 +749,13 @@ class FavoriteStickerFromMessageView(APIView):
try:
if file_url.startswith('http'):
# 安全修复:先做 SSRF 内网黑名单校验,再走网络;host 必须解析到公网
if not _ssrf_is_safe(file_url):
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='消息图片链接不可访问',
status_code=status.HTTP_400_BAD_REQUEST,
)
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
async with session.get(file_url, headers={'User-Agent': 'Mozilla/5.0'}) as resp:
image_data = await resp.read()