b11360015d
确立 Redis 仅密码认证(:pass@/default),隐藏误导性用户名字段; connection-check 自动修正错误 NEXUS_REDIS_URL,并更新运维文档与验收脚本。
409 lines
14 KiB
Python
409 lines
14 KiB
Python
"""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_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")
|