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:
co-authored by
Claude Opus 4.8
parent
a818b49b3f
commit
4fc2b10945
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user