mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-06 03:47:38 +08:00
fix(security): protect cookiecloud update uploads (#6053)
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import gzip
|
||||
import hmac
|
||||
import json
|
||||
from typing import Annotated, Callable, Any, Dict, Optional
|
||||
|
||||
import aiofiles
|
||||
from anyio import Path as AsyncPath
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request, Response
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Request, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
@@ -44,6 +45,24 @@ async def verify_server_enabled():
|
||||
return True
|
||||
|
||||
|
||||
async def verify_update_auth(
|
||||
x_cookiecloud_auth: Annotated[
|
||||
Optional[str], Header(alias="X-CookieCloud-Auth")
|
||||
] = None,
|
||||
):
|
||||
"""
|
||||
校验CookieCloud上传接口的可选共享认证头。
|
||||
"""
|
||||
expected_header = (settings.COOKIECLOUD_AUTH_HEADER or "").strip()
|
||||
if not expected_header:
|
||||
return True
|
||||
|
||||
provided_header = (x_cookiecloud_auth or "").strip()
|
||||
if not hmac.compare_digest(provided_header, expected_header):
|
||||
raise HTTPException(status_code=403, detail="CookieCloud认证失败")
|
||||
return True
|
||||
|
||||
|
||||
cookie_router = APIRouter(
|
||||
route_class=GzipRoute,
|
||||
tags=["servcookie"],
|
||||
@@ -61,7 +80,7 @@ async def post_root():
|
||||
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
||||
|
||||
|
||||
@cookie_router.post("/update")
|
||||
@cookie_router.post("/update", dependencies=[Depends(verify_update_auth)])
|
||||
async def update_cookie(req: schemas.CookieData):
|
||||
"""
|
||||
上传Cookie数据
|
||||
|
||||
@@ -377,6 +377,8 @@ class ConfigModel(BaseModel):
|
||||
COOKIECLOUD_KEY: Optional[str] = None
|
||||
# CookieCloud端对端加密密码
|
||||
COOKIECLOUD_PASSWORD: Optional[str] = None
|
||||
# CookieCloud本地上传接口的X-CookieCloud-Auth期望值,留空表示不校验
|
||||
COOKIECLOUD_AUTH_HEADER: Optional[str] = None
|
||||
# CookieCloud同步间隔(分钟)
|
||||
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
||||
# CookieCloud同步黑名单,多个域名,分割
|
||||
|
||||
117
tests/test_cookiecloud_routes.py
Normal file
117
tests/test_cookiecloud_routes.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api import servcookie
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def cookiecloud_app(tmp_path, monkeypatch):
|
||||
settings = SimpleNamespace(
|
||||
COOKIE_PATH=tmp_path,
|
||||
COOKIECLOUD_ENABLE_LOCAL=True,
|
||||
COOKIECLOUD_AUTH_HEADER=None,
|
||||
)
|
||||
monkeypatch.setattr(servcookie, "settings", settings)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(servcookie.cookie_router, prefix="/cookiecloud")
|
||||
return app
|
||||
|
||||
|
||||
def make_client(app):
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
async def test_update_rejects_when_local_cookiecloud_disabled(cookiecloud_app):
|
||||
servcookie.settings.COOKIECLOUD_ENABLE_LOCAL = False
|
||||
servcookie.settings.COOKIECLOUD_AUTH_HEADER = "secret"
|
||||
|
||||
async with make_client(cookiecloud_app) as client:
|
||||
response = await client.post(
|
||||
"/cookiecloud/update",
|
||||
json={"uuid": "abcde", "encrypted": "payload"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "本地CookieCloud服务器未启用"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth_header", [None, "", " "])
|
||||
async def test_update_allows_legacy_clients_when_auth_header_unconfigured(
|
||||
cookiecloud_app, auth_header
|
||||
):
|
||||
servcookie.settings.COOKIECLOUD_AUTH_HEADER = auth_header
|
||||
|
||||
async with make_client(cookiecloud_app) as client:
|
||||
response = await client.post(
|
||||
"/cookiecloud/update",
|
||||
json={"uuid": "abcde", "encrypted": "payload"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"action": "done"}
|
||||
assert json.loads((servcookie.settings.COOKIE_PATH / "abcde.json").read_text()) == {
|
||||
"encrypted": "payload"
|
||||
}
|
||||
|
||||
|
||||
async def test_update_allows_matching_auth_header(cookiecloud_app):
|
||||
servcookie.settings.COOKIECLOUD_AUTH_HEADER = " secret-token "
|
||||
|
||||
async with make_client(cookiecloud_app) as client:
|
||||
response = await client.post(
|
||||
"/cookiecloud/update",
|
||||
json={"uuid": "abcde", "encrypted": "payload"},
|
||||
headers={"X-CookieCloud-Auth": "secret-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"action": "done"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("headers", [{}, {"X-CookieCloud-Auth": "wrong"}])
|
||||
async def test_update_rejects_missing_or_wrong_auth_header(cookiecloud_app, headers):
|
||||
servcookie.settings.COOKIECLOUD_AUTH_HEADER = "secret-token"
|
||||
|
||||
async with make_client(cookiecloud_app) as client:
|
||||
response = await client.post(
|
||||
"/cookiecloud/update",
|
||||
json={"uuid": "abcde", "encrypted": "payload"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "CookieCloud认证失败"
|
||||
|
||||
|
||||
async def test_get_routes_do_not_require_auth_header(cookiecloud_app, monkeypatch):
|
||||
servcookie.settings.COOKIECLOUD_AUTH_HEADER = "secret-token"
|
||||
|
||||
async def load_encrypt_data(uuid):
|
||||
assert uuid == "abcde"
|
||||
return {"encrypted": "payload"}
|
||||
|
||||
monkeypatch.setattr(servcookie, "load_encrypt_data", load_encrypt_data)
|
||||
|
||||
async with make_client(cookiecloud_app) as client:
|
||||
get_response = await client.get("/cookiecloud/get/abcde")
|
||||
post_response = await client.post("/cookiecloud/get/abcde")
|
||||
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json() == {"encrypted": "payload"}
|
||||
assert post_response.status_code == 200
|
||||
assert post_response.json() == {"encrypted": "payload"}
|
||||
Reference in New Issue
Block a user