From 806ab0ce33290e086f0539fb972c28cb0cc23fcd Mon Sep 17 00:00:00 2001 From: chengyongru Date: Thu, 25 Jun 2026 18:26:15 +0800 Subject: [PATCH] fix: cap rendered webhook thread keys --- docs/configuration.md | 2 +- nanobot/webhooks.py | 6 +++++- tests/test_webhooks.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index de88a7790..a5078e4d9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2009,7 +2009,7 @@ Common template variables: | `webhooks.routes..to` | empty | Required target address in `channel:chat` format. | | `webhooks.routes..events` | `[]` | Optional provider event-name allowlist. For GitHub this matches `X-GitHub-Event`, such as `pull_request`. | | `webhooks.routes..actions` | `[]` | Optional JSON payload `action` allowlist, such as `opened` or `synchronize`. | -| `webhooks.routes..thread` | empty | Optional Jinja template for the session key. Defaults to `to`. | +| `webhooks.routes..thread` | empty | Optional Jinja template for the session key. Defaults to `to`; rendered values are capped at 512 characters. | | `webhooks.routes..prompt` | empty | Optional Jinja template for the inbound agent message. | | `webhooks.routes..sender` | `webhook` | Sender ID placed on the inbound message. | | `webhooks.routes..maxBodyBytes` | `1048576` | Maximum request body size, from 1 KiB to 10 MiB. | diff --git a/nanobot/webhooks.py b/nanobot/webhooks.py index 1a2eb4667..6d1450913 100644 --- a/nanobot/webhooks.py +++ b/nanobot/webhooks.py @@ -19,6 +19,7 @@ from nanobot.utils.helpers import truncate_text _HMAC_PREFIX = "sha256=" _DEFAULT_PROMPT_MAX_CHARS = 24_000 +_DEFAULT_THREAD_MAX_CHARS = 512 _REDACTED_HEADERS = { "authorization", "cookie", @@ -494,7 +495,10 @@ def _render_thread(route: WebhookRouteConfig, context: dict[str, Any]) -> str: rendered = _jinja().from_string(template).render(**context) except TemplateError as exc: raise WebhookError(400, f"webhook thread template failed: {exc}") from exc - return rendered.strip() + rendered = rendered.strip() + if len(rendered) > _DEFAULT_THREAD_MAX_CHARS: + raise WebhookError(400, "webhook thread template rendered too long") + return rendered def _jinja() -> Environment: diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 468ea38e1..c2d12c618 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -218,6 +218,47 @@ async def test_webhook_custom_path_and_thread_template() -> None: assert msg.session_key_override == "deploy:api:delivery-2" +@pytest.mark.asyncio +async def test_webhook_rejects_overlong_thread_template_without_dedupe() -> None: + bus = MessageBus() + router = WebhookRouter( + WebhooksConfig( + routes={ + "deploy": WebhookRouteConfig( + auth="none", + to="websocket:ops", + thread="deploy:{{ event.thread }}", + prompt="Deploy {{ event.service }}", + ) + } + ), + bus, + ) + + failed = await router.handle( + method="POST", + path="/webhooks/deploy", + headers={"X-Nanobot-Delivery": "delivery-3"}, + body=json.dumps({"service": "api", "thread": "x" * 600}).encode(), + ) + accepted = await router.handle( + method="POST", + path="/webhooks/deploy", + headers={"X-Nanobot-Delivery": "delivery-3"}, + body=json.dumps({"service": "api", "thread": "release"}).encode(), + ) + + assert failed is not None + assert failed.status == 400 + assert "thread template rendered too long" in failed.body["error"] + assert accepted is not None + assert accepted.status == 202 + assert accepted.body["queued"] is True + assert bus.inbound_size == 1 + msg = await bus.consume_inbound() + assert msg.session_key_override == "deploy:release" + + @pytest.mark.asyncio async def test_github_webhook_validates_signature_and_dedupes_delivery() -> None: bus = MessageBus()