Files
Nexus/tests/test_security_unit.py
T
Nexus Deploy 49f88a2cd8 fix(install): Docker .env 持久化与 entrypoint 覆盖 Compose 占位符
向导 init-db 后立即同步 nexus-state 卷;entrypoint 跳过不完整 env 并在启动前 source /app/.env,避免缺 DATABASE_URL 与无密码 REDIS_URL 导致容器崩溃。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 11:08:22 +08:00

431 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""P0/P2 security regression unit tests (no live server required)."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from starlette.requests import Request
from server.api.dependencies import check_dangerous_command
from server.application.services.script_jobs import master_callback_url
from server.infrastructure.redis.script_execution_store import _parse_server_ids_json
from server.infrastructure.telegram import sanitize_external_message
def test_dangerous_command_rm_rf_detected():
warnings = check_dangerous_command("rm -rf /")
assert warnings
def test_dangerous_command_safe_empty():
assert check_dangerous_command("echo hello") == []
def test_parse_server_ids_invalid_json():
assert _parse_server_ids_json("not-json") == []
def test_parse_server_ids_valid():
assert _parse_server_ids_json("[1, 2]") == [1, 2]
def test_master_callback_url_rejects_http_non_local(monkeypatch):
monkeypatch.setattr(
"server.application.services.script_jobs.settings.API_BASE_URL",
"http://api.example.com",
)
with pytest.raises(ValueError, match="HTTPS"):
master_callback_url()
def test_master_callback_url_allows_localhost_http(monkeypatch):
monkeypatch.setattr(
"server.application.services.script_jobs.settings.API_BASE_URL",
"http://localhost:8600",
)
assert master_callback_url().endswith("/api/agent/script-callback")
def test_sanitize_external_message_redacts_mysql_url():
raw = "connect failed: mysql+pymysql://nexus:SecretPass@db.internal:3306/nexus"
cleaned = sanitize_external_message(raw)
assert "SecretPass" not in cleaned
assert "mysql+pymysql://" not in cleaned
assert "<connection-url-redacted>" in cleaned
def test_sanitize_external_message_redacts_bearer_token():
raw = "auth failed Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"
cleaned = sanitize_external_message(raw)
assert "eyJhbGci" not in cleaned
assert "Bearer <redacted>" in cleaned
def test_sanitize_external_message_truncates_long_text():
assert len(sanitize_external_message("x" * 500)) <= 240
@pytest.mark.asyncio
async def test_jwt_middleware_blocks_missing_token(monkeypatch):
from server.api import auth_jwt
monkeypatch.setattr(auth_jwt, "_is_install_mode", lambda: False)
inner = AsyncMock()
middleware = auth_jwt.JwtAuthMiddleware(app=inner)
scope = {
"type": "http",
"method": "GET",
"path": "/api/servers/",
"headers": [],
"query_string": b"",
}
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
sent: list[dict] = []
async def send(message):
sent.append(message)
await middleware(scope, receive, send)
starts = [m for m in sent if m.get("type") == "http.response.start"]
assert starts and starts[0].get("status") == 401
inner.assert_not_called()
@pytest.mark.asyncio
async def test_jwt_middleware_skips_public_login(monkeypatch):
from server.api import auth_jwt
monkeypatch.setattr(auth_jwt, "_is_install_mode", lambda: False)
inner = AsyncMock()
middleware = auth_jwt.JwtAuthMiddleware(app=inner)
scope = {
"type": "http",
"method": "POST",
"path": "/api/auth/login",
"headers": [],
"query_string": b"",
}
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
pass
await middleware(scope, receive, send)
inner.assert_called_once()
def test_public_path_install_and_logout():
from server.api.auth_jwt import _is_public_path
assert _is_public_path("/api/install/status") is True
assert _is_public_path("/api/auth/logout") is True
def _cdn_offenders_in_text(path: Path, text: str) -> bool:
return "cdn.jsdelivr" in text or "unpkg.com" in text
def test_html_pages_do_not_load_external_cdn():
app_dir = Path(__file__).resolve().parent.parent / "web" / "app"
offenders = []
for html in app_dir.glob("*.html"):
if _cdn_offenders_in_text(html, html.read_text(encoding="utf-8")):
offenders.append(html.name)
assert offenders == [], f"CDN still referenced in: {offenders}"
def test_app_root_js_do_not_load_external_cdn():
app_dir = Path(__file__).resolve().parent.parent / "web" / "app"
offenders = []
for js in app_dir.glob("*.js"):
if _cdn_offenders_in_text(js, js.read_text(encoding="utf-8")):
offenders.append(js.name)
assert offenders == [], f"CDN still referenced in: {offenders}"
def test_monaco_vendor_loader_present():
loader = (
Path(__file__).resolve().parent.parent
/ "web"
/ "app"
/ "vendor"
/ "monaco"
/ "vs"
/ "loader.js"
)
assert loader.is_file(), "Run: python scripts/sync_frontend_vendor.py (needs network)"
def test_vendor_manifest_lists_core_assets():
manifest_path = Path(__file__).resolve().parent.parent / "web" / "app" / "vendor" / "manifest.json"
assert manifest_path.is_file()
import json
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
for name in ("tailwindcss-browser.js", "alpinejs.min.js"):
assert name in manifest["files"]
assert (manifest_path.parent / name).is_file()
@pytest.mark.asyncio
async def test_install_test_credentials_rejects_when_env_exists(monkeypatch, tmp_path):
from fastapi import HTTPException
from server.api import install as install_api
from server.api.install import InitDbRequest, test_credentials
env_file = tmp_path / ".env"
env_file.write_text("NEXUS_SECRET_KEY=test\n", encoding="utf-8")
monkeypatch.setattr(install_api, "ENV_FILE", env_file)
monkeypatch.setattr(install_api, "INSTALL_LOCK", tmp_path / ".install_locked")
req = InitDbRequest(db_pass="secret")
with pytest.raises(HTTPException) as exc:
await test_credentials(req)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_install_test_credentials_all_pass(monkeypatch, tmp_path):
from server.api import install as install_api
from server.api.install import InitDbRequest, test_credentials
monkeypatch.setattr(install_api, "ENV_FILE", tmp_path / ".env")
monkeypatch.setattr(install_api, "INSTALL_LOCK", tmp_path / ".install_locked")
async def _mysql_ok(_url):
return True, "✓ MySQL 连接正常"
monkeypatch.setattr(install_api, "_test_mysql_url", _mysql_ok)
monkeypatch.setattr(install_api, "_test_redis_url", lambda _url: (True, "✓ Redis 连接正常"))
req = InitDbRequest(db_pass="secret")
out = await test_credentials(req)
assert out["all_pass"] is True
assert len(out["checks"]) == 2
def test_build_redis_url_password_with_leading_colon():
from server.api.install import InitDbRequest, _build_redis_url
req = InitDbRequest(
db_pass="db",
redis_host="1Panel-redis-x",
redis_pass="redis_WP3NyN",
)
assert _build_redis_url(req) == "redis://:redis_WP3NyN@1Panel-redis-x:6379/0"
def test_build_redis_url_with_default_user():
from server.api.install import InitDbRequest, _build_redis_url
req = InitDbRequest(
db_pass="db",
redis_host="1Panel-redis-x",
redis_user="default",
redis_pass="redis_WP3NyN",
)
assert _build_redis_url(req) == "redis://default:redis_WP3NyN@1Panel-redis-x:6379/0"
def test_resolve_redis_url_falls_back_to_colon_pass(monkeypatch):
from server.api.install import InitDbRequest, _resolve_redis_url, _sanitize_redis_request
monkeypatch.setenv("NEXUS_DOCKER_WRITE_ENV", "1")
req = InitDbRequest(
db_pass="db",
redis_host="1Panel-redis-x",
redis_user="root",
redis_pass="secret",
)
calls: list[str] = []
def fake_test(url: str) -> tuple[bool, str]:
calls.append(url)
return url.startswith("redis://:secret@"), "ok" if url.startswith("redis://:secret@") else "fail"
monkeypatch.setattr("server.api.install._test_redis_url", fake_test)
ok, msg, url = _resolve_redis_url(_sanitize_redis_request(req))
assert ok is True
assert url == "redis://:secret@1Panel-redis-x:6379/0"
assert "仅密码" in msg
assert not any(u.startswith("redis://root:") for u in calls)
def test_redis_url_candidates_never_tries_root_in_docker_mode(monkeypatch):
from server.api.install import InitDbRequest, _redis_url_candidates, _sanitize_redis_request
monkeypatch.setenv("NEXUS_DOCKER_WRITE_ENV", "1")
req = InitDbRequest(
db_pass="db",
redis_host="1Panel-redis-x",
redis_user="root",
redis_pass="secret",
)
labels = [label for label, url in _redis_url_candidates(_sanitize_redis_request(req))]
urls = [url for _, url in _redis_url_candidates(_sanitize_redis_request(req))]
assert "root" not in " ".join(labels).lower()
assert not any("root:" in u for u in urls)
def test_configure_guardian_docker_mode_skips_supervisor(monkeypatch):
from server.api import install as install_api
from server.api.install import _configure_docker_guardian, _configure_guardian
monkeypatch.setenv("NEXUS_DOCKER_WRITE_ENV", "1")
monkeypatch.setenv("NEXUS_API_BASE_URL", "https://api.example.com")
monkeypatch.setattr(
install_api.Path,
"is_file",
lambda self: str(self) == "/.dockerenv",
)
docker_results = _configure_docker_guardian("/opt/nexus", "8600")
docker_text = "\n".join(docker_results)
assert "Supervisor" not in docker_text
assert "self_monitor" in docker_text
assert "OpenResty" in docker_text or "反代" in docker_text
bare_results = _configure_guardian("/opt/nexus", "8600")
assert bare_results == docker_results
def test_init_db_request_from_redis_url_legacy_and_colon_pass():
from server.api.install import _init_db_request_from_redis_url
legacy = _init_db_request_from_redis_url("redis://badpass@1Panel-redis-x:6379/0")
assert legacy is not None
assert legacy.redis_pass == "badpass"
assert legacy.redis_user == ""
colon = _init_db_request_from_redis_url("redis://:secret@1Panel-redis-x:6379/0")
assert colon is not None
assert colon.redis_pass == "secret"
assert colon.redis_user == ""
@pytest.mark.asyncio
async def test_connection_check_repairs_bad_redis_url(monkeypatch, tmp_path):
from server.api import install as install_api
from server.api.install import connection_check
env_file = tmp_path / ".env"
env_file.write_text(
'NEXUS_DATABASE_URL="mysql+aiomysql://n:pass@h:3306/n"\n'
'NEXUS_REDIS_URL="redis://root:secret@1Panel-redis-x:6379/0"\n',
encoding="utf-8",
)
monkeypatch.setattr(install_api, "ENV_FILE", env_file)
monkeypatch.setattr(install_api, "INSTALL_LOCK", tmp_path / ".install_locked")
async def _mysql_ok(_url):
return True, "✓ MySQL"
calls: list[str] = []
def fake_redis(url: str) -> tuple[bool, str]:
calls.append(url)
if url.startswith("redis://:secret@"):
return True, "✓ Redis 连接正常(仅密码 (:pass@)"
return False, "auth fail"
monkeypatch.setattr(install_api, "_test_mysql_url", _mysql_ok)
monkeypatch.setattr(install_api, "_test_redis_url", fake_redis)
monkeypatch.setenv("NEXUS_DOCKER_WRITE_ENV", "1")
out = await connection_check()
assert out["all_pass"] is True
assert out.get("redis_repaired") is True
assert "redis://:secret@" in env_file.read_text(encoding="utf-8")
def test_install_reject_repeat_init_when_env_exists(monkeypatch, tmp_path):
from fastapi import HTTPException
from server.api import install as install_api
env_file = tmp_path / ".env"
env_file.write_text("NEXUS_SECRET_KEY=test\n", encoding="utf-8")
monkeypatch.setattr(install_api, "ENV_FILE", env_file)
monkeypatch.setattr(install_api, "INSTALL_LOCK", tmp_path / ".install_locked")
with pytest.raises(HTTPException) as exc:
install_api._reject_if_env_exists()
assert exc.value.status_code == 403
def test_install_create_admin_allowed_when_env_not_locked(monkeypatch, tmp_path):
from server.api import install as install_api
env_file = tmp_path / ".env"
env_file.write_text("NEXUS_SECRET_KEY=test\n", encoding="utf-8")
monkeypatch.setattr(install_api, "ENV_FILE", env_file)
monkeypatch.setattr(install_api, "INSTALL_LOCK", tmp_path / ".install_locked")
install_api._reject_if_locked() # should not raise
def test_script_aggregate_empty_server_ids_pending():
from server.application.services.script_job_callback import _aggregate_execution_status
status = _aggregate_execution_status(
[],
{"1": {"phase": "pending"}, "2": {"phase": "done", "exit_code": 0}},
)
assert status == "running"
def test_script_aggregate_empty_server_ids_no_pending():
from server.application.services.script_job_callback import _aggregate_execution_status
assert _aggregate_execution_status([], {}) == "failed"
def test_install_reject_when_locked(monkeypatch, tmp_path):
from fastapi import HTTPException
from server.api import install as install_api
monkeypatch.setattr(install_api, "ENV_FILE", tmp_path / ".env")
lock_file = tmp_path / ".install_locked"
lock_file.write_text("locked\n", encoding="utf-8")
monkeypatch.setattr(install_api, "INSTALL_LOCK", lock_file)
with pytest.raises(HTTPException) as exc:
install_api._reject_if_locked()
assert exc.value.status_code == 403
def test_install_verify_token_rejects_missing(monkeypatch, tmp_path):
from fastapi import HTTPException
from server.api import install as install_api
state_file = tmp_path / ".install_state.json"
state_file.write_text('{"install_token":"abc123"}', encoding="utf-8")
monkeypatch.setattr(install_api, "STATE_FILE", state_file)
with pytest.raises(HTTPException) as exc:
install_api._verify_install_token("")
assert exc.value.status_code == 403
with pytest.raises(HTTPException) as exc:
install_api._verify_install_token("wrong")
assert exc.value.status_code == 403
install_api._verify_install_token("abc123")
def test_resolve_nexus_push_source_path_rejects_system_prefix(tmp_path, monkeypatch):
from server.utils.posix_paths import PosixPathError, resolve_nexus_push_source_path
allowed = tmp_path / "push_src"
allowed.mkdir()
assert resolve_nexus_push_source_path(str(allowed)) == str(allowed.resolve())
with pytest.raises(PosixPathError, match="系统路径"):
resolve_nexus_push_source_path("/etc")