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/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``). """ ... 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..12224d4 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -10,8 +10,10 @@ from custom_components.pyscript import trigger from custom_components.pyscript.const import DOMAIN 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): @@ -224,3 +226,33 @@ 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 = 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 webhook.async_handle_webhook(hass, "test_req_hook", request) + + assert literal_eval(await wait_until_done(notify_q)) == ["abc123", "POST", {"hello": "world"}]