From abfcd331e3b1401bee3e6d5856b79cfb18bc220e Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Sat, 3 Oct 2026 11:39:06 +0200 Subject: [PATCH 1/2] Test that /aidbox requires the app's credentials Fails: the dispatch answers any caller, with or without the secret. Refs #118 --- main.py | 13 +++++++++ tests/test_sdk.py | 70 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/main.py b/main.py index d2e270f..e9fafdb 100644 --- a/main.py +++ b/main.py @@ -160,3 +160,16 @@ async def observation_custom_op(operation, request): ) async def operation_outcome_test_op(operation, request): raise OperationOutcome(reason="test reason") + + +@sdk.operation( + ["POST"], + ["$dispatch-test"], +) +async def dispatch_test_op(operation, request): + return web.json_response({"status": "ok"}) + + +@sdk.subscription("Location") +async def dispatch_test_sub(event, request): + pass diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 0174812..7f9319b 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -1,4 +1,5 @@ import asyncio +import base64 import logging from unittest import mock @@ -7,6 +8,7 @@ from fhirpy.base.exceptions import OperationOutcome import main +from aidbox_python_sdk import app_keys as ak from aidbox_python_sdk.db import DBProxy @@ -200,3 +202,71 @@ async def test_operation_outcome_test_op(aidbox_client): with pytest.raises(OperationOutcome) as exc: await aidbox_client.execute("/$operation-outcome-test") assert exc.value.resource.get("issue")[0].get("diagnostics") == "test reason" + + +DISPATCH_TEST_OP = { + "type": "operation", + "operation": {"id": "POST.main.dispatch_test_op.ddispatch-test"}, + "request": {}, +} +DISPATCH_TEST_EVENT = {"type": "subscription", "handler": "dispatch_test_sub", "event": {}} +ANOTHER_APP_AUTHORIZATION = f"Basic {base64.b64encode(b'app-test:another-secret').decode()}" + + +@pytest.fixture +def aidbox_authorization(app): + settings = app[ak.settings] + credentials = f"{settings.APP_ID}:{settings.APP_SECRET}".encode() + return f"Basic {base64.b64encode(credentials).decode()}" + + +@pytest.mark.asyncio +async def test_dispatch_refuses_an_operation_without_credentials(client): + resp = await client.post("/aidbox", json=DISPATCH_TEST_OP) + + assert resp.status == 401 + + +@pytest.mark.asyncio +async def test_dispatch_refuses_an_operation_with_another_app_secret(client): + resp = await client.post( + "/aidbox", json=DISPATCH_TEST_OP, headers={"Authorization": ANOTHER_APP_AUTHORIZATION} + ) + + assert resp.status == 401 + + +@pytest.mark.asyncio +async def test_dispatch_invokes_an_operation_for_aidbox(client, aidbox_authorization): + resp = await client.post( + "/aidbox", json=DISPATCH_TEST_OP, headers={"Authorization": aidbox_authorization} + ) + + assert resp.status == 200 + # The fallback branch answers 200 too, so only the operation's own body proves it ran. + assert await resp.json() == {"status": "ok"} + + +@pytest.mark.asyncio +async def test_dispatch_refuses_a_subscription_without_credentials(client): + resp = await client.post("/aidbox", json=DISPATCH_TEST_EVENT) + + assert resp.status == 401 + + +@pytest.mark.asyncio +async def test_dispatch_refuses_a_subscription_with_another_app_secret(client): + resp = await client.post( + "/aidbox", json=DISPATCH_TEST_EVENT, headers={"Authorization": ANOTHER_APP_AUTHORIZATION} + ) + + assert resp.status == 401 + + +@pytest.mark.asyncio +async def test_dispatch_triggers_a_subscription_for_aidbox(client, aidbox_authorization): + resp = await client.post( + "/aidbox", json=DISPATCH_TEST_EVENT, headers={"Authorization": aidbox_authorization} + ) + + assert resp.status == 200 From caf5f8cdb6079e9fa2c38258669c3b0bde28bbe7 Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Sat, 3 Oct 2026 11:39:25 +0200 Subject: [PATCH 2/2] Refuse an /aidbox request that does not carry the app's credentials Aidbox sends Basic APP_ID:APP_SECRET on every operation and subscription callback. Refs #118 --- CHANGELOG.md | 1 + aidbox_python_sdk/handlers.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb392a6..2e13cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## 0.3.0 +- Answer `401` to a `POST /aidbox` that does not carry the app's `APP_ID:APP_SECRET` credentials - Drop Python 3.9 and 3.10, both end of life diff --git a/aidbox_python_sdk/handlers.py b/aidbox_python_sdk/handlers.py index e2b8f0a..ca2b367 100644 --- a/aidbox_python_sdk/handlers.py +++ b/aidbox_python_sdk/handlers.py @@ -1,5 +1,7 @@ import asyncio +import base64 import logging +import secrets from typing import Any from aiohttp import web @@ -11,6 +13,13 @@ routes = web.RouteTableDef() +def is_from_aidbox(request: web.Request) -> bool: + """Aidbox calls an http-rpc endpoint with the secret the app registered it under.""" + settings = request.app[ak.settings] + expected = b"Basic " + base64.b64encode(f"{settings.APP_ID}:{settings.APP_SECRET}".encode()) + return secrets.compare_digest(request.headers.get("Authorization", "").encode(), expected) + + async def subscription(request: web.Request, data: dict): logger.debug("Subscription handler: %s", data["handler"]) if "handler" not in data or "event" not in data: @@ -59,6 +68,10 @@ async def operation(request: web.Request, data: dict[str, Any]): @routes.post("/aidbox") async def dispatch(request): logger.debug("Dispatch new request %s %s", request.method, request.url) + if not is_from_aidbox(request): + logger.error("Dispatch request without the app's credentials") + raise web.HTTPUnauthorized() + data = await request.json() if "type" in data and data["type"] in TYPES: logger.debug("Dispatch to `%s` handler", data["type"])