Address the two Security findings from PR review: - webhook_receiver: encode Authorization header and expected bearer to utf-8 bytes before hmac.compare_digest. Conventional form; doesn't rely on Python's implicit ASCII encoding. - webhook_routes: html.escape user-influenced and exception-derived strings before interpolating into HTMLResponse content. Covers the preset_id path param echoed in the "Unknown preset" branch and the str(e) text rendered on handler exceptions. Adds regression tests verifying compare_digest is invoked on bytes and that <script> payloads (in preset_id and exception messages) are emitted as escaped entities, not active markup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
134 lines
4.4 KiB
Python
134 lines
4.4 KiB
Python
"""Unit tests verifying that user-influenced and exception-derived strings
|
|
are HTML-escaped before they are rendered into ``HTMLResponse`` content.
|
|
|
|
The handlers under test are decorated with ``@requires("authenticated")``,
|
|
so we install an ``AuthenticationMiddleware`` backed by a trivial backend
|
|
that always reports the request as authenticated. We then monkeypatch the
|
|
internal helpers (``_get_authenticated_client``, ``is_nextcloud_admin``,
|
|
``get_preset``) to drive the handler down the specific code path we want
|
|
to exercise.
|
|
"""
|
|
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.authentication import (
|
|
AuthCredentials,
|
|
AuthenticationBackend,
|
|
SimpleUser,
|
|
)
|
|
from starlette.middleware import Middleware
|
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
from starlette.routing import Route
|
|
from starlette.testclient import TestClient
|
|
|
|
from nextcloud_mcp_server.auth import webhook_routes
|
|
from nextcloud_mcp_server.auth.webhook_routes import (
|
|
disable_webhook_preset,
|
|
enable_webhook_preset,
|
|
)
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
class _AlwaysAuthBackend(AuthenticationBackend):
|
|
async def authenticate(self, conn):
|
|
return AuthCredentials(["authenticated"]), SimpleUser("testuser")
|
|
|
|
|
|
def _make_app() -> Starlette:
|
|
return Starlette(
|
|
routes=[
|
|
Route(
|
|
"/app/webhooks/enable/{preset_id:path}",
|
|
enable_webhook_preset,
|
|
methods=["POST"],
|
|
),
|
|
Route(
|
|
"/app/webhooks/disable/{preset_id:path}",
|
|
disable_webhook_preset,
|
|
methods=["DELETE"],
|
|
),
|
|
],
|
|
middleware=[Middleware(AuthenticationMiddleware, backend=_AlwaysAuthBackend())],
|
|
)
|
|
|
|
|
|
def _stub_admin_path(monkeypatch):
|
|
"""Make the handler progress past auth/admin checks without real I/O."""
|
|
|
|
async def _fake_client(_request):
|
|
return object() # never actually used because get_preset returns None
|
|
|
|
async def _fake_is_admin(_request, _client):
|
|
return True
|
|
|
|
monkeypatch.setattr(webhook_routes, "_get_authenticated_client", _fake_client)
|
|
monkeypatch.setattr(webhook_routes, "is_nextcloud_admin", _fake_is_admin)
|
|
|
|
|
|
def test_enable_unknown_preset_id_is_html_escaped(monkeypatch):
|
|
"""A `<script>` tag in the preset_id path param must be rendered as
|
|
escaped text, not active markup."""
|
|
_stub_admin_path(monkeypatch)
|
|
monkeypatch.setattr(webhook_routes, "get_preset", lambda _id: None)
|
|
|
|
app = _make_app()
|
|
payload = "<script>alert(1)</script>"
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(f"/app/webhooks/enable/{payload}")
|
|
|
|
assert response.status_code == 404
|
|
assert "<script>alert(1)</script>" in response.text
|
|
assert "<script>alert(1)</script>" not in response.text
|
|
|
|
|
|
def test_disable_unknown_preset_id_is_html_escaped(monkeypatch):
|
|
_stub_admin_path(monkeypatch)
|
|
monkeypatch.setattr(webhook_routes, "get_preset", lambda _id: None)
|
|
|
|
app = _make_app()
|
|
payload = "<script>alert(2)</script>"
|
|
|
|
with TestClient(app) as client:
|
|
response = client.delete(f"/app/webhooks/disable/{payload}")
|
|
|
|
assert response.status_code == 404
|
|
assert "<script>alert(2)</script>" in response.text
|
|
assert "<script>alert(2)</script>" not in response.text
|
|
|
|
|
|
def test_enable_exception_message_is_html_escaped(monkeypatch):
|
|
"""If the handler raises, the exception text must be escaped before
|
|
it lands in the 500 response body."""
|
|
|
|
async def _boom(_request):
|
|
raise RuntimeError("</p><script>x</script>")
|
|
|
|
monkeypatch.setattr(webhook_routes, "_get_authenticated_client", _boom)
|
|
|
|
app = _make_app()
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/app/webhooks/enable/notes_sync")
|
|
|
|
assert response.status_code == 500
|
|
assert "</p><script>x</script>" in response.text
|
|
assert "<script>x</script>" not in response.text
|
|
|
|
|
|
def test_disable_exception_message_is_html_escaped(monkeypatch):
|
|
async def _boom(_request):
|
|
raise RuntimeError("</p><script>y</script>")
|
|
|
|
monkeypatch.setattr(webhook_routes, "_get_authenticated_client", _boom)
|
|
|
|
app = _make_app()
|
|
|
|
with TestClient(app) as client:
|
|
response = client.delete("/app/webhooks/disable/notes_sync")
|
|
|
|
assert response.status_code == 500
|
|
assert "</p><script>y</script>" in response.text
|
|
assert "<script>y</script>" not in response.text
|