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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user