Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ jobs:
run: php -d zend.assertions=1 -d assert.exception=1 tests/php-test.php
- name: Test Node.js example
run: node tests/node-test.mjs
- name: Test Python example
run: python3 tests/python-test.py
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All notable changes to this repository will be documented here.
- detailed idempotency guidance for `X-GitHub-Delivery`, including claim/process/complete-fail flow, leases, retries and failure windows.
- webhook receiver threat model covering trust boundaries, replay, business authorization, resource exhaustion, secret handling, logging, least privilege and incident response.
- PHP and Node.js regression coverage for empty secrets, wrong signature algorithms and malformed SHA-256 signature headers.
- Python HMAC SHA-256 example and regression tests using GitHub's public webhook validation vector.

### Changed

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Guia prático para receber webhooks do GitHub sem confiar cegamente no payload recebido.

Os exemplos mostram como validar `X-Hub-Signature-256` em PHP e Node.js usando comparação em tempo constante. O material é independente de framework e não contém código de nenhum produto comercial.
Os exemplos mostram como validar `X-Hub-Signature-256` em PHP, Node.js e Python usando comparação em tempo constante. O material é independente de framework e não contém código de nenhum produto comercial.

## Checklist mínimo

Expand Down Expand Up @@ -61,8 +61,9 @@ O [threat model](docs/threat-model.md) cobre:
|---|---|---|
| PHP 8+ | [`examples/php/verify.php`](examples/php/verify.php) | `php tests/php-test.php` |
| Node.js 20+ | [`examples/node/verify.mjs`](examples/node/verify.mjs) | `node tests/node-test.mjs` |
| Python 3.10+ | [`examples/python/verify.py`](examples/python/verify.py) | `python3 tests/python-test.py` |

Os exemplos recebem três valores: corpo bruto, header de assinatura e secret compartilhado.
Os exemplos recebem três valores: corpo bruto, header de assinatura e secret compartilhado. No exemplo Python, passe o corpo bruto da requisição como `bytes`, sem decodificar ou reserializar o JSON antes da validação.

## Validação local

Expand All @@ -71,6 +72,7 @@ A suíte pode ser executada sem GitHub Actions:
```bash
php tests/php-test.php
node tests/node-test.mjs
python3 tests/python-test.py
```

O workflow de teste permanece disponível em modo manual. A manutenção normal prioriza execução local para evitar consumo desnecessário de CI.
Expand Down
29 changes: 29 additions & 0 deletions examples/python/verify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from __future__ import annotations

import hashlib
import hmac
import re

_SIGNATURE_PATTERN = re.compile(r"^sha256=[0-9a-f]{64}$")


def verify_github_webhook(
payload: bytes,
signature_header: str | None,
secret: str,
) -> bool:
"""Verify a GitHub X-Hub-Signature-256 value against the raw request body."""
if (
not secret
or signature_header is None
or not _SIGNATURE_PATTERN.fullmatch(signature_header)
):
return False

expected = "sha256=" + hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()

return hmac.compare_digest(expected, signature_header)
63 changes: 63 additions & 0 deletions tests/python-test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import importlib.util
from pathlib import Path
import unittest

MODULE_PATH = Path(__file__).resolve().parents[1] / "examples" / "python" / "verify.py"
SPEC = importlib.util.spec_from_file_location("github_webhook_verify", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
verify_github_webhook = MODULE.verify_github_webhook


class VerifyGitHubWebhookTest(unittest.TestCase):
def setUp(self) -> None:
self.secret = "It's a Secret to Everybody"
self.payload = b"Hello, World!"
self.signature = (
"sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
)

def test_accepts_github_documentation_vector(self) -> None:
self.assertTrue(
verify_github_webhook(self.payload, self.signature, self.secret)
)

def test_rejects_wrong_secret(self) -> None:
self.assertFalse(
verify_github_webhook(self.payload, self.signature, "wrong-secret")
)

def test_rejects_tampered_payload(self) -> None:
self.assertFalse(
verify_github_webhook(
self.payload + b"tampered",
self.signature,
self.secret,
)
)

def test_rejects_missing_or_malformed_signatures(self) -> None:
invalid_signatures = (
None,
"",
"sha1=" + "a" * 40,
"sha256=not-hex",
"sha256=" + "A" * 64,
"sha256=" + "a" * 63,
)

for signature in invalid_signatures:
with self.subTest(signature=signature):
self.assertFalse(
verify_github_webhook(self.payload, signature, self.secret)
)

def test_rejects_empty_secret(self) -> None:
self.assertFalse(verify_github_webhook(self.payload, self.signature, ""))


if __name__ == "__main__":
unittest.main()