GHSA-8vh3-g2qg-2h2c (CVSS 9.1, CWE-306): POST /webhooks/nextcloud had no
authentication when WEBHOOK_SECRET was unset (the default). The receiver
trusted the attacker-supplied user.uid and fed it to Qdrant, letting an
unauthenticated network caller delete or re-index any user's vector
embeddings.
Webhooks now require WEBHOOK_SECRET end-to-end:
- app.py: the /webhooks/nextcloud route is only mounted when WEBHOOK_SECRET
is set; otherwise it 404s and a startup warning notes vector sync falls
back to the polling scanner.
- webhook_receiver.py: removed the warn-and-accept fallback. No secret -> 503,
missing/invalid bearer -> 401; the payload is never processed unauthenticated.
- webhook_routes.py / api/webhooks.py: webhook_auth_pair() raises
WebhookSecretNotConfigured instead of returning authMethod="none"; both
registration entry points return a clear 503 so no dead unauthenticated
webhooks are created.
Also expose webhooks availability to the Astrolabe UI via GET /api/v1/status
("webhooks_enabled": bool), set WEBHOOK_SECRET on the docker-compose
semantic-search dev services, and update env.sample + ADR-010 / ADR-018 /
webhook-management-guide docs.
Vector sync still works without a secret via the polling scanner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
145 lines
4.5 KiB
Python
145 lines
4.5 KiB
Python
"""Unit tests for ``_get_webhook_uri`` priority order and the
|
|
``webhook_auth_pair`` registration helper.
|
|
|
|
Cloud deployments register the webhook URI returned by this function with
|
|
Nextcloud. ECS Fargate also exposes ``/.dockerenv``, so an explicit public
|
|
URL must win over the docker auto-detection branch. The URL fields are
|
|
read via dynaconf (``Settings``), so tests patch ``get_settings`` directly.
|
|
The docker-detection markers (``/.dockerenv``, ``DOCKER_CONTAINER``,
|
|
``NEXTCLOUD_MCP_SERVICE_NAME``, ``NEXTCLOUD_MCP_PORT``) remain on
|
|
``os.getenv`` and are exercised via env-var monkeypatching.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from nextcloud_mcp_server.auth import webhook_routes
|
|
from nextcloud_mcp_server.auth.webhook_routes import (
|
|
WebhookSecretNotConfigured,
|
|
_get_webhook_uri,
|
|
webhook_auth_pair,
|
|
)
|
|
from nextcloud_mcp_server.config import Settings
|
|
|
|
DOCKER_ENV_VARS = (
|
|
"NEXTCLOUD_MCP_SERVICE_NAME",
|
|
"NEXTCLOUD_MCP_PORT",
|
|
"DOCKER_CONTAINER",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_env(monkeypatch):
|
|
for name in DOCKER_ENV_VARS:
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
def _patch_settings(monkeypatch, **overrides) -> None:
|
|
"""Make ``get_settings()`` (as called inside webhook_routes) return a
|
|
Settings instance with the given URL/secret fields set; everything else
|
|
falls back to the dataclass defaults."""
|
|
monkeypatch.setattr(
|
|
webhook_routes,
|
|
"get_settings",
|
|
lambda: Settings(**overrides),
|
|
)
|
|
|
|
|
|
def _no_docker_markers(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"nextcloud_mcp_server.auth.webhook_routes.os.path.exists",
|
|
lambda _path: False,
|
|
)
|
|
|
|
|
|
def _docker_markers(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"nextcloud_mcp_server.auth.webhook_routes.os.path.exists",
|
|
lambda path: path == "/.dockerenv",
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_webhook_internal_url_wins_over_everything(monkeypatch):
|
|
_patch_settings(
|
|
monkeypatch,
|
|
webhook_internal_url="https://internal.example.com",
|
|
nextcloud_mcp_server_url="https://public.example.com",
|
|
)
|
|
_docker_markers(monkeypatch)
|
|
|
|
assert _get_webhook_uri() == "https://internal.example.com/webhooks/nextcloud"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_public_url_wins_over_docker_detection(monkeypatch):
|
|
"""The bug-fix case: ECS containers have /.dockerenv but a public URL is
|
|
set. Docker auto-detection must NOT clobber the explicit public URL."""
|
|
_patch_settings(
|
|
monkeypatch,
|
|
nextcloud_mcp_server_url="https://holy-bluegill.astrolabecloud.com",
|
|
)
|
|
_docker_markers(monkeypatch)
|
|
|
|
assert (
|
|
_get_webhook_uri()
|
|
== "https://holy-bluegill.astrolabecloud.com/webhooks/nextcloud"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_docker_detection_used_when_no_public_url(monkeypatch):
|
|
"""docker-compose dev: no public URL set, /.dockerenv exists → use the
|
|
docker-compose service name."""
|
|
_patch_settings(monkeypatch)
|
|
_docker_markers(monkeypatch)
|
|
|
|
assert _get_webhook_uri() == "http://mcp:8000/webhooks/nextcloud"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_docker_detection_honors_service_name_and_port_overrides(monkeypatch):
|
|
_patch_settings(monkeypatch)
|
|
monkeypatch.setenv("NEXTCLOUD_MCP_SERVICE_NAME", "mcp-login-flow")
|
|
monkeypatch.setenv("NEXTCLOUD_MCP_PORT", "8004")
|
|
_docker_markers(monkeypatch)
|
|
|
|
assert _get_webhook_uri() == "http://mcp-login-flow:8004/webhooks/nextcloud"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_docker_container_env_var_triggers_docker_branch(monkeypatch):
|
|
_patch_settings(monkeypatch)
|
|
monkeypatch.setenv("DOCKER_CONTAINER", "true")
|
|
_no_docker_markers(monkeypatch)
|
|
|
|
assert _get_webhook_uri() == "http://mcp:8000/webhooks/nextcloud"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_localhost_fallback_when_nothing_set(monkeypatch):
|
|
_patch_settings(monkeypatch)
|
|
_no_docker_markers(monkeypatch)
|
|
|
|
assert _get_webhook_uri() == "http://localhost:8000/webhooks/nextcloud"
|
|
|
|
|
|
# --- webhook_auth_pair() --------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_auth_pair_raises_when_secret_unset(monkeypatch):
|
|
"""Security (GHSA-8vh3-g2qg-2h2c): no secret => no webhook registration.
|
|
The helper raises instead of returning an ``authMethod="none"`` pair."""
|
|
_patch_settings(monkeypatch, webhook_secret=None)
|
|
with pytest.raises(WebhookSecretNotConfigured):
|
|
webhook_auth_pair()
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_auth_pair_emits_bearer_header_when_secret_set(monkeypatch):
|
|
_patch_settings(monkeypatch, webhook_secret="supersecret")
|
|
assert webhook_auth_pair() == (
|
|
"header",
|
|
{"Authorization": "Bearer supersecret"},
|
|
)
|