fix: patch storage quota bypass and harden auth security
All checks were successful
Test / test (push) Successful in 2m11s

- Fix WebDAV chunked PUT bypassing storage quota when remaining_quota <= 0
- Add QuotaLimitedWriter to enforce quota during streaming writes
- Clean up residual files on write failure in end_write()
- Add Magic Link replay attack prevention via TokenStore
- Reject startup when JWT SECRET_KEY is not configured
- Sanitize OAuth callback and Magic Link log output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 22:20:43 +08:00
parent 40b6a31c98
commit 7200df6d87
5 changed files with 84 additions and 15 deletions

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from loguru import logger as l
from sqlmodels import ResponseBase from sqlmodels import ResponseBase
import service.oauth import service.oauth
@@ -64,16 +65,17 @@ async def router_callback_github(
""" """
try: try:
access_token = await service.oauth.github.get_access_token(code) access_token = await service.oauth.github.get_access_token(code)
# [TODO] 把access_token写数据库里
if not access_token: if not access_token:
return PlainTextResponse("Failed to retrieve access token from GitHub.", status_code=400) return PlainTextResponse("GitHub 认证失败", status_code=400)
user_data = await service.oauth.github.get_user_info(access_token.access_token) user_data = await service.oauth.github.get_user_info(access_token.access_token)
# [TODO] 把user_data写数据库 # [TODO] 把 access_token 和 user_data 写数据库,生成 JWT重定向到前端
l.info(f"GitHub OAuth 回调成功: user={user_data.user_data.login}")
return PlainTextResponse(f"User information processed successfully, code: {code}, user_data: {user_data.json_dump()}", status_code=200)
return PlainTextResponse("认证成功,功能开发中", status_code=200)
except Exception as e: except Exception as e:
return PlainTextResponse(f"An error occurred: {str(e)}", status_code=500) l.error(f"GitHub OAuth 回调异常: {e}")
return PlainTextResponse("认证过程中发生错误,请重试", status_code=500)
@pay_router.post( @pay_router.post(
path='/alipay', path='/alipay',

View File

@@ -318,7 +318,7 @@ async def router_user_magic_link(
site_url = site_url_setting.value if site_url_setting else "http://localhost" site_url = site_url_setting.value if site_url_setting else "http://localhost"
# TODO: 发送邮件(包含 {site_url}/auth/magic-link?token={token} # TODO: 发送邮件(包含 {site_url}/auth/magic-link?token={token}
logger.info(f"Magic Link token 已生成: {token} (邮件发送待实现)") logger.info(f"Magic Link token 已 {request.email} 生成 (邮件发送待实现)")
@user_router.post( @user_router.post(

View File

@@ -260,6 +260,33 @@ def _check_storage_quota(user: User, additional_bytes: int) -> None:
raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足") raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足")
class QuotaLimitedWriter(io.RawIOBase):
"""带配额限制的写入流包装器"""
def __init__(self, stream: io.BufferedWriter, max_bytes: int) -> None:
self._stream = stream
self._max_bytes = max_bytes
self._bytes_written = 0
def writable(self) -> bool:
return True
def write(self, b: bytes | bytearray) -> int:
if self._bytes_written + len(b) > self._max_bytes:
raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足")
written = self._stream.write(b)
self._bytes_written += written
return written
def close(self) -> None:
self._stream.close()
super().close()
@property
def bytes_written(self) -> int:
return self._bytes_written
# ==================== Provider ==================== # ==================== Provider ====================
class DiskNextDAVProvider(DAVProvider): class DiskNextDAVProvider(DAVProvider):
@@ -441,7 +468,7 @@ class DiskNextFile(DAVNonCollection):
self._user_id = user_id self._user_id = user_id
self._account = account self._account = account
self._write_path: str | None = None self._write_path: str | None = None
self._write_stream: io.BufferedWriter | None = None self._write_stream: io.BufferedWriter | QuotaLimitedWriter | None = None
def get_content_length(self) -> int | None: def get_content_length(self) -> int | None:
return self._obj.size if self._obj.size else 0 return self._obj.size if self._obj.size else 0
@@ -486,20 +513,32 @@ class DiskNextFile(DAVNonCollection):
return open(full_path, "rb") # noqa: SIM115 return open(full_path, "rb") # noqa: SIM115
def begin_write(self, *, content_type: str | None = None) -> io.BufferedWriter: def begin_write(
self,
*,
content_type: str | None = None,
) -> io.BufferedWriter | QuotaLimitedWriter:
""" """
开始写入文件PUT 操作)。 开始写入文件PUT 操作)。
返回一个可写的文件流WsgiDAV 将向其中写入请求体数据。 返回一个可写的文件流WsgiDAV 将向其中写入请求体数据。
当用户有配额限制时,返回 QuotaLimitedWriter 在写入过程中实时检查配额。
""" """
_check_readonly(self.environ) _check_readonly(self.environ)
# 检查配额 # 检查配额
remaining_quota: int = 0
user = _run_async(_get_user(self._user_id)) user = _run_async(_get_user(self._user_id))
if user: if user:
content_length = self.environ.get("CONTENT_LENGTH") max_storage = user.group.max_storage
if content_length: if max_storage > 0:
_check_storage_quota(user, int(content_length)) remaining_quota = max_storage - user.storage
if remaining_quota <= 0:
raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足")
# Content-Length 预检(如果有的话)
content_length = self.environ.get("CONTENT_LENGTH")
if content_length and int(content_length) > remaining_quota:
raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足")
# 获取策略以确定存储路径 # 获取策略以确定存储路径
policy = _run_async(_get_policy(self._obj.policy_id)) policy = _run_async(_get_policy(self._obj.policy_id))
@@ -515,7 +554,14 @@ class DiskNextFile(DAVNonCollection):
) )
self._write_path = full_path self._write_path = full_path
self._write_stream = open(full_path, "wb") # noqa: SIM115 raw_stream = open(full_path, "wb") # noqa: SIM115
# 有配额限制时使用包装流,实时检查写入量
if remaining_quota > 0:
self._write_stream = QuotaLimitedWriter(raw_stream, remaining_quota)
else:
self._write_stream = raw_stream
return self._write_stream return self._write_stream
def end_write(self, *, with_errors: bool) -> None: def end_write(self, *, with_errors: bool) -> None:
@@ -524,7 +570,14 @@ class DiskNextFile(DAVNonCollection):
self._write_stream.close() self._write_stream.close()
self._write_stream = None self._write_stream = None
if with_errors or not self._write_path: if with_errors:
if self._write_path:
file_path = Path(self._write_path)
if file_path.exists():
file_path.unlink()
return
if not self._write_path:
return return
# 获取文件大小 # 获取文件大小

View File

@@ -3,12 +3,14 @@
支持多种认证方式邮箱密码、GitHub OAuth、QQ OAuth、Passkey、Magic Link、手机短信预留 支持多种认证方式邮箱密码、GitHub OAuth、QQ OAuth、Passkey、Magic Link、手机短信预留
""" """
import hashlib
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from loguru import logger as l from loguru import logger as l
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from service.redis.token_store import TokenStore
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
from sqlmodels.group import GroupClaims, GroupOptions from sqlmodels.group import GroupClaims, GroupOptions
from sqlmodels.object import Object, ObjectType from sqlmodels.object import Object, ObjectType
@@ -363,6 +365,12 @@ async def _login_magic_link(
except BadSignature: except BadSignature:
http_exceptions.raise_unauthorized("Magic Link 无效") http_exceptions.raise_unauthorized("Magic Link 无效")
# 防重放:使用 token 哈希作为标识符
token_hash = hashlib.sha256(request.identifier.encode()).hexdigest()
is_first_use = await TokenStore.mark_used(f"magic_link:{token_hash}", ttl=600)
if not is_first_use:
http_exceptions.raise_unauthorized("Magic Link 已被使用")
# 查找绑定了该邮箱的 AuthIdentityemail_password 或 magic_link # 查找绑定了该邮箱的 AuthIdentityemail_password 或 magic_link
identity: AuthIdentity | None = await AuthIdentity.get( identity: AuthIdentity | None = await AuthIdentity.get(
session, session,

View File

@@ -37,6 +37,12 @@ async def load_secret_key() -> None:
if setting: if setting:
SECRET_KEY = setting.value SECRET_KEY = setting.value
if not SECRET_KEY:
raise RuntimeError(
"JWT SECRET_KEY 未配置,拒绝启动。"
"请在 Setting 表中添加 type='auth', name='secret_key' 的记录。"
)
def build_token_payload( def build_token_payload(
data: dict, data: dict,