fix(security): require WEBHOOK_SECRET for the Nextcloud webhook receiver

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>
This commit is contained in:
Chris Coutinho
2026-06-14 18:18:34 +02:00
co-authored by Claude Opus 4.8
parent a818b49b3f
commit 4fc2b10945
16 changed files with 337 additions and 102 deletions
@@ -36,6 +36,7 @@ def create_mock_settings(
oidc_discovery_url: str | None = None,
oidc_issuer: str | None = None,
vector_sync_enabled: bool = False,
webhook_secret: str | None = None,
nextcloud_url: str = "http://localhost",
mcp_client_id: str | None = None,
mcp_client_secret: str | None = None,
@@ -47,6 +48,9 @@ def create_mock_settings(
settings.oidc_discovery_url = oidc_discovery_url
settings.oidc_issuer = oidc_issuer
settings.vector_sync_enabled = vector_sync_enabled
# Explicit so bool(settings.webhook_secret) is deterministic (a bare
# MagicMock attribute is truthy, which would always report webhooks on).
settings.webhook_secret = webhook_secret
settings.nextcloud_url = nextcloud_url
settings.mcp_client_id = mcp_client_id
settings.mcp_client_secret = mcp_client_secret
@@ -342,3 +346,48 @@ class TestStatusEndpointBasicResponse:
data = response.json()
assert data["vector_sync_enabled"] is True
def test_status_reports_webhooks_enabled_when_secret_set(self):
"""webhooks_enabled is True when WEBHOOK_SECRET is configured."""
mock_settings = create_mock_settings(webhook_secret="supersecret")
with (
patch(
"nextcloud_mcp_server.api.management.get_settings",
return_value=mock_settings,
),
patch(
"nextcloud_mcp_server.api.management.detect_auth_mode",
return_value=AuthMode.SINGLE_USER_BASIC,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
assert response.json()["webhooks_enabled"] is True
def test_status_reports_webhooks_disabled_when_secret_unset(self):
"""webhooks_enabled is False when WEBHOOK_SECRET is unset (default).
Security (GHSA-8vh3-g2qg-2h2c): the receiver route is not mounted
without a secret, so the Astrolabe UI can surface webhooks as off."""
mock_settings = create_mock_settings(webhook_secret=None)
with (
patch(
"nextcloud_mcp_server.api.management.get_settings",
return_value=mock_settings,
),
patch(
"nextcloud_mcp_server.api.management.detect_auth_mode",
return_value=AuthMode.SINGLE_USER_BASIC,
),
):
app = create_test_app()
client = TestClient(app)
response = client.get("/api/v1/status")
assert response.status_code == 200
assert response.json()["webhooks_enabled"] is False
+53 -23
View File
@@ -16,6 +16,13 @@ from nextcloud_mcp_server.vector.webhook_receiver import handle_nextcloud_webhoo
pytestmark = pytest.mark.unit
# WEBHOOK_SECRET is required (GHSA-8vh3-g2qg-2h2c): the receiver rejects any
# request without a matching bearer. The functional tests below exercise
# parsing/queueing, so they run with a secret configured (via the autouse
# fixture) and send the matching header (via ``_client``).
_TEST_SECRET = "testsecret"
_AUTH = {"Authorization": f"Bearer {_TEST_SECRET}"}
@pytest.fixture(autouse=True)
def _reset_warned_flag():
@@ -26,6 +33,18 @@ def _reset_warned_flag():
webhook_receiver._warned_about_missing_secret = False
@pytest.fixture(autouse=True)
def _default_secret(monkeypatch):
"""Default every test to a configured WEBHOOK_SECRET. Auth-specific tests
override this by calling ``_patch_secret`` in the test body (last patch
wins)."""
monkeypatch.setattr(
webhook_receiver,
"get_settings",
lambda: Settings(webhook_secret=_TEST_SECRET),
)
def _patch_secret(monkeypatch, secret: str | None) -> None:
"""Make ``get_settings()`` (as called inside the receiver) return a
Settings instance with the given ``webhook_secret``."""
@@ -36,6 +55,13 @@ def _patch_secret(monkeypatch, secret: str | None) -> None:
)
def _client(app) -> TestClient:
"""TestClient that sends the matching bearer by default. Per-request
``headers=`` still override it (httpx request headers win over client
headers), so auth tests can send a wrong/absent token."""
return TestClient(app, headers=_AUTH)
def _make_app(send_stream=None) -> Starlette:
app = Starlette(
routes=[
@@ -138,7 +164,7 @@ def test_index_event_queues_task_and_returns_200():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 200
@@ -157,7 +183,7 @@ def test_delete_event_queues_delete_task():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_DELETED)
assert response.status_code == 200
@@ -182,7 +208,7 @@ def test_unsupported_event_is_ignored():
},
}
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=payload)
assert response.status_code == 200
@@ -196,7 +222,7 @@ def test_deck_card_created_queues_index_task():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_DECK_CARD_CREATED)
assert response.status_code == 200
@@ -216,7 +242,7 @@ def test_deck_card_deleted_queues_delete_task():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_DECK_CARD_DELETED)
assert response.status_code == 200
@@ -235,7 +261,7 @@ def test_deck_board_updated_is_ignored():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_DECK_BOARD_UPDATED)
assert response.status_code == 200
@@ -251,7 +277,7 @@ def test_deck_card_missing_id_is_ignored():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post(
"/webhooks/nextcloud", json=_DECK_CARD_CREATED_MISSING_ID
)
@@ -269,7 +295,7 @@ def test_note_missing_node_id_is_ignored():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED_MISSING_ID)
assert response.status_code == 200
@@ -282,7 +308,7 @@ def test_note_missing_node_id_is_ignored():
def test_invalid_json_returns_400():
app = _make_app(send_stream=None)
with TestClient(app) as client:
with _client(app) as client:
response = client.post(
"/webhooks/nextcloud",
content=b"not json",
@@ -298,7 +324,7 @@ def test_returns_503_when_send_stream_not_wired():
event."""
app = _make_app(send_stream=None)
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 503
@@ -310,7 +336,7 @@ def test_returns_500_when_stream_is_closed():
receive_stream.close() # close receiver → send raises BrokenResourceError
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 500
@@ -335,7 +361,7 @@ def test_returns_503_when_queue_is_full(monkeypatch):
send_stream.send_nowait("sentinel") # type: ignore[arg-type]
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 503
@@ -352,7 +378,7 @@ def test_secret_set_valid_bearer_header_queues_task(monkeypatch):
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post(
"/webhooks/nextcloud",
json=_NOTE_CREATED,
@@ -369,6 +395,7 @@ def test_secret_set_missing_authorization_returns_401(monkeypatch):
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
# Bare client (no default auth header) so the request truly omits it.
with TestClient(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
@@ -383,7 +410,7 @@ def test_secret_set_wrong_secret_returns_401(monkeypatch):
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post(
"/webhooks/nextcloud",
json=_NOTE_CREATED,
@@ -401,7 +428,7 @@ def test_secret_set_wrong_scheme_returns_401(monkeypatch):
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post(
"/webhooks/nextcloud",
json=_NOTE_CREATED,
@@ -411,19 +438,22 @@ def test_secret_set_wrong_scheme_returns_401(monkeypatch):
assert response.status_code == 401
def test_secret_unset_accepts_unauthenticated(monkeypatch):
"""Backward compat: deployments that haven't yet set WEBHOOK_SECRET keep
working — the receiver accepts unauthenticated POSTs and logs a one-time
warning."""
def test_secret_unset_rejects_with_503(monkeypatch):
"""Security (GHSA-8vh3-g2qg-2h2c): when WEBHOOK_SECRET is unset the receiver
refuses to process the (attacker-controllable) payload. ``app.py`` does not
even mount the route in this case; this exercises the handler's
defense-in-depth branch and proves no task is queued."""
_patch_secret(monkeypatch, None)
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 200
assert receive_stream.receive_nowait().doc_id == "437"
assert response.status_code == 503
assert response.json()["status"] == "unavailable"
with pytest.raises(anyio.WouldBlock):
receive_stream.receive_nowait()
def test_compare_digest_is_called_with_bytes(monkeypatch, mocker):
@@ -436,7 +466,7 @@ def test_compare_digest_is_called_with_bytes(monkeypatch, mocker):
send_stream, _receive = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
with _client(app) as client:
response = client.post(
"/webhooks/nextcloud",
json=_NOTE_CREATED,
+14 -9
View File
@@ -9,7 +9,10 @@ full Starlette app.
import pytest
from nextcloud_mcp_server.auth import webhook_routes
from nextcloud_mcp_server.auth.webhook_routes import _register_preset_webhooks
from nextcloud_mcp_server.auth.webhook_routes import (
WebhookSecretNotConfigured,
_register_preset_webhooks,
)
from nextcloud_mcp_server.client.webhooks import WebhooksClient
from nextcloud_mcp_server.config import Settings
from nextcloud_mcp_server.server.webhook_presets import get_preset
@@ -58,23 +61,25 @@ async def test_register_threads_bearer_auth_when_secret_set(monkeypatch, mocker)
assert kwargs["event_filter"] == event_config["filter"]
async def test_register_uses_none_auth_when_secret_unset(monkeypatch, mocker):
async def test_register_refuses_when_secret_unset(monkeypatch, mocker):
"""Security (GHSA-8vh3-g2qg-2h2c): webhooks require WEBHOOK_SECRET.
Without it, registration raises instead of creating a dead, unauthenticated
(``authMethod="none"``) delivery target pointing at a disabled receiver."""
_patch_secret(monkeypatch, None)
preset = get_preset("notes_sync")
assert preset is not None
client = _make_webhooks_client(mocker, ids=[1, 2, 3])
await _register_preset_webhooks(
client, preset, "https://mcp.example.com/webhooks/nextcloud"
)
with pytest.raises(WebhookSecretNotConfigured):
await _register_preset_webhooks(
client, preset, "https://mcp.example.com/webhooks/nextcloud"
)
for call in client.create_webhook.await_args_list:
assert call.kwargs["auth_method"] == "none"
assert call.kwargs["auth_data"] is None
client.create_webhook.assert_not_called()
async def test_register_returns_ids_in_call_order(monkeypatch, mocker):
_patch_secret(monkeypatch, None)
_patch_secret(monkeypatch, "supersecret")
preset = get_preset("notes_sync")
assert preset is not None
client = _make_webhooks_client(mocker, ids=[42, 43, 44])
+6 -2
View File
@@ -14,6 +14,7 @@ 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,
)
@@ -126,9 +127,12 @@ def test_localhost_fallback_when_nothing_set(monkeypatch):
@pytest.mark.unit
def test_auth_pair_returns_none_when_secret_unset(monkeypatch):
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)
assert webhook_auth_pair() == ("none", None)
with pytest.raises(WebhookSecretNotConfigured):
webhook_auth_pair()
@pytest.mark.unit
+27 -1
View File
@@ -24,6 +24,7 @@ from nextcloud_mcp_server.api.webhooks import (
list_webhooks,
)
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
from nextcloud_mcp_server.auth.webhook_routes import WebhookSecretNotConfigured
pytestmark = pytest.mark.unit
@@ -145,7 +146,7 @@ async def test_create_webhook_uses_basic_auth(mocker):
)
mocker.patch(
"nextcloud_mcp_server.api.webhooks.webhook_auth_pair",
return_value=("none", None),
return_value=("header", {"Authorization": "Bearer supersecret"}),
)
client = TestClient(_build_test_app())
@@ -163,6 +164,31 @@ async def test_create_webhook_uses_basic_auth(mocker):
_assert_basic_auth_not_bearer(factory)
async def test_create_webhook_returns_503_when_secret_unset(mocker):
"""Security (GHSA-8vh3-g2qg-2h2c): registration is refused without a
WEBHOOK_SECRET so no unauthenticated delivery target is created."""
_patch_token_validation(mocker)
_patch_basic_auth(mocker, username="bob", app_password="bob-pwd")
_patch_outbound_client_factory(mocker)
mocker.patch(
"nextcloud_mcp_server.api.webhooks.webhook_auth_pair",
side_effect=WebhookSecretNotConfigured("WEBHOOK_SECRET must be set"),
)
client = TestClient(_build_test_app())
resp = client.post(
"/api/v1/webhooks",
headers={"Authorization": "Bearer mcp-token"},
json={
"event": "OCP\\Events\\NodeCreated",
"uri": "http://mcp:8000/webhooks/nextcloud",
},
)
assert resp.status_code == 503
assert resp.json()["error"] == "Webhooks disabled"
async def test_create_webhook_validates_required_fields(mocker):
_patch_token_validation(mocker)
_patch_basic_auth(mocker)