fix(webhooks): authenticate deliveries via WEBHOOK_SECRET; review nits
Adds optional shared-secret authentication for /webhooks/nextcloud, addressing the security follow-up flagged in #747. Behavior: - WEBHOOK_SECRET set: registrations pass authMethod="header" with authData={"Authorization": "Bearer <secret>"} (encrypted at-rest in Nextcloud's DB and forwarded on every delivery). The receiver validates the same header with hmac.compare_digest before parsing any payload; missing/invalid → 401. - WEBHOOK_SECRET unset: registrations stay on authMethod="none" and the receiver accepts unauthenticated POSTs (logging a one-time startup warning). Backward compatible — operators can roll out at their own pace. Implementation notes: - WebhooksClient.create_webhook gains an `auth_data` parameter mapped to NC's `authData` body field; this is distinct from the existing `headers` parameter (`headers` is plaintext static request headers, `authData` is encrypted at-rest in NC and only emitted when authMethod="header"). The previous `auth_method="bearer"` mention in the docstring was incorrect — NC supports only "none" and "header". - A small `webhook_auth_pair()` helper in auth/webhook_routes.py centralises the secret→(auth_method, auth_data) resolution so the preset flow and the Astrolabe-facing /api/v1/webhooks endpoint stay in sync. Also addresses the smaller review points from #747: - f-string → lazy %s formatting in webhook_receiver.py and webhook_routes.py. - Move `int(time)` inside webhook_parser's try/except so a malformed `time` field returns None instead of raising ValueError. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
2e2a098bee
commit
224428fca5
@@ -10,11 +10,32 @@ from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from nextcloud_mcp_server.config import Settings
|
||||
from nextcloud_mcp_server.vector import webhook_receiver
|
||||
from nextcloud_mcp_server.vector.webhook_receiver import handle_nextcloud_webhook
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_warned_flag():
|
||||
"""The receiver warns once per process when WEBHOOK_SECRET is missing.
|
||||
Reset between tests so each gets a clean slate."""
|
||||
webhook_receiver._warned_about_missing_secret = False
|
||||
yield
|
||||
webhook_receiver._warned_about_missing_secret = False
|
||||
|
||||
|
||||
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``."""
|
||||
monkeypatch.setattr(
|
||||
webhook_receiver,
|
||||
"get_settings",
|
||||
lambda: Settings(webhook_secret=secret),
|
||||
)
|
||||
|
||||
|
||||
def _make_app(send_stream=None) -> Starlette:
|
||||
app = Starlette(
|
||||
routes=[
|
||||
@@ -142,3 +163,85 @@ def test_returns_500_when_stream_is_closed():
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json()["status"] == "error"
|
||||
|
||||
|
||||
# --- WEBHOOK_SECRET authentication ---------------------------------------
|
||||
|
||||
|
||||
def test_secret_set_valid_bearer_header_queues_task(monkeypatch):
|
||||
_patch_secret(monkeypatch, "supersecret")
|
||||
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:
|
||||
response = client.post(
|
||||
"/webhooks/nextcloud",
|
||||
json=_NOTE_CREATED,
|
||||
headers={"Authorization": "Bearer supersecret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "queued"
|
||||
assert receive_stream.receive_nowait().doc_id == "437"
|
||||
|
||||
|
||||
def test_secret_set_missing_authorization_returns_401(monkeypatch):
|
||||
_patch_secret(monkeypatch, "supersecret")
|
||||
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:
|
||||
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["status"] == "unauthorized"
|
||||
with pytest.raises(anyio.WouldBlock):
|
||||
receive_stream.receive_nowait()
|
||||
|
||||
|
||||
def test_secret_set_wrong_secret_returns_401(monkeypatch):
|
||||
_patch_secret(monkeypatch, "supersecret")
|
||||
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:
|
||||
response = client.post(
|
||||
"/webhooks/nextcloud",
|
||||
json=_NOTE_CREATED,
|
||||
headers={"Authorization": "Bearer wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
with pytest.raises(anyio.WouldBlock):
|
||||
receive_stream.receive_nowait()
|
||||
|
||||
|
||||
def test_secret_set_wrong_scheme_returns_401(monkeypatch):
|
||||
"""A token without the Bearer prefix is rejected."""
|
||||
_patch_secret(monkeypatch, "supersecret")
|
||||
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:
|
||||
response = client.post(
|
||||
"/webhooks/nextcloud",
|
||||
json=_NOTE_CREATED,
|
||||
headers={"Authorization": "supersecret"},
|
||||
)
|
||||
|
||||
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."""
|
||||
_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:
|
||||
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert receive_stream.receive_nowait().doc_id == "437"
|
||||
|
||||
Reference in New Issue
Block a user