Skip to content

Commit 8d97989

Browse files
authored
Merge pull request #109 from MisonL/feature/newapi-token-validation
fix: validate NEWAPI token input across UI and API
2 parents 808d452 + e130051 commit 8d97989

7 files changed

Lines changed: 317 additions & 8 deletions

File tree

src/core/upload/newapi_upload.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,42 @@ def _normalize_base(api_url: str) -> str:
2323
return (api_url or "").strip().rstrip("/")
2424

2525

26+
def normalize_authorization_token(header_value: str, header_name: str = "Authorization Token") -> str:
27+
normalized_value = (header_value or "").strip()
28+
if not normalized_value:
29+
raise ValueError(f"{header_name} 不能为空")
30+
try:
31+
normalized_value.encode("ascii")
32+
except UnicodeEncodeError as exc:
33+
raise ValueError(f"{header_name} 包含非 ASCII 字符,请确认填写的是实际令牌而不是中文说明") from exc
34+
if any(ord(ch) < 32 or ord(ch) == 127 for ch in normalized_value):
35+
raise ValueError(f"{header_name} 包含非法控制字符")
36+
return normalized_value
37+
38+
39+
def _mask_header_value(header_value: str, keep: int = 4) -> str:
40+
"""
41+
Mask a sensitive header value for safe logging.
42+
43+
The strategy is:
44+
- If the value is empty, return an empty string.
45+
- If the length is <= keep, fully mask it (no characters revealed).
46+
- Otherwise, reveal only the last `keep` characters and mask the rest.
47+
"""
48+
if not header_value:
49+
return ""
50+
51+
length = len(header_value)
52+
if length <= keep:
53+
return "*" * length
54+
55+
masked_prefix = "*" * (length - keep)
56+
visible_suffix = header_value[-keep:]
57+
return masked_prefix + visible_suffix
2658
def _build_headers(api_key: str) -> dict:
59+
safe_api_key = normalize_authorization_token(api_key)
2760
return {
28-
"Authorization": f"Bearer {api_key}",
61+
"Authorization": f"Bearer {safe_api_key}",
2962
"New-Api-User": "1",
3063
"Content-Type": "application/json",
3164
}
@@ -68,7 +101,7 @@ def upload_to_newapi(
68101
"auto_ban": 1,
69102
"name": account.email or "",
70103
"type": resolved_channel_type,
71-
"key": json.dumps({"access_token": account.access_token or "", "account_id": account_name}, ensure_ascii=False),
104+
"key": json.dumps({"access_token": account.access_token or "", "account_id": account_name}, ensure_ascii=True),
72105
"base_url": resolved_channel_base_url,
73106
"models": resolved_channel_models,
74107
"multi_key_mode": "random",
@@ -79,10 +112,20 @@ def upload_to_newapi(
79112
}
80113

81114
try:
115+
payload = json.dumps({"mode": "single", "channel": channel}, ensure_ascii=True)
116+
headers = _build_headers(api_key)
117+
headers["Content-Type"] = "application/json; charset=utf-8"
118+
119+
logger.info("NEWAPI 上传 URL: %s", url)
120+
safe_headers = dict(headers)
121+
if "Authorization" in safe_headers:
122+
safe_headers["Authorization"] = "REDACTED"
123+
logger.debug("NEWAPI 请求头: %s", safe_headers)
124+
82125
resp = cffi_requests.post(
83126
url,
84-
headers=_build_headers(api_key),
85-
json={"mode": "single", "channel": channel},
127+
headers=headers,
128+
data=payload.encode("utf-8"),
86129
proxies=None,
87130
timeout=30,
88131
impersonate="chrome110",

src/web/routes/upload/newapi_services.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from ....database import crud
1010
from ....database.session import get_db
11+
from ....core.upload.newapi_upload import normalize_authorization_token
1112

1213
router = APIRouter()
1314

@@ -67,6 +68,13 @@ def _to_response(svc) -> NewapiServiceResponse:
6768
)
6869

6970

71+
def _validated_newapi_api_key(api_key: str) -> str:
72+
try:
73+
return normalize_authorization_token(api_key, header_name="Root Token / API Key")
74+
except ValueError as exc:
75+
raise HTTPException(status_code=400, detail=str(exc)) from exc
76+
77+
7078
@router.get("", response_model=List[NewapiServiceResponse])
7179
async def list_newapi_services(enabled: Optional[bool] = None):
7280
with get_db() as db:
@@ -81,7 +89,7 @@ async def create_newapi_service(request: NewapiServiceCreate):
8189
db,
8290
name=request.name,
8391
api_url=request.api_url,
84-
api_key=request.api_key,
92+
api_key=_validated_newapi_api_key(request.api_key),
8593
channel_type=request.channel_type,
8694
channel_base_url=request.channel_base_url,
8795
channel_models=request.channel_models,
@@ -113,7 +121,7 @@ async def update_newapi_service(service_id: int, request: NewapiServiceUpdate):
113121
if request.api_url is not None:
114122
update_data["api_url"] = request.api_url
115123
if request.api_key:
116-
update_data["api_key"] = request.api_key
124+
update_data["api_key"] = _validated_newapi_api_key(request.api_key)
117125
if request.enabled is not None:
118126
update_data["enabled"] = request.enabled
119127
if request.priority is not None:

static/js/settings.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,28 @@ function closeNewapiServiceModal() {
13671367
elements.newapiServiceEditModal.classList.remove('active');
13681368
}
13691369

1370+
function validateNewapiApiKeyInput(apiKey, { required = false } = {}) {
1371+
const normalizedApiKey = String(apiKey || '').trim();
1372+
if (!normalizedApiKey) {
1373+
if (required) {
1374+
return '新增服务时 Root Token / API Key 不能为空';
1375+
}
1376+
return '';
1377+
}
1378+
1379+
for (const char of normalizedApiKey) {
1380+
const code = char.charCodeAt(0);
1381+
if (code > 127) {
1382+
return 'Root Token / API Key 只能包含 ASCII 字符,请粘贴实际令牌,不要填写中文说明';
1383+
}
1384+
if (code < 32 || code === 127) {
1385+
return 'Root Token / API Key 包含非法控制字符';
1386+
}
1387+
}
1388+
1389+
return '';
1390+
}
1391+
13701392
async function editNewapiService(id) {
13711393
try {
13721394
const service = await api.get(`/newapi-services/${id}`);
@@ -1392,8 +1414,9 @@ async function handleSaveNewapiService(e) {
13921414
toast.error('名称和 API URL 不能为空');
13931415
return;
13941416
}
1395-
if (!id && !apiKey) {
1396-
toast.error('新增服务时 Root Token / API Key 不能为空');
1417+
const apiKeyValidationError = validateNewapiApiKeyInput(apiKey, { required: !id });
1418+
if (apiKeyValidationError) {
1419+
toast.error(apiKeyValidationError);
13971420
return;
13981421
}
13991422

templates/settings.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,7 @@ <h3 id="newapi-service-modal-title">添加 NEWAPI 服务</h3>
496496
<div class="form-group">
497497
<label for="newapi-service-key">Root Token / API Key *</label>
498498
<input type="password" id="newapi-service-key" placeholder="编辑时留空则保持原值" autocomplete="new-password">
499+
<p class="hint">仅支持 ASCII 字符,请直接粘贴系统访问令牌,不要填写中文说明。</p>
499500
</div>
500501
<div class="form-row">
501502
<div class="form-group">
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import asyncio
2+
from contextlib import contextmanager
3+
4+
import pytest
5+
from fastapi import HTTPException
6+
7+
import src.web.routes.upload.newapi_services as newapi_routes
8+
from src.database.session import DatabaseSessionManager
9+
from src.web.routes.upload.newapi_services import NewapiServiceCreate, NewapiServiceUpdate
10+
11+
12+
def _build_fake_get_db(manager):
13+
@contextmanager
14+
def fake_get_db():
15+
with manager.session_scope() as session:
16+
yield session
17+
18+
return fake_get_db
19+
20+
21+
def test_create_newapi_service_rejects_non_ascii_api_key(tmp_path, monkeypatch):
22+
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/newapi-create.db")
23+
manager.create_tables()
24+
manager.migrate_tables()
25+
monkeypatch.setattr(newapi_routes, "get_db", _build_fake_get_db(manager))
26+
27+
with pytest.raises(HTTPException) as exc_info:
28+
asyncio.run(
29+
newapi_routes.create_newapi_service(
30+
NewapiServiceCreate(
31+
name="bad-token",
32+
api_url="https://newapi.example.com",
33+
api_key="系统访问令牌 (System Access Token)",
34+
)
35+
)
36+
)
37+
38+
assert exc_info.value.status_code == 400
39+
assert exc_info.value.detail == "Authorization Token 包含非 ASCII 字符,请确认填写的是实际令牌而不是中文说明"
40+
41+
42+
def test_update_newapi_service_rejects_non_ascii_api_key(tmp_path, monkeypatch):
43+
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/newapi-update.db")
44+
manager.create_tables()
45+
manager.migrate_tables()
46+
monkeypatch.setattr(newapi_routes, "get_db", _build_fake_get_db(manager))
47+
48+
created = asyncio.run(
49+
newapi_routes.create_newapi_service(
50+
NewapiServiceCreate(
51+
name="good-token",
52+
api_url="https://newapi.example.com",
53+
api_key="token-123",
54+
)
55+
)
56+
)
57+
58+
with pytest.raises(HTTPException) as exc_info:
59+
asyncio.run(
60+
newapi_routes.update_newapi_service(
61+
created.id,
62+
NewapiServiceUpdate(api_key="系统访问令牌 (System Access Token)"),
63+
)
64+
)
65+
66+
assert exc_info.value.status_code == 400
67+
assert exc_info.value.detail == "Authorization Token 包含非 ASCII 字符,请确认填写的是实际令牌而不是中文说明"

tests/test_newapi_upload.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from types import SimpleNamespace
2+
3+
from src.core.upload import newapi_upload
4+
5+
6+
class FakeResponse:
7+
def __init__(self, status_code=200, payload=None, text=""):
8+
self.status_code = status_code
9+
self._payload = payload
10+
self.text = text
11+
12+
def json(self):
13+
if self._payload is None:
14+
raise ValueError("no json payload")
15+
return self._payload
16+
17+
18+
def test_build_headers_rejects_non_ascii_api_key():
19+
try:
20+
newapi_upload._build_headers("系统访问令牌 (System Access Token)")
21+
except ValueError as exc:
22+
assert str(exc) == "Authorization Token 包含非 ASCII 字符,请确认填写的是实际令牌而不是中文说明"
23+
else:
24+
raise AssertionError("expected ValueError")
25+
26+
27+
def test_upload_to_newapi_uses_ascii_authorization_header(monkeypatch):
28+
calls = []
29+
30+
def fake_post(url, **kwargs):
31+
calls.append({"url": url, "kwargs": kwargs})
32+
return FakeResponse(status_code=201)
33+
34+
monkeypatch.setattr(newapi_upload.cffi_requests, "post", fake_post)
35+
36+
success, message = newapi_upload.upload_to_newapi(
37+
account=SimpleNamespace(email="tester@example.com", access_token="access-token"),
38+
api_url="https://newapi.example.com/",
39+
api_key="token-123",
40+
)
41+
42+
assert success is True
43+
assert message == "上传成功"
44+
assert calls[0]["url"] == "https://newapi.example.com/api/channel/"
45+
assert calls[0]["kwargs"]["headers"]["Authorization"] == "Bearer token-123"
46+
assert calls[0]["kwargs"]["headers"]["Content-Type"] == "application/json; charset=utf-8"
47+
assert calls[0]["kwargs"]["data"].startswith(b"{")
48+
49+
50+
def test_upload_to_newapi_returns_clear_error_for_non_ascii_api_key():
51+
success, message = newapi_upload.upload_to_newapi(
52+
account=SimpleNamespace(email="tester@example.com", access_token="access-token"),
53+
api_url="https://newapi.example.com/",
54+
api_key="系统访问令牌 (System Access Token)",
55+
)
56+
57+
assert success is False
58+
assert message == "上传异常: Authorization Token 包含非 ASCII 字符,请确认填写的是实际令牌而不是中文说明"

0 commit comments

Comments
 (0)