fix(security): path traversal, timing-safe auth, stale credential bindings

Security:
- Remove /mnt/ from _ALLOWED_LOG_PREFIXES to prevent Unraid share exposure
- Add early .. detection for disk/logs and live/log_tail path validation
- Add /boot/ prefix restriction for flash_backup source_path
- Use hmac.compare_digest for timing-safe API key verification in server.py
- Gate include_traceback on DEBUG log level (no tracebacks in production)

Correctness:
- Re-raise CredentialsNotConfiguredError in health check instead of swallowing
- Fix ups_device query (remove non-existent nominalPower/currentPower fields)

Best practices (BP-01, BP-05, BP-06):
- Add # noqa: ASYNC109 to timeout params in _handle_live and unraid()
- Fix start_array* → start_array in docstring (not in ARRAY_DESTRUCTIVE)
- Remove from __future__ import annotations from snapshot.py
- Replace import-time UNRAID_API_KEY/URL bindings with _settings.ATTR pattern
  in manager.py, snapshot.py, utils.py, diagnostics.py — fixes stale binding
  after apply_runtime_config() post-elicitation (BP-05)

CI/CD:
- Add .github/workflows/ci.yml (5-job pipeline: lint, typecheck, test, version-sync, audit)
- Add fail_under = 80 to [tool.coverage.report]
- Add version sync check to scripts/validate-marketplace.sh

Documentation:
- Sync plugin.json version 1.1.1 → 1.1.2 with pyproject.toml
- Update CLAUDE.md: 3 tools, system domain count 18, scripts comment fix
- Update README.md: 3 tools, security notes
- Update docs/AUTHENTICATION.md: H1 title fix
- Add UNRAID_CREDENTIALS_DIR to .env.example

Bump: 1.1.1 → 1.1.2

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jacob Magar
2026-03-23 11:37:05 -04:00
parent d59f8c22a8
commit 2b777be927
17 changed files with 233 additions and 83 deletions

View File

