diff --git a/.env.test b/.env.test index e28b733..c65b3ea 100644 --- a/.env.test +++ b/.env.test @@ -7,4 +7,5 @@ RUNBOAT_BUILD_ENV={} RUNBOAT_BUILD_SECRET_ENV={"PGPASSWORD": "thepgpassword"} RUNBOAT_BUILD_TEMPLATE_VARS={"storageClassName": "my-storage-class"} RUNBOAT_GITHUB_TOKEN= +RUNBOAT_GITHUB_WEBHOOK_SECRET= RUNBOAT_LOG_CONFIG=log-config.yaml diff --git a/pyproject.toml b/pyproject.toml index d28bde8..1153d32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dynamic = ["version", "description"] [project.optional-dependencies] test = [ "pytest", + "pytest-asyncio", "pytest-cov", "pytest-dotenv", "pytest-mock", @@ -42,7 +43,7 @@ profile = 'black' [tool.pytest.ini_options] env_override_existing_values = 1 env_files = [".env.test"] - +asyncio_mode = "strict" # flake8 config is in .flake8 [tool.mypy] diff --git a/requirements-test.txt b/requirements-test.txt index 39fe8fb..76f30b0 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -7,6 +7,7 @@ pluggy==1.0.0 py==1.11.0 pyparsing==3.0.7 pytest==6.2.5 +pytest-asyncio==0.17.2 pytest-cov==3.0.0 pytest-dotenv==0.5.2 pytest-mock==3.6.1 diff --git a/src/runboat/settings.py b/src/runboat/settings.py index c535240..d6059a1 100644 --- a/src/runboat/settings.py +++ b/src/runboat/settings.py @@ -54,6 +54,8 @@ class Settings(BaseSettings): # The token to use for the GitHub api calls (to query branches and pull requests, # and report build statuses). github_token: str | None + # The secret used to verify GitHub webhook signatures + github_webhook_secret: bytes | None # The file with the python logging configuration to use for the runboat controller. log_config: str | None # The base url where the runboat UI and API is exposed on internet. diff --git a/src/runboat/webhooks.py b/src/runboat/webhooks.py index 9198159..ec81d26 100644 --- a/src/runboat/webhooks.py +++ b/src/runboat/webhooks.py @@ -1,3 +1,4 @@ +import hmac import logging from fastapi import APIRouter, BackgroundTasks, Header, Request @@ -11,13 +12,33 @@ _logger = logging.getLogger(__name__) router = APIRouter() +def _verify_github_signature( + x_hub_signature_256: str | None, secret: bytes | None, body: bytes +) -> bool: + if not secret: + return True + if not x_hub_signature_256: + _logger.warning("Got payload without X-Hub-Signature-256") + return False + signature = "sha256=" + hmac.new(secret, body, "sha256").hexdigest() + if not hmac.compare_digest(signature, x_hub_signature_256): + _logger.warning("Got payload with invalid X-Hub-Signature-256") + return False + return True + + @router.post("/webhooks/github") async def receive_payload( background_tasks: BackgroundTasks, request: Request, x_github_event: str = Header(...), + x_hub_signature_256: str | None = Header(None), ) -> None: - # TODO check x-hub-signature + body = await request.body() + if not _verify_github_signature( + x_hub_signature_256, settings.github_webhook_secret, body + ): + return payload = await request.json() if x_github_event == "pull_request": repo = payload["repository"]["full_name"] diff --git a/tests/test_webhook.py b/tests/test_webhook.py index fc75914..fd29c93 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -5,6 +5,7 @@ from pytest_mock import MockerFixture from runboat.app import app from runboat.controller import controller from runboat.github import CommitInfo +from runboat.webhooks import _verify_github_signature client = TestClient(app) @@ -137,3 +138,18 @@ def test_webhook_github_pr_close(mocker: MockerFixture) -> None: repo="oca/mis-builder", pr=381, ) + + +def test_verify_github_signature() -> None: + assert _verify_github_signature(None, None, b"body") # no secret configured, ok + assert not _verify_github_signature( + None, b"secret", b"body" + ) # no X-Hub-Signature-256 + assert not _verify_github_signature( + "sha256=invalid-sig", b"secret", b"body" + ) # no X-Hub-Signature-256 + assert _verify_github_signature( + "sha256=dc46983557fea127b43af721467eb9b3fde2338fe3e14f51952aa8478c13d355", + b"secret", + b"body", + )