mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Make copied webhook verification freshness-aware
Reject stale or malformed signed requests and empty keys in the existing receiver example. Explain authenticated payload deduplication and unsigned correlation headers, and exercise the exact copied code with adverse and healthy controls. Contract-Neutral: Documentation and its executable safety checks only; the outbound signing format, transport, endpoints and runtime are unchanged. Change-source: pulse-maintainer
This commit is contained in:
+45
-3
@@ -86,16 +86,58 @@ These fields and behaviors are stable; ticket-routing integrations can rely on t
|
||||
- `X-Pulse-Timestamp`: Unix seconds at send time.
|
||||
- `X-Pulse-Signature`: `v1=` + hex HMAC-SHA256 over `timestamp + "." + body`, keyed with the shared secret.
|
||||
|
||||
To verify: recompute the HMAC over the received timestamp and raw body, compare with constant-time equality, and reject requests whose timestamp is outside your tolerance window (e.g. 5 minutes) to block replays.
|
||||
Verify before parsing the JSON or performing any action. Read the original body
|
||||
as bytes and the complete `X-Pulse-Timestamp` and `X-Pulse-Signature` header
|
||||
values; reject missing or duplicate signing headers. Do not re-serialize JSON,
|
||||
trim the body, or trust a timestamp from the payload instead of the header.
|
||||
|
||||
This Python example accepts timestamps within five minutes of the receiver's
|
||||
clock, rejects malformed headers, and compares the HMAC in constant time. Keep
|
||||
both machines' clocks synchronised. Load the shared secret from private receiver
|
||||
configuration, not from the request, a command argument or a log. Pulse trims
|
||||
surrounding whitespace from its configured secret; the verifier does the same.
|
||||
An empty secret must never authenticate a request.
|
||||
|
||||
```python
|
||||
import hashlib, hmac
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import time
|
||||
|
||||
MAX_SKEW_SECONDS = 300
|
||||
|
||||
|
||||
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
|
||||
expected = "v1=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
|
||||
secret = secret.strip()
|
||||
if not secret or not isinstance(body, bytes):
|
||||
return False
|
||||
if not isinstance(timestamp, str) or re.fullmatch(r"[0-9]{1,12}", timestamp) is None:
|
||||
return False
|
||||
if not isinstance(signature, str) or re.fullmatch(r"v1=[0-9a-f]{64}", signature) is None:
|
||||
return False
|
||||
if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
|
||||
return False
|
||||
expected = "v1=" + hmac.new(
|
||||
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
```
|
||||
|
||||
A time window rejects old captures, **not every replay**: the same signed
|
||||
request can arrive more than once within that window, and Pulse signs each
|
||||
retry with its current send time. After verification, deduplicate atomically
|
||||
before creating a ticket or taking another action. Use a durable key scoped to
|
||||
the configured Pulse sender and destination, built from the authenticated
|
||||
payload's alert ID and event (`{{.ID}}` and `{{.Event}}` in your template); keep
|
||||
it across retries and receiver restarts. An `alert` and its later `resolved`
|
||||
are different events, not duplicates. The `X-Pulse-Event-ID` header is a useful
|
||||
correlation hint, but is **not covered by this HMAC**: do not let a changed
|
||||
header bypass deduplication of an otherwise identical signed event. The sample
|
||||
PSA payloads below include `alertId` and `event` for this purpose. A valid HMAC
|
||||
establishes integrity, not that processing succeeded; retain your receiver's
|
||||
normal durable processing and retry handling. Never log the secret or full
|
||||
credential-bearing request while diagnosing a rejection.
|
||||
|
||||
The secret is write-only through the API: list responses mask it, and an update that echoes the masked placeholder keeps the stored secret.
|
||||
|
||||
```http
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Verify the copyable receiver example through production Docs/router/Markdown.
|
||||
// This does not send a webhook or exercise a native receiver or deduplication.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = path.resolve('frontend-modern');
|
||||
const artifacts = path.join(root, 'node_modules', 'webhook-verification-browser');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const guide = fs.readFileSync(path.join(root, 'public/docs/WEBHOOKS.md'), 'utf8');
|
||||
const example = guide.match(/```python\n([\s\S]*?)```/)[1].trim();
|
||||
process.chdir(root);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root, configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(root, 'node_modules', '.vite-webhook-verification'),
|
||||
server: { host: '127.0.0.1', port: 5255, strictPort: true },
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
});
|
||||
const results = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) {
|
||||
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true });
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 },
|
||||
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
await page.goto('http://127.0.0.1:5255/browser-tests/docs-fragment-navigation.html',
|
||||
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
await page.getByRole('link', { name: '← All documentation' }).click();
|
||||
const entry = page.locator('a[data-doc-link][href="/docs/WEBHOOKS"]').first();
|
||||
await entry.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const heading = page.getByRole('heading', { name: '📦 Delivery Contract', exact: true });
|
||||
await heading.waitFor();
|
||||
const code = page.locator('pre').filter({ hasText: 'MAX_SKEW_SECONDS = 300' });
|
||||
await code.scrollIntoViewIfNeeded();
|
||||
assert.equal((await code.locator('code').textContent()).trim(), example);
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await page.screenshot({ path: path.join(artifacts, `${engine}-verifier.png`) });
|
||||
const warning = page.locator('p').filter({ hasText: 'A time window rejects old captures' });
|
||||
await warning.scrollIntoViewIfNeeded();
|
||||
const text = await page.locator('article').innerText();
|
||||
for (const phrase of ['within five minutes', 'empty secret', 'not every replay',
|
||||
'deduplicate atomically', 'receiver restarts', 'not covered by this HMAC',
|
||||
'missing or duplicate signing headers', 'before parsing the JSON']) {
|
||||
assert.ok(text.includes(phrase), phrase);
|
||||
}
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await page.screenshot({ path: path.join(artifacts, `${engine}-deduplication.png`) });
|
||||
const payloadHeading = page.getByRole('heading', { name: 'Sample PSA payloads', exact: true });
|
||||
await payloadHeading.scrollIntoViewIfNeeded();
|
||||
assert.ok((await page.locator('pre').filter({ hasText: '"alertId": "{{.ID | jsonString}}"' })
|
||||
.textContent()).includes('"event": "{{.Event}}"'));
|
||||
assert.deepEqual(errors, []);
|
||||
results.push({ engine, browserVersion: browser.version(), width, tone,
|
||||
keyboardEntry: true, exactCopiedPython: true, freshnessAndReplayLimits: true,
|
||||
payloadIdentity: true, noBodyOverflow: true, errors });
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version,
|
||||
scope: 'rendered guide and exact copied code; not webhook delivery or receiver processing',
|
||||
docs: { 'frontend-modern/public/docs/WEBHOOKS.md':
|
||||
createHash('sha256').update(guide).digest('hex') }, results };
|
||||
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -86,16 +86,58 @@ These fields and behaviors are stable; ticket-routing integrations can rely on t
|
||||
- `X-Pulse-Timestamp`: Unix seconds at send time.
|
||||
- `X-Pulse-Signature`: `v1=` + hex HMAC-SHA256 over `timestamp + "." + body`, keyed with the shared secret.
|
||||
|
||||
To verify: recompute the HMAC over the received timestamp and raw body, compare with constant-time equality, and reject requests whose timestamp is outside your tolerance window (e.g. 5 minutes) to block replays.
|
||||
Verify before parsing the JSON or performing any action. Read the original body
|
||||
as bytes and the complete `X-Pulse-Timestamp` and `X-Pulse-Signature` header
|
||||
values; reject missing or duplicate signing headers. Do not re-serialize JSON,
|
||||
trim the body, or trust a timestamp from the payload instead of the header.
|
||||
|
||||
This Python example accepts timestamps within five minutes of the receiver's
|
||||
clock, rejects malformed headers, and compares the HMAC in constant time. Keep
|
||||
both machines' clocks synchronised. Load the shared secret from private receiver
|
||||
configuration, not from the request, a command argument or a log. Pulse trims
|
||||
surrounding whitespace from its configured secret; the verifier does the same.
|
||||
An empty secret must never authenticate a request.
|
||||
|
||||
```python
|
||||
import hashlib, hmac
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import time
|
||||
|
||||
MAX_SKEW_SECONDS = 300
|
||||
|
||||
|
||||
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
|
||||
expected = "v1=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
|
||||
secret = secret.strip()
|
||||
if not secret or not isinstance(body, bytes):
|
||||
return False
|
||||
if not isinstance(timestamp, str) or re.fullmatch(r"[0-9]{1,12}", timestamp) is None:
|
||||
return False
|
||||
if not isinstance(signature, str) or re.fullmatch(r"v1=[0-9a-f]{64}", signature) is None:
|
||||
return False
|
||||
if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
|
||||
return False
|
||||
expected = "v1=" + hmac.new(
|
||||
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
```
|
||||
|
||||
A time window rejects old captures, **not every replay**: the same signed
|
||||
request can arrive more than once within that window, and Pulse signs each
|
||||
retry with its current send time. After verification, deduplicate atomically
|
||||
before creating a ticket or taking another action. Use a durable key scoped to
|
||||
the configured Pulse sender and destination, built from the authenticated
|
||||
payload's alert ID and event (`{{.ID}}` and `{{.Event}}` in your template); keep
|
||||
it across retries and receiver restarts. An `alert` and its later `resolved`
|
||||
are different events, not duplicates. The `X-Pulse-Event-ID` header is a useful
|
||||
correlation hint, but is **not covered by this HMAC**: do not let a changed
|
||||
header bypass deduplication of an otherwise identical signed event. The sample
|
||||
PSA payloads below include `alertId` and `event` for this purpose. A valid HMAC
|
||||
establishes integrity, not that processing succeeded; retain your receiver's
|
||||
normal durable processing and retry handling. Never log the secret or full
|
||||
credential-bearing request while diagnosing a rejection.
|
||||
|
||||
The secret is write-only through the API: list responses mask it, and an update that echoes the masked placeholder keeps the stored secret.
|
||||
|
||||
```http
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the receiver verifier copied from the shipped webhook guide.
|
||||
|
||||
Synthetic signatures exercise freshness, malformed inputs and byte integrity.
|
||||
This is not a running receiver, durable deduplication or notification delivery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOC = ROOT / "docs/WEBHOOKS.md"
|
||||
NOW = 1_791_030_000
|
||||
SECRET = "synthetic-receiver-test-secret"
|
||||
BODY = b'{"alertId":"alert-42","event":"alert","message":"test"}\r\n'
|
||||
|
||||
|
||||
def copied_verifier():
|
||||
blocks = re.findall(r"```python\n(.*?)```", DOC.read_text(), re.DOTALL)
|
||||
if len(blocks) != 1:
|
||||
raise AssertionError("expected one copyable webhook verifier")
|
||||
namespace = {}
|
||||
exec(compile(blocks[0], str(DOC), "exec"), namespace)
|
||||
return namespace["verify"]
|
||||
|
||||
|
||||
def sign(timestamp: str, body: bytes = BODY, secret: str = SECRET) -> str:
|
||||
# Independent wire-format oracle; do not use the copied implementation.
|
||||
message = timestamp.encode("utf-8") + b"." + body
|
||||
return "v1=" + hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
class WebhookVerificationDocsTest(unittest.TestCase):
|
||||
def check(self, timestamp=str(NOW), *, body=BODY, signature=None, secret=SECRET, now=NOW):
|
||||
if signature is None and isinstance(timestamp, str):
|
||||
signature = sign(timestamp, secret=secret.strip())
|
||||
with patch("time.time", return_value=now):
|
||||
return copied_verifier()(secret, timestamp, body, signature)
|
||||
|
||||
def test_shipped_copy_matches_the_executed_guide(self):
|
||||
self.assertEqual(DOC.read_bytes(),
|
||||
(ROOT / "frontend-modern/public/docs/WEBHOOKS.md").read_bytes())
|
||||
|
||||
def test_current_signed_payload_is_accepted(self):
|
||||
self.assertIs(self.check(), True)
|
||||
self.assertIs(self.check(secret=" " + SECRET + "\n"), True)
|
||||
|
||||
def test_five_minute_boundary_and_nearby_delivery_are_accepted(self):
|
||||
for delta in (-300, -1, 0, 1, 300):
|
||||
with self.subTest(delta=delta):
|
||||
self.assertIs(self.check(str(NOW + delta)), True)
|
||||
|
||||
def test_old_valid_signature_cannot_be_replayed_after_the_window(self):
|
||||
timestamp = str(NOW)
|
||||
self.assertIs(self.check(timestamp), True)
|
||||
for delta in (301, 3_600, 86_400):
|
||||
with self.subTest(delta=delta):
|
||||
self.assertIs(self.check(timestamp, now=NOW + delta), False)
|
||||
|
||||
def test_future_valid_signature_is_not_accepted_beyond_the_window(self):
|
||||
for delta in (301, 3_600, 86_400):
|
||||
with self.subTest(delta=delta):
|
||||
self.assertIs(self.check(str(NOW + delta)), False)
|
||||
|
||||
def test_signed_malformed_timestamps_are_rejected_without_an_exception(self):
|
||||
# These are valid MACs for invalid headers, not ordinary bad signatures.
|
||||
# Bound integer parsing and accept only the sender's ASCII seconds.
|
||||
values = ("", " " + str(NOW), str(NOW) + "\n", "+" + str(NOW),
|
||||
str(NOW) + ".0", "1e9", "١٧٩١٠٣٠٠٠٠", str(NOW) + "," + str(NOW),
|
||||
"9" * 5_000)
|
||||
for timestamp in values:
|
||||
with self.subTest(timestamp=timestamp[:30]):
|
||||
self.assertIs(self.check(timestamp), False)
|
||||
self.assertIs(self.check(None, signature=sign(str(NOW))), False)
|
||||
|
||||
def test_malformed_or_missing_signatures_fail_closed(self):
|
||||
good = sign(str(NOW))
|
||||
for signature in ("", "v2=" + good[3:], good.upper(), good + "\n",
|
||||
good + "," + good, "v1=" + "é" * 64, b"v1=" + b"a" * 64):
|
||||
with self.subTest(signature=str(signature)[:30]):
|
||||
self.assertIs(self.check(signature=signature), False)
|
||||
with patch("time.time", return_value=NOW):
|
||||
self.assertIs(copied_verifier()(SECRET, str(NOW), BODY, None), False)
|
||||
|
||||
def test_empty_or_whitespace_key_cannot_authenticate(self):
|
||||
for secret in ("", " \n\t"):
|
||||
with self.subTest(secret=repr(secret)):
|
||||
self.assertIs(self.check(secret=secret), False)
|
||||
|
||||
def test_body_timestamp_key_and_signature_are_all_authenticated(self):
|
||||
good = sign(str(NOW))
|
||||
cases = (
|
||||
{"body": BODY.replace(b"alert-42", b"alert-43")},
|
||||
{"timestamp": str(NOW + 1)},
|
||||
{"secret": SECRET + "-other"},
|
||||
{"signature": "v1=" + ("0" if good[3] != "0" else "1") + good[4:]},
|
||||
)
|
||||
for changed in cases:
|
||||
with self.subTest(changed=list(changed)):
|
||||
self.assertIs(self.check(**{"signature": good, **changed}), False)
|
||||
|
||||
def test_raw_bytes_not_reserialised_json_are_signed(self):
|
||||
body = '{"message":"héllo", "alertId":"alert-42", "event":"alert"}\r\n'.encode()
|
||||
signature = sign(str(NOW), body=body)
|
||||
self.assertIs(self.check(body=body, signature=signature), True)
|
||||
self.assertIs(self.check(body=body.rstrip(), signature=signature), False)
|
||||
self.assertIs(self.check(body=body.replace(b", ", b","), signature=signature), False)
|
||||
self.assertIs(self.check(body=body.decode(), signature=signature), False)
|
||||
|
||||
def test_go_sender_deterministic_vector_uses_the_same_wire_format(self):
|
||||
# The production sender's TestSignWebhookPayloadDeterministic uses
|
||||
# these exact key/timestamp/body bytes and an independent Go MAC.
|
||||
sender_test = (ROOT / "internal/notifications/webhook_signing_test.go").read_text()
|
||||
self.assertIn('signWebhookPayload("secret", "1700000000", []byte(`{"a":1}`))', sender_test)
|
||||
signature = "v1=" + hmac.new(b"secret", b'1700000000.{"a":1}', hashlib.sha256).hexdigest()
|
||||
self.assertIs(self.check("1700000000", body=b'{"a":1}', signature=signature,
|
||||
secret="secret", now=1_700_000_000), True)
|
||||
|
||||
def test_digest_comparison_remains_constant_time(self):
|
||||
verifier = copied_verifier()
|
||||
compare = hmac.compare_digest
|
||||
with patch("time.time", return_value=NOW), patch("hmac.compare_digest", wraps=compare) as spy:
|
||||
self.assertIs(verifier(SECRET, str(NOW), BODY, sign(str(NOW))), True)
|
||||
spy.assert_called_once_with(sign(str(NOW)), sign(str(NOW)))
|
||||
|
||||
def test_signed_retry_is_valid_but_not_a_new_logical_event(self):
|
||||
# Freshness alone is not at-most-once processing. A receiver needs
|
||||
# durable/atomic payload-derived deduplication, not a new MAC per retry.
|
||||
first, retry = sign(str(NOW)), sign(str(NOW + 60))
|
||||
self.assertNotEqual(first, retry)
|
||||
self.assertIs(self.check(signature=first), True)
|
||||
self.assertIs(self.check(str(NOW + 60), signature=retry, now=NOW + 60), True)
|
||||
# Changing the correlation header cannot change a timestamp/body MAC.
|
||||
# The docs must not claim that X-Pulse-Event-ID is authenticated.
|
||||
text = " ".join(DOC.read_text().split())
|
||||
for phrase in ("not every replay", "deduplicate atomically", "authenticated payload's",
|
||||
"receiver restarts", "different events", "not covered by this HMAC",
|
||||
"missing or duplicate signing headers", "before parsing the JSON",
|
||||
"not from the request", "Never log the secret"):
|
||||
self.assertIn(phrase, text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user