@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
from unraid_mcp.server import ApiKeyVerifier, _build_auth
import unraid_mcp.server as srv
# ---------------------------------------------------------------------------
@@ -16,7 +16,7 @@ from unraid_mcp.server import ApiKeyVerifier, _build_auth
@pytest.mark.asyncio
async def test_api_key_verifier_accepts_correct_key():
"""Returns AccessToken when the presented token matches the configured key."""
verifier = ApiKeyVerifier("secret-key-abc123")
verifier = srv.ApiKeyVerifier("secret-key-abc123")
result = await verifier.verify_token("secret-key-abc123")
assert result is not None
@@ -27,7 +27,7 @@ async def test_api_key_verifier_accepts_correct_key():
@pytest.mark.asyncio
async def test_api_key_verifier_rejects_wrong_key():
"""Returns None when the token does not match."""
verifier = ApiKeyVerifier("secret-key-abc123")
verifier = srv.ApiKeyVerifier("secret-key-abc123")
result = await verifier.verify_token("wrong-key")
assert result is None
@@ -36,7 +36,7 @@ async def test_api_key_verifier_rejects_wrong_key():
@pytest.mark.asyncio
async def test_api_key_verifier_rejects_empty_token():
"""Returns None for an empty string token."""
verifier = ApiKeyVerifier("secret-key-abc123")
verifier = srv.ApiKeyVerifier("secret-key-abc123")
result = await verifier.verify_token("")
assert result is None
@@ -50,7 +50,7 @@ async def test_api_key_verifier_empty_key_rejects_empty_token():
should not be instantiated in that case. But if it is, it must not
grant access via an empty bearer token.
"""
verifier = ApiKeyVerifier("")
verifier = srv.ApiKeyVerifier("")
result = await verifier.verify_token("")
assert result is None
@@ -72,7 +72,7 @@ def test_build_auth_returns_none_when_nothing_configured(monkeypatch):
importlib.reload(s)
result = _build_auth()
result = srv._build_auth()
assert result is None
@@ -87,8 +87,8 @@ def test_build_auth_returns_api_key_verifier_when_only_api_key_set(monkeypatch):
importlib.reload(s)
result = _build_auth()
assert isinstance(result, ApiKeyVerifier)
result = srv._build_auth()
assert isinstance(result, srv.ApiKeyVerifier)
def test_build_auth_returns_google_provider_when_only_oauth_set(monkeypatch):
@@ -105,7 +105,7 @@ def test_build_auth_returns_google_provider_when_only_oauth_set(monkeypatch):
mock_provider = MagicMock()
with patch("unraid_mcp.server.GoogleProvider", return_value=mock_provider):
result = _build_auth()
result = srv._build_auth()
assert result is mock_provider
@@ -126,14 +126,14 @@ def test_build_auth_returns_multi_auth_when_both_configured(monkeypatch):
mock_provider = MagicMock()
with patch("unraid_mcp.server.GoogleProvider", return_value=mock_provider):
result = _build_auth()
result = srv._build_auth()
assert isinstance(result, MultiAuth)
# Server is the Google provider
assert result.server is mock_provider
# One additional verifier — the ApiKeyVerifier
assert len(result.verifiers) == 1
assert isinstance(result.verifiers[0], ApiKeyVerifier)
assert isinstance(result.verifiers[0], srv.ApiKeyVerifier)
def test_build_auth_multi_auth_api_key_verifier_uses_correct_key(monkeypatch):
@@ -149,7 +149,7 @@ def test_build_auth_multi_auth_api_key_verifier_uses_correct_key(monkeypatch):
importlib.reload(s)
with patch("unraid_mcp.server.GoogleProvider", return_value=MagicMock()):
result = _build_auth()
result = srv._build_auth()
verifier = result.verifiers[0]
assert verifier._api_key == "super-secret-token"

View File

@@ -8,9 +8,10 @@ from unraid_mcp.server import _build_google_auth
def test_build_google_auth_returns_none_when_unconfigured(monkeypatch):
"""Returns None when Google OAuth env vars are absent."""
monkeypatch.delenv("GOOGLE_CLIENT_ID", raising=False)
monkeypatch.delenv("GOOGLE_CLIENT_SECRET", raising=False)
monkeypatch.delenv("UNRAID_MCP_BASE_URL", raising=False)
# Use explicit empty values so dotenv reload cannot re-inject from ~/.unraid-mcp/.env.
monkeypatch.setenv("GOOGLE_CLIENT_ID", "")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "")
import unraid_mcp.config.settings as s
@@ -42,6 +43,8 @@ def test_build_google_auth_returns_provider_when_configured(monkeypatch):
client_id="test-id.apps.googleusercontent.com",
client_secret="GOCSPX-test-secret",
base_url="http://10.1.0.2:6970",
extra_authorize_params={"access_type": "online", "prompt": "consent"},
require_authorization_consent=False,
jwt_signing_key="x" * 32,
)
@@ -95,7 +98,7 @@ def test_mcp_instance_has_no_auth_by_default():
import os
for var in ("GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "UNRAID_MCP_BASE_URL"):
os.environ.pop(var, None)
os.environ[var] = ""
import importlib

View File

@@ -64,12 +64,12 @@ class TestStorageValidation:
async def test_logs_rejects_path_traversal(self, _mock_graphql: AsyncMock) -> None:
tool_fn = _make_tool()
# Traversal that escapes /var/log/ to reach /etc/shadow
with pytest.raises(ToolError, match="log_path must start with"):
# Traversal that escapes /var/log/ — detected by early .. check
with pytest.raises(ToolError, match="log_path"):
await tool_fn(action="disk", subaction="logs", log_path="/var/log/../../etc/shadow")
# Traversal that escapes /mnt/ to reach /etc/passwd
with pytest.raises(ToolError, match="log_path must start with"):
await tool_fn(action="disk", subaction="logs", log_path="/mnt/../etc/passwd")
# Traversal via .. — detected by early .. check
with pytest.raises(ToolError, match="log_path"):
await tool_fn(action="disk", subaction="logs", log_path="/var/log/../etc/passwd")
async def test_logs_allows_valid_paths(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"logFile": {"path": "/var/log/syslog", "content": "ok"}}