Skip to content

Commit 0ee2f04

Browse files
authored
Merge pull request #498 from vastsa/fix/security-hardening-gaps
fix: magic-byte file checks, admin login rate limit, non-root Docker
2 parents c11059b + eda0a31 commit 0ee2f04

12 files changed

Lines changed: 350 additions & 34 deletions

File tree

Dockerfile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@ RUN apt-get update \
6363
&& apt-get upgrade -y --no-install-recommends \
6464
&& rm -rf /var/lib/apt/lists/* \
6565
&& pip install --no-cache-dir -r requirements.txt \
66-
&& pip cache purge || true
66+
&& pip cache purge || true \
67+
&& groupadd --system --gid 1000 appuser \
68+
&& useradd --system --uid 1000 --gid appuser --create-home --home-dir /home/appuser --shell /usr/sbin/nologin appuser \
69+
&& mkdir -p /app/data \
70+
&& chown -R appuser:appuser /app
71+
72+
# 非 root 运行,降低容器逃逸后的主机影响面
73+
USER appuser
6774

6875
# 环境变量配置
6976
ENV HOST="0.0.0.0" \

apps/admin/services.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,8 @@ class ConfigService:
15181518
"enableChunk",
15191519
"errorCount",
15201520
"errorMinute",
1521+
"loginCount",
1522+
"loginMinute",
15211523
"max_save_seconds",
15221524
"onedrive_proxy",
15231525
"openUpload",

apps/admin/views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
)
3939
from core.settings import settings
4040
from core.utils import get_now, verify_password
41+
from apps.base.utils import ip_limit
4142

4243
admin_api = APIRouter(
4344
prefix="/admin", tags=["管理"], dependencies=[Depends(admin_required)]
@@ -53,8 +54,10 @@ def _pick_query_text(*values: Optional[str]) -> Optional[str]:
5354

5455

5556
@admin_api.post("/login")
56-
async def login(data: LoginData):
57+
async def login(data: LoginData, ip: str = Depends(ip_limit["login"])):
58+
# 登录失败计入 IP 频率限制,超过 loginCount/loginMinute 后暂时锁定
5759
if not verify_password(data.password, settings.admin_token):
60+
ip_limit["login"].add_ip(ip)
5861
raise HTTPException(status_code=401, detail="密码错误")
5962

6063
expires_in = get_admin_session_expire_seconds()

apps/base/file_validation.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""文件类型校验:扩展名 / MIME 白名单 + magic bytes 防伪造。"""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
from typing import Optional
8+
9+
from fastapi import HTTPException, UploadFile
10+
from fnmatch import fnmatchcase
11+
12+
from core.settings import settings
13+
14+
15+
@dataclass(frozen=True)
16+
class FileKind:
17+
name: str
18+
extensions: tuple[str, ...]
19+
mimes: tuple[str, ...]
20+
signatures: tuple[bytes, ...]
21+
22+
23+
FILE_KINDS: tuple[FileKind, ...] = (
24+
FileKind("png", (".png",), ("image/png",), (b"\x89PNG\r\n\x1a\n",)),
25+
FileKind("jpg", (".jpg", ".jpeg"), ("image/jpeg",), (b"\xff\xd8\xff",)),
26+
FileKind("gif", (".gif",), ("image/gif",), (b"GIF87a", b"GIF89a")),
27+
FileKind("webp", (".webp",), ("image/webp",), ()),
28+
FileKind("bmp", (".bmp",), ("image/bmp", "image/x-ms-bmp"), (b"BM",)),
29+
FileKind("pdf", (".pdf",), ("application/pdf",), (b"%PDF",)),
30+
FileKind(
31+
"zip",
32+
(".zip", ".docx", ".xlsx", ".pptx", ".apk", ".jar"),
33+
(
34+
"application/zip",
35+
"application/x-zip-compressed",
36+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
37+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
38+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
39+
"application/java-archive",
40+
"application/vnd.android.package-archive",
41+
),
42+
(b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"),
43+
),
44+
FileKind(
45+
"rar",
46+
(".rar",),
47+
("application/x-rar-compressed", "application/vnd.rar"),
48+
(b"Rar!\x1a\x07\x00", b"Rar!\x1a\x07\x01\x00"),
49+
),
50+
FileKind("7z", (".7z",), ("application/x-7z-compressed",), (b"7z\xbc\xaf'\x1c",)),
51+
FileKind("gz", (".gz", ".tgz"), ("application/gzip", "application/x-gzip"), (b"\x1f\x8b",)),
52+
FileKind(
53+
"mp3",
54+
(".mp3",),
55+
("audio/mpeg",),
56+
(b"ID3", b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"),
57+
),
58+
FileKind("mp4", (".mp4", ".m4a", ".mov"), ("video/mp4", "audio/mp4", "video/quicktime"), ()),
59+
FileKind(
60+
"exe",
61+
(".exe", ".dll", ".sys"),
62+
("application/x-msdownload", "application/x-dosexec"),
63+
(b"MZ",),
64+
),
65+
FileKind("elf", (".elf", ".so", ".o"), ("application/x-executable",), (b"\x7fELF",)),
66+
)
67+
68+
KNOWN_EXTENSIONS = {ext for kind in FILE_KINDS for ext in kind.extensions}
69+
70+
71+
def normalize_allowed_file_types() -> list[str]:
72+
raw_value = settings.allowed_file_types
73+
if isinstance(raw_value, str):
74+
values = [item.strip() for item in raw_value.split(",")]
75+
elif isinstance(raw_value, (list, tuple, set)):
76+
values = [str(item).strip() for item in raw_value]
77+
else:
78+
values = []
79+
normalized = [item.lower() for item in values if item]
80+
return normalized or ["*"]
81+
82+
83+
def _extension_of(file_name: str) -> str:
84+
return Path(file_name or "").suffix.lower()
85+
86+
87+
def _match_allow_rule(rule: str, file_name: str, content_type: str) -> bool:
88+
if rule in {"*", "*/*"}:
89+
return True
90+
if "/" in rule:
91+
return fnmatchcase(content_type, rule)
92+
extension = rule if rule.startswith(".") else f".{rule}"
93+
return file_name.endswith(extension)
94+
95+
96+
def is_type_allowed(file_name: str, content_type: Optional[str] = None) -> bool:
97+
allowed = normalize_allowed_file_types()
98+
if any(rule in {"*", "*/*"} for rule in allowed):
99+
return True
100+
normalized_name = (file_name or "").strip().lower()
101+
normalized_content_type = (content_type or "").strip().lower()
102+
return any(
103+
_match_allow_rule(rule, normalized_name, normalized_content_type)
104+
for rule in allowed
105+
)
106+
107+
108+
def detect_file_kind(header: bytes) -> Optional[FileKind]:
109+
if not header:
110+
return None
111+
112+
if len(header) >= 12 and header.startswith(b"RIFF") and header[8:12] == b"WEBP":
113+
return next(kind for kind in FILE_KINDS if kind.name == "webp")
114+
115+
if len(header) >= 12 and header[4:8] == b"ftyp":
116+
return next(kind for kind in FILE_KINDS if kind.name == "mp4")
117+
118+
candidates: list[tuple[int, FileKind]] = []
119+
for kind in FILE_KINDS:
120+
for signature in kind.signatures:
121+
if signature and header.startswith(signature):
122+
candidates.append((len(signature), kind))
123+
if not candidates:
124+
return None
125+
candidates.sort(key=lambda item: item[0], reverse=True)
126+
return candidates[0][1]
127+
128+
129+
def validate_file_type(file_name: str, content_type: Optional[str] = None) -> None:
130+
if not is_type_allowed(file_name, content_type):
131+
raise HTTPException(status_code=403, detail="不允许上传该类型文件")
132+
133+
134+
def _kind_matches_ext(kind: FileKind, extension: str) -> bool:
135+
return extension in kind.extensions
136+
137+
138+
def _kind_matches_mime(kind: FileKind, mime: str) -> bool:
139+
return mime in kind.mimes
140+
141+
142+
def validate_file_magic(
143+
file_name: str,
144+
content_type: Optional[str],
145+
header: Optional[bytes],
146+
) -> None:
147+
"""白名单通过后,用 magic bytes 防止扩展名 / MIME 伪造。"""
148+
validate_file_type(file_name, content_type)
149+
if not header:
150+
return
151+
152+
claimed_ext = _extension_of(file_name)
153+
claimed_mime = (content_type or "").strip().lower()
154+
detected = detect_file_kind(header)
155+
156+
if claimed_ext in KNOWN_EXTENSIONS:
157+
if detected is None or not _kind_matches_ext(detected, claimed_ext):
158+
raise HTTPException(
159+
status_code=403,
160+
detail="文件内容与扩展名不匹配,疑似伪造类型",
161+
)
162+
163+
if claimed_mime and any(claimed_mime in kind.mimes for kind in FILE_KINDS):
164+
if detected is None or not _kind_matches_mime(detected, claimed_mime):
165+
raise HTTPException(
166+
status_code=403,
167+
detail="文件内容与 Content-Type 不匹配,疑似伪造类型",
168+
)
169+
170+
allowed = normalize_allowed_file_types()
171+
if detected and not any(rule in {"*", "*/*"} for rule in allowed):
172+
allowed_by_detected = any(
173+
is_type_allowed(f"file{ext}", mime)
174+
for ext in detected.extensions
175+
for mime in (detected.mimes or (None,))
176+
)
177+
if not allowed_by_detected:
178+
raise HTTPException(status_code=403, detail="不允许上传该类型文件")
179+
180+
181+
async def read_upload_header(file: UploadFile, size: int = 64) -> bytes:
182+
await file.seek(0)
183+
header = await file.read(size)
184+
await file.seek(0)
185+
return header or b""
186+
187+
188+
async def validate_upload_file(file: UploadFile) -> None:
189+
header = await read_upload_header(file)
190+
validate_file_magic(file.filename or "", file.content_type, header)
191+
192+
193+
def validate_header_bytes(
194+
file_name: str, content_type: Optional[str], header: bytes
195+
) -> None:
196+
validate_file_magic(file_name, content_type, header)

apps/base/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,5 @@ async def calculate_file_hash(file: UploadFile, chunk_size=1024 * 1024) -> str:
136136
"error": IPRateLimit(count=settings.errorCount, minutes=settings.errorMinute),
137137
"metadata": IPRateLimit(count=settings.errorCount, minutes=settings.errorMinute),
138138
"upload": IPRateLimit(count=settings.uploadCount, minutes=settings.uploadMinute),
139+
"login": IPRateLimit(count=settings.loginCount, minutes=settings.loginMinute),
139140
}

apps/base/views.py

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import datetime
2-
from fnmatch import fnmatchcase
32
import hashlib
43
import os
54
import uuid
@@ -23,6 +22,7 @@
2322
CompleteUploadModel,
2423
PresignUploadInitRequest,
2524
)
25+
from apps.base.file_validation import validate_file_type, validate_upload_file, validate_header_bytes
2626
from apps.base.utils import (
2727
get_expire_info,
2828
get_file_path_name,
@@ -106,36 +106,6 @@ async def validate_file_size(file: UploadFile, max_size: int) -> int:
106106
return size
107107

108108

109-
def normalize_allowed_file_types() -> list[str]:
110-
raw_value = settings.allowed_file_types
111-
if isinstance(raw_value, str):
112-
values = raw_value.replace(";", ",").split(",")
113-
elif isinstance(raw_value, (list, tuple, set)):
114-
values = raw_value
115-
else:
116-
values = []
117-
118-
allowed = [str(item).strip().lower() for item in values if str(item).strip()]
119-
return allowed or ["*"]
120-
121-
122-
def validate_file_type(file_name: str, content_type: Optional[str] = None) -> None:
123-
allowed = normalize_allowed_file_types()
124-
if "*" in allowed or "*/*" in allowed:
125-
return
126-
127-
normalized_name = file_name.strip().lower()
128-
normalized_content_type = (content_type or "").strip().lower()
129-
130-
for rule in allowed:
131-
if "/" in rule and fnmatchcase(normalized_content_type, rule):
132-
return
133-
134-
extension = rule if rule.startswith(".") else f".{rule}"
135-
if normalized_name.endswith(extension):
136-
return
137-
138-
raise HTTPException(status_code=403, detail="不允许上传该类型文件")
139109

140110

141111
async def create_file_code(code, **kwargs):
@@ -188,7 +158,7 @@ async def share_file(
188158
ip: str = Depends(ip_limit["upload"]),
189159
):
190160
file_size = await validate_file_size(file, settings.uploadSize)
191-
validate_file_type(file.filename or "", file.content_type)
161+
await validate_upload_file(file)
192162
validate_expire_style(expire_style)
193163
path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(file)
194164
reservation_token = f"file:{uuid.uuid4().hex}"
@@ -549,6 +519,8 @@ async def upload_chunk(
549519

550520
# 读取分片数据并计算哈希
551521
chunk_data = await chunk.read()
522+
if chunk_index == 0:
523+
validate_header_bytes(chunk_info.file_name, None, chunk_data[:64])
552524
chunk_size = len(chunk_data)
553525

554526
# 校验分片大小不超过声明的 chunk_size
@@ -844,6 +816,7 @@ async def presign_upload_proxy(
844816
)
845817

846818
file_size = await validate_file_size(file, settings.uploadSize)
819+
await validate_upload_file(file)
847820
if abs(file_size - session.file_size) > 1024:
848821
raise HTTPException(400, "文件大小与声明不符")
849822

core/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"enableChunk",
2020
"errorCount",
2121
"errorMinute",
22+
"loginCount",
23+
"loginMinute",
2224
"expireStyle",
2325
"max_save_seconds",
2426
"name",
@@ -68,6 +70,8 @@ def _sync_ip_limits() -> None:
6870
ip_limit["metadata"].count = settings.errorCount
6971
ip_limit["upload"].minutes = settings.uploadMinute
7072
ip_limit["upload"].count = settings.uploadCount
73+
ip_limit["login"].minutes = settings.loginMinute
74+
ip_limit["login"].count = settings.loginCount
7175

7276

7377
async def refresh_settings() -> None:

core/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
"themesSelect": "themes/2024",
7777
"errorMinute": 1,
7878
"errorCount": 10,
79+
"loginCount": 5,
80+
"loginMinute": 15,
7981
"serverWorkers": 1,
8082
"serverHost": "0.0.0.0",
8183
"serverPort": 12345,

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ version: "3"
22
services:
33
file-code-box:
44
image: lanol/filecodebox:2.5.2 # x-release-please-version
5+
user: "1000:1000"
56
volumes:
67
- fcb-data:/app/data:rw
78
restart: unless-stopped

0 commit comments

Comments
 (0)