Skip to content

How-to: Webhook Delivery System

FT reference: FT308 (NENE2-FT/webhookdeliverylog) — Webhook delivery system: SSRF protection via UrlValidator (HTTPS-only, private IP blocklist, CRLF injection prevention), HMAC-SHA256 signature with timestamp binding, secret stored as SHA-256 hash (never plaintext), secret not returned in GET responses, deactivated endpoints skip delivery, event type isolation, ATK-01〜12 all BLOCKED, 31 tests / 47 assertions PASS.

This guide shows how to build a webhook delivery system where webhook secrets are protected, URLs are validated against SSRF attacks, and payloads are signed with timestamps to prevent replay attacks.

Schema

sql
CREATE TABLE IF NOT EXISTS webhook_endpoints (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    url         TEXT    NOT NULL,
    event_type  TEXT    NOT NULL,
    secret_hash TEXT    NOT NULL,   -- SHA-256 hash of the raw secret
    max_retries INTEGER NOT NULL DEFAULT 3,
    active      INTEGER NOT NULL DEFAULT 1,
    created_at  TEXT    NOT NULL
);

CREATE TABLE IF NOT EXISTS webhook_deliveries (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    endpoint_id   INTEGER NOT NULL REFERENCES webhook_endpoints(id),
    event_type    TEXT    NOT NULL,
    payload       TEXT    NOT NULL DEFAULT '{}',
    status        TEXT    NOT NULL DEFAULT 'pending',
    attempt_count INTEGER NOT NULL DEFAULT 0,
    last_status   INTEGER,
    last_error    TEXT,
    delivered_at  TEXT,
    created_at    TEXT    NOT NULL,
    updated_at    TEXT    NOT NULL
);

secret_hash stores the SHA-256 hash of the raw secret — never the secret itself. active flag allows soft-disabling an endpoint without deleting delivery history.

SSRF Protection — UrlValidator

php
final class UrlValidator
{
    public function validate(string $url): ?string
    {
        // Block CRLF and null byte injection
        if (str_contains($url, "\n") || str_contains($url, "\r") || str_contains($url, "\0")) {
            return 'URL contains illegal control characters.';
        }

        $parsed = parse_url($url);
        if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) {
            return 'URL is not valid.';
        }

        // HTTPS only
        if (strtolower($parsed['scheme']) !== 'https') {
            return 'Only HTTPS URLs are allowed for webhook delivery.';
        }

        $host = strtolower($parsed['host']);

        // Block localhost and variants
        if (in_array($host, ['localhost', 'ip6-localhost', 'ip6-loopback'], true)) {
            return "Webhook URL must not target '{$host}'.";
        }

        // Block internal TLDs
        foreach (['.local', '.internal', '.test', '.example', '.invalid', '.localhost'] as $pattern) {
            if (str_ends_with($host, $pattern)) {
                return "Webhook URL must not target '{$pattern}' domains.";
            }
        }

        // Block private IPv4 ranges (127.x, 10.x, 172.16-31.x, 192.168.x)
        $ip = trim($host, '[]');
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
                return 'Webhook URL must not target private or loopback IP addresses.';
            }
        }

        // Block private IPv6 (::1, fc00::/7, fe80::/10)
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
            // ... IPv6 private range checks
        }

        return null; // valid
    }
}

Validation blocks:

  1. CRLF/null byte injection — prevents header injection in HTTP requests to the webhook URL
  2. Non-HTTPS schemeshttp://, file://, ftp://, gopher:// all blocked
  3. Loopback addresses127.0.0.0/8, ::1
  4. Private ranges10.x, 172.16-31.x, 192.168.x, 0.0.0.0
  5. Internal TLDs.local, .internal, .test, .example

Webhook Signing — HMAC-SHA256 + Timestamp

php
final class WebhookSigner
{
    public function sign(string $rawSecret, string $body, string $timestamp): string
    {
        $payload = $timestamp . '.' . $body;  // timestamp binds signature to time
        $mac     = hash_hmac('sha256', $payload, $rawSecret);
        return 'sha256=' . $mac;
    }

    public function hashSecret(string $rawSecret): string
    {
        return hash('sha256', $rawSecret);
    }
}

The signature format sha256=<hex> is the same pattern used by GitHub webhooks. The timestamp is included in the signed content (timestamp.body) — this prevents replay attacks: a signature captured at time T cannot be replayed at time T+1h.

Secret Storage — Hash, Never Plaintext

php
// On endpoint creation:
$secretHash = $this->signer->hashSecret($rawSecret);
$this->repo->createEndpoint($url, $eventType, $secretHash, $maxRetries);

// Return the raw secret ONCE to the caller:
return $this->json->create([
    'id'     => $endpointId,
    'secret' => $rawSecret,  // shown only at creation
    // stored as: secret_hash = SHA-256($rawSecret)
]);

The raw secret is returned to the caller only once at creation time. Subsequent GET /endpoints/{id} responses never include secret or secret_hash.

php
// GET endpoint response — secret NOT included
return $this->json->create([
    'id'         => (int) $endpoint['id'],
    'url'        => $endpoint['url'],
    'event_type' => $endpoint['event_type'],
    'active'     => (bool) $endpoint['active'],
    'max_retries'=> (int) $endpoint['max_retries'],
    'created_at' => $endpoint['created_at'],
    // 'secret_hash' intentionally omitted
]);

