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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user