From 5612dd4d8841006ad769c98061802fb3a6a94f70 Mon Sep 17 00:00:00 2001 From: Jay Newstrom Date: Sat, 9 May 2026 18:59:35 -0600 Subject: [PATCH 1/3] expose aiohttp request to @webhook_trigger functions Add the underlying aiohttp.web.Request to the kwargs passed to @webhook_trigger user functions (and to the str_expr filter). Lets scripts inspect headers, method, query string, and re-read the raw body via `await request.read()` -- enabling HMAC signature validation, which previously wasn't possible since only the parsed payload was exposed. Backward compatible: existing functions that don't declare `request` are unaffected. --- .../pyscript/decorators/webhook.py | 1 + docs/reference.rst | 20 +++++++++++++ tests/test_decorators.py | 30 ++++++++++++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/custom_components/pyscript/decorators/webhook.py b/custom_components/pyscript/decorators/webhook.py index 612476c..3db0a09 100644 --- a/custom_components/pyscript/decorators/webhook.py +++ b/custom_components/pyscript/decorators/webhook.py @@ -54,6 +54,7 @@ async def _handler(_hass, webhook_id, request): func_args = { "trigger_type": "webhook", "webhook_id": webhook_id, + "request": request, } if "json" in request.headers.get(hdrs.CONTENT_TYPE, ""): diff --git a/docs/reference.rst b/docs/reference.rst index fc8992c..3b7c587 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -882,6 +882,7 @@ variables: - ``trigger_type`` is set to "webhook" - ``webhook_id`` is set to the webhook_id that was called. - ``payload`` is the data/json that was sent in the request returned as a dictionary. +- ``request`` is the underlying ``aiohttp.web.Request``. Use it to inspect headers (e.g. for HMAC signature validation), the HTTP method, query string, or to re-read the raw body via ``await request.read()`` (the body is cached after pyscript parses it into ``payload``). When the ``@webhook_trigger`` occurs, those same variables are passed as keyword arguments to the function in case it needs them. Additional keyword parameters can be specified by setting the optional ``kwargs`` argument to a ``dict`` with the keywords and values. @@ -895,6 +896,25 @@ An simple example looks like which if called using the curl command ``curl -X POST -d 'key1=xyz&key2=abc' hass_url/api/webhook/myid`` outputs ``It ran! {'key1': 'xyz', 'key2': 'abc'}, 10`` +To validate an HMAC signature on incoming requests, declare ``request`` in the function and read the raw body: + +.. code:: python + + import hmac + import hashlib + + SECRET = b"shared-secret" + + @webhook_trigger("github") + def gh(payload, request): + sig = request.headers.get("X-Hub-Signature-256", "") + body = await request.read() + expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expected): + log.warning("bad signature, ignoring") + return + log.info(f"verified webhook: {payload}") + NOTE: A webhook_id can only be used by either a built-in Home Assistant automation or pyscript, but not both. Trying to use the same webhook_id in both will result in an error. @state_active diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 8350500..2a5f7a7 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -3,12 +3,13 @@ from ast import literal_eval import asyncio from datetime import datetime as dt -from unittest.mock import mock_open, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest from custom_components.pyscript import trigger from custom_components.pyscript.const import DOMAIN +from custom_components.pyscript.decorators.webhook import WebhookTriggerDecorator from custom_components.pyscript.function import Function from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED from homeassistant.setup import async_setup_component @@ -224,3 +225,30 @@ def func6(value): hass.states.async_set("pyscript.var1", 6 + 2 * i) seq_num += 1 assert literal_eval(await wait_until_done(notify_q)) == [seq_num, 6 + 2 * i] + + +@pytest.mark.asyncio +async def test_webhook_request_kwarg(hass): + """The aiohttp request is passed to the user function as the `request` kwarg.""" + notify_q = asyncio.Queue(0) + await setup_script( + hass, + notify_q, + [dt(2020, 7, 1, 11, 59, 59, 999999)], + """ +@webhook_trigger("test_req_hook") +def webhook_test(payload, request): + pyscript.done = [request.headers["X-My-Sig"], request.method, payload] +""", + ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + request = MagicMock() + request.headers = {"Content-Type": "application/json", "X-My-Sig": "abc123"} + request.method = "POST" + request.json = AsyncMock(return_value={"hello": "world"}) + + await WebhookTriggerDecorator._handler(hass, "test_req_hook", request) + + assert literal_eval(await wait_until_done(notify_q)) == ["abc123", "POST", {"hello": "world"}] From e45bc6dee2bebd09339cb6c1ca846db53e164d7c Mon Sep 17 00:00:00 2001 From: Jay Newstrom Date: Sat, 9 May 2026 19:35:54 -0600 Subject: [PATCH 2/3] Attempt to make the test better + fix lint. --- tests/test_decorators.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 2a5f7a7..12224d4 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -3,16 +3,17 @@ from ast import literal_eval import asyncio from datetime import datetime as dt -from unittest.mock import AsyncMock, MagicMock, mock_open, patch +from unittest.mock import mock_open, patch import pytest from custom_components.pyscript import trigger from custom_components.pyscript.const import DOMAIN -from custom_components.pyscript.decorators.webhook import WebhookTriggerDecorator from custom_components.pyscript.function import Function +from homeassistant.components import webhook from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockRequest async def setup_script(hass, notify_q, now, source): @@ -244,11 +245,14 @@ def webhook_test(payload, request): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - request = MagicMock() - request.headers = {"Content-Type": "application/json", "X-My-Sig": "abc123"} - request.method = "POST" - request.json = AsyncMock(return_value={"hello": "world"}) + request = MockRequest( + content=b'{"hello": "world"}', + mock_source="test", + method="POST", + headers={"Content-Type": "application/json", "X-My-Sig": "abc123"}, + remote="127.0.0.1", + ) - await WebhookTriggerDecorator._handler(hass, "test_req_hook", request) + await webhook.async_handle_webhook(hass, "test_req_hook", request) assert literal_eval(await wait_until_done(notify_q)) == ["abc123", "POST", {"hello": "world"}] From a049c55ec14d91c593f71781be686bba8f604074 Mon Sep 17 00:00:00 2001 From: Jay Newstrom Date: Sun, 10 May 2026 06:15:13 -0600 Subject: [PATCH 3/3] Add builtins. --- custom_components/pyscript/eval.py | 1 + custom_components/pyscript/stubs/pyscript_builtins.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/pyscript/eval.py b/custom_components/pyscript/eval.py index 07d6310..8a9df38 100644 --- a/custom_components/pyscript/eval.py +++ b/custom_components/pyscript/eval.py @@ -78,6 +78,7 @@ "payload", "payload_obj", "qos", + "request", "retain", "topic", "trigger_type", diff --git a/custom_components/pyscript/stubs/pyscript_builtins.py b/custom_components/pyscript/stubs/pyscript_builtins.py index c4b1736..ea75580 100644 --- a/custom_components/pyscript/stubs/pyscript_builtins.py +++ b/custom_components/pyscript/stubs/pyscript_builtins.py @@ -133,10 +133,12 @@ def webhook_trigger( Args: webhook_id: Webhook id to listen to. - str_expr: Optional expression evaluated against ``trigger_type``, ``webhook_id``, and ``payload``. + str_expr: Optional expression evaluated against ``trigger_type``, ``webhook_id``, ``request``, and ``payload``. local_only: If False, allow requests from anywhere on the internet. methods: HTTP methods to allow. kwargs: Extra keyword arguments merged into each invocation. + + Trigger kwargs include ``trigger_type="webhook"``, ``webhook_id``, the parsed payload fields, and ``request`` (the underlying ``aiohttp.web.Request``). """ ...