Deactivated Endpoint Skip

php
// Dispatch handler
if (!(bool) $endpoint['active']) {
    return $this->json->create(['message' => 'Endpoint is inactive, no delivery queued.'], 200);
}

Deactivated endpoints receive no new deliveries. This allows disabling a webhook without deleting the endpoint or its delivery history.

Event Type Isolation

Each endpoint subscribes to a specific event_type. When dispatching:

php
$endpoints = $this->repo->findActiveEndpointsByType($eventType);
// Only endpoints matching the event_type are delivered to

An endpoint subscribed to order.created does not receive order.cancelled events.


ATK Assessment — Cracker-Mindset Attack Test

ATK-01 — SSRF via Loopback IPv4 (127.x.x.x) 🚫 BLOCKED

Attack: Register endpoint with url: "https://127.0.0.1/admin". Result: BLOCKED — UrlValidator detects private IPv4 range → 422.


ATK-02 — SSRF via 0.0.0.0 🚫 BLOCKED

Attack: url: "https://0.0.0.0/internal". Result: BLOCKED — reserved IP range blocked by FILTER_FLAG_NO_RES_RANGE → 422.


ATK-03 — SSRF via Private Range 10.x.x.x 🚫 BLOCKED

Attack: url: "https://10.0.0.1/internal". Result: BLOCKED — private IPv4 range → 422.


ATK-04 — SSRF via Private Range 172.16-31.x.x 🚫 BLOCKED

Attack: url: "https://172.16.0.1/internal". Result: BLOCKED — private IPv4 range → 422.


ATK-05 — HTTP Scheme Downgrade 🚫 BLOCKED

Attack: url: "http://example.com/hook" (non-HTTPS). Result: BLOCKED — scheme check: only https allowed → 422.


ATK-06 — file:// Scheme 🚫 BLOCKED

Attack: url: "file:///etc/passwd". Result: BLOCKED — scheme check blocks non-HTTPS → 422.


ATK-07 — CRLF Injection in URL 🚫 BLOCKED

Attack: url: "https://example.com/\r\nX-Injected: header". Result: BLOCKED — str_contains($url, "\r") check → 422.


ATK-08 — Null Byte in URL 🚫 BLOCKED

Attack: url: "https://example.com/\0hidden". Result: BLOCKED — str_contains($url, "\0") check → 422.


ATK-09 — Secret Leak via GET Endpoint 🚫 BLOCKED

Attack: GET /endpoints/{id} to retrieve the stored secret. Result: BLOCKED — GET response omits secret and secret_hash fields entirely.


ATK-10 — Secret Leak via Dispatch Response 🚫 BLOCKED

Attack: Inspect dispatch response body for secret material. Result: BLOCKED — dispatch response contains only delivery metadata, no secret fields.


ATK-11 — Replay Attack (Captured Signature) 🚫 BLOCKED

Attack: Capture a signed webhook and replay it with the same signature later. Result: BLOCKED — signature is HMAC(timestamp.body, secret). Timestamp changes per delivery; old signature doesn't match new timestamp.


ATK-12 — Forged Signature with Wrong Secret 🚫 BLOCKED

Attack: Compute HMAC with a guessed/different secret, submit as valid signature. Result: BLOCKED — receiver validates with stored secret hash; forged HMAC doesn't match.


ATK Summary

IDAttackResult
ATK-01SSRF loopback IPv4🚫 BLOCKED
ATK-02SSRF 0.0.0.0🚫 BLOCKED
ATK-03SSRF private 10.x🚫 BLOCKED
ATK-04SSRF private 172.16-31.x🚫 BLOCKED
ATK-05HTTP scheme downgrade🚫 BLOCKED
ATK-06file:// scheme🚫 BLOCKED
ATK-07CRLF injection in URL🚫 BLOCKED
ATK-08Null byte in URL🚫 BLOCKED
ATK-09Secret leak via GET🚫 BLOCKED
ATK-10Secret leak via dispatch🚫 BLOCKED
ATK-11Replay attack🚫 BLOCKED
ATK-12Forged signature🚫 BLOCKED

12 BLOCKED, 0 EXPOSED UrlValidator blocks all SSRF vectors. Timestamp-bound HMAC prevents replays. Secret stored as hash, never returned after creation.


What NOT to do

Anti-patternRisk
Store raw webhook secret in DBDB breach exposes all secrets; SHA-256 hash is one-way
Return secret in GET responseAny admin API leak exposes all webhook secrets
HMAC over body only (no timestamp)Replay attack: captured signature reused indefinitely
Allow http:// webhook URLsTraffic eavesdropping on webhook payloads
No SSRF validation on URLWebhook system used to probe internal network
Allow 127.x, 10.x in webhook URLServer makes requests to its own internal services
No CRLF checkURL with \r\n injects headers into outbound HTTP request
Deliver to inactive endpointsDeactivated endpoints continue to receive traffic
No event type filteringAll event types delivered to all endpoints

Released under the MIT License.