Skip to content

Commit 031d300

Browse files
committed
fix: keep Docker as root, close s3_hostname SSRF, unbreak 404 tests
Revert gosu/non-root entrypoint so NAS and root-owned volumes keep working. Reject @/userinfo in s3_hostname. Theme-less checkouts no longer crash the HTML 404 handler.
1 parent 57c75a7 commit 031d300

6 files changed

Lines changed: 36 additions & 39 deletions

File tree

‎Dockerfile‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,13 @@ COPY --from=frontend-builder /build/fronted-2023/dist ./themes/2023
5959

6060
# 安装系统安全更新 + Python 依赖
6161
# 依赖从带哈希的锁定文件安装(--require-hashes),保证构建可复现、防供应链篡改。
62-
# gosu 用于入口脚本的数据卷属主修正后降权;清理 apt 缓存,降低镜像噪音与扫描面
62+
# 清理 apt 缓存,降低镜像噪音与扫描面
6363
RUN apt-get update \
6464
&& apt-get upgrade -y --no-install-recommends \
65-
&& apt-get install -y --no-install-recommends gosu \
6665
&& rm -rf /var/lib/apt/lists/* \
6766
&& pip install --no-cache-dir --require-hashes -r requirements.lock.txt \
6867
&& pip cache purge || true
6968

70-
# 非 root 运行用户;数据卷属主由 docker-entrypoint.sh 按需修正(兼容存量 root 卷)
71-
RUN useradd --system --uid 10001 --home-dir /app app
72-
7369
# 环境变量配置
7470
ENV HOST="0.0.0.0" \
7571
PORT=12345 \
@@ -81,11 +77,8 @@ ENV HOST="0.0.0.0" \
8177

8278
EXPOSE 12345
8379

84-
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
85-
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
86-
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
87-
8880
# 生产环境启动命令
8981
# FORWARDED_ALLOW_IPS 默认为空:仅信任直连 IP,避免任意客户端伪造 X-Forwarded-*。
9082
# 若前面有反向代理,请显式设置为代理网段,例如 "10.0.0.0/8,172.16.0.0/12"。
83+
# 容器以 root 运行:兼容存量 root 属主的数据卷,以及 NAS/只读 bind mount。
9184
CMD ["sh", "-c", "access_log_arg=--no-access-log; if [ \"${APP_ENV:-development}\" != \"production\" ] || [ \"${ACCESS_LOG:-false}\" = \"true\" ]; then access_log_arg=--access-log; fi; exec uvicorn main:app --host \"$HOST\" --port \"$PORT\" --workers \"$WORKERS\" --log-level \"$LOG_LEVEL\" \"$access_log_arg\" --proxy-headers --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-}\""]

‎apps/base/pages.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,18 @@ async def not_found_handler(request, exc=None):
9696
(负路径测试抓到的存量 bug)。浏览器(Accept 含 text/html)仍拿到
9797
主题首页做 SPA 兜底;API 客户端拿到 JSON 404。
9898
"""
99-
if request is not None and request.method in {"GET", "HEAD"} and "text/html" in (
100-
request.headers.get("accept", "")
101-
):
102-
return await index(request, exc)
103-
return JSONResponse(
99+
json_404 = JSONResponse(
104100
status_code=404,
105101
content={"code": 404, "message": "Not Found", "detail": "资源不存在"},
106102
)
103+
if request is not None and request.method in {"GET", "HEAD"} and "text/html" in (
104+
request.headers.get("accept", "")
105+
):
106+
try:
107+
return await index(request, exc)
108+
except HTTPException:
109+
return json_404
110+
return json_404
107111

108112

109113
@router.get("/")

‎core/security.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,8 @@ def validate_outbound_hostname(value: Any) -> str:
155155
if os.environ.get("APP_ENV", "development") != "production":
156156
return hostname
157157

158-
if "://" in hostname or "/" in hostname:
159-
raise ValueError(f"s3_hostname 应为裸主机名,不含协议或路径:{hostname}")
158+
if "://" in hostname or "/" in hostname or "@" in hostname:
159+
raise ValueError(f"s3_hostname 应为裸主机名,不含协议、路径或 userinfo:{hostname}")
160160
# 取主机部分:[v6] 形态取括号内;host:port 取冒号前;裸 IPv6(多个冒号)整体
161161
if "[" in hostname:
162162
host = hostname.split("[", 1)[1].split("]", 1)[0]

‎docker-entrypoint.sh‎

Lines changed: 0 additions & 20 deletions
This file was deleted.

‎tests/test_negative_edge_paths.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,23 @@ async def test_api_accept_gets_json_404(self, initialized_client):
138138
assert "text/html" not in response.headers["content-type"]
139139

140140
async def test_browser_accept_gets_theme_page(self, initialized_client):
141-
response = await initialized_client.get("/no-such-path", headers={"Accept": "text/html"})
142-
assert response.status_code == 200
143-
assert "text/html" in response.headers["content-type"]
141+
from core.settings import BASE_DIR
142+
143+
theme_dir = BASE_DIR / "themes" / "2024"
144+
theme_dir.mkdir(parents=True, exist_ok=True)
145+
index = theme_dir / "index.html"
146+
created = not index.exists()
147+
if created:
148+
index.write_text("<!doctype html><title>{{title}}</title>", encoding="utf-8")
149+
try:
150+
response = await initialized_client.get(
151+
"/no-such-path", headers={"Accept": "text/html"}
152+
)
153+
assert response.status_code == 200
154+
assert "text/html" in response.headers["content-type"]
155+
finally:
156+
if created:
157+
index.unlink(missing_ok=True)
144158

145159
async def test_default_star_accept_gets_json_404(self, initialized_client):
146160
"""curl 默认 */* 不含 text/html——必须走 JSON 分支(防误伤脚本调用方)。"""

‎tests/test_outbound_endpoint_validation.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ def test_hostname_tier_rejects_url_forms(production_env):
9090
validate_outbound_hostname("http://127.0.0.1:9000")
9191

9292

93+
def test_hostname_tier_rejects_userinfo(production_env):
94+
"""存储层拼 https://{s3_hostname},@ 会被当成 userinfo,实际连 @ 后的地址。"""
95+
for hostname in ("evil.com@127.0.0.1", "attacker.example@127.0.0.1:8443"):
96+
with pytest.raises(ValueError):
97+
validate_outbound_hostname(hostname)
98+
9399
@pytest.mark.asyncio
94100
class TestChangedOnlyEnforcement:
95101
"""changed-only 集成回归:存量内网 endpoint 不得挡死无关设置保存。

0 commit comments

Comments
 (0)