Squashes the work that landed on GitHub via PRs #2 (v0.1.1), #3 (v0.1.2), and #4 (wiki sync) into a single commit on Gitea so both remotes converge. Content is identical to github/main; commit history is split for branching reasons (Gitea was merged via PR #1 long ago, GitHub used squash merges from then on, so the SHAs diverged). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# Recipe: GitHub-style HMAC-signed webhook
|
||||
|
||||
GitHub, Stripe, Slack, Shopify, and most SaaS providers sign their outbound webhooks with HMAC. The receiver computes the same HMAC over the request body using a shared secret and rejects the request if the signatures don't match. Webhook Server has this built in — you just point a real GitHub webhook at your endpoint.
|
||||
|
||||
## What we're building
|
||||
|
||||
A webhook URL that GitHub calls on every push to a repo. The server runs a PowerShell script that pulls the latest commit and triggers a deployment. Authentication is HMAC-SHA256 over the request body, using the secret you configured in GitHub's webhook settings.
|
||||
|
||||
## On the GitHub side
|
||||
|
||||
In your repo: **Settings → Webhooks → Add webhook**.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Payload URL | `https://hooks.contoso.com/hook/gh-deploy` (yes, HTTPS — GitHub enforces it for public hosts) |
|
||||
| Content type | `application/json` |
|
||||
| Secret | Generate a long random string. Copy it for the next step. |
|
||||
| SSL verification | Enable |
|
||||
| Events | Just `push` |
|
||||
|
||||
Save. GitHub immediately delivers a `ping` event for testing. You'll see it in **Recent Deliveries** with whatever response code your server returns.
|
||||
|
||||
## The PowerShell deployment script
|
||||
|
||||
`C:\Scripts\gh-deploy.ps1`:
|
||||
|
||||
```powershell
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$payload = $input | ConvertFrom-Json
|
||||
|
||||
# Verify the event type via the X-GitHub-Event header passed as an env var
|
||||
$event = $env:WEBHOOK_HEADER_X_GITHUB_EVENT
|
||||
if ($event -eq 'ping') {
|
||||
"got ping from $($payload.repository.full_name)"
|
||||
return
|
||||
}
|
||||
if ($event -ne 'push') {
|
||||
Write-Error "ignoring $event event"
|
||||
}
|
||||
|
||||
$repo = $payload.repository.full_name
|
||||
$branch = $payload.ref -replace '^refs/heads/', ''
|
||||
$sha = $payload.after
|
||||
|
||||
if ($branch -ne 'main') {
|
||||
"ignoring push to $branch"
|
||||
return
|
||||
}
|
||||
|
||||
$repoDir = "C:\Deploys\$($payload.repository.name)"
|
||||
if (-not (Test-Path $repoDir)) {
|
||||
git clone "https://github.com/$repo.git" $repoDir
|
||||
}
|
||||
|
||||
Push-Location $repoDir
|
||||
try {
|
||||
git fetch --all
|
||||
git reset --hard $sha
|
||||
# ...your build/deploy steps here...
|
||||
& npm ci
|
||||
& npm run build
|
||||
Restart-Service MyAppService
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
"deployed $repo @ $sha"
|
||||
```
|
||||
|
||||
## Configure the endpoint
|
||||
|
||||
**File → New endpoint**:
|
||||
|
||||
| Section | Setting | Value |
|
||||
|---|---|---|
|
||||
| Identity | Slug | `gh-deploy` |
|
||||
| Auth | Mode | **HMAC** |
|
||||
| Auth | HMAC secret | paste the GitHub-side secret |
|
||||
| Auth | HMAC header | `X-Hub-Signature-256` *(GitHub's default)* |
|
||||
| Allowed clients | | `140.82.112.0/20`, `192.30.252.0/22` *(GitHub's webhook IP ranges; check [docs.github.com](https://api.github.com/meta) for the live list)* |
|
||||
| Executor | Type | **Windows PowerShell** |
|
||||
| Executor | Script path | `C:\Scripts\gh-deploy.ps1` |
|
||||
| Data passing | JSON body to stdin | ✓ |
|
||||
| Data passing | Headers/query as env vars | ✓ *(needed so `WEBHOOK_HEADER_X_GITHUB_EVENT` is set)* |
|
||||
| Run as | Identity | **Service** (default) — assumes the deployment is local |
|
||||
| Response | Mode | **Async** *(GitHub times out fast; don't make it wait for the build)* |
|
||||
| Response | Timeout (sec) | `600` |
|
||||
|
||||
Save.
|
||||
|
||||
## What HMAC does for you here
|
||||
|
||||
GitHub computes `sha256(body, secret)` and sends it as `sha256=<hex>` in `X-Hub-Signature-256`. Webhook Server computes the same hash, verifies in fixed time, and rejects (401) on mismatch.
|
||||
|
||||
This means:
|
||||
|
||||
- A request with a tampered body fails the check
|
||||
- A captured request can be **replayed verbatim** (the signature is valid for that body) — if that matters, GitHub also includes a `X-GitHub-Delivery` ID and timestamp you can deduplicate against
|
||||
- The secret never travels over the network — only the digest does, so HTTPS is for confidentiality of the body, not the secret
|
||||
|
||||
## Adapting for Stripe, Slack, etc.
|
||||
|
||||
Same pattern, different headers and signing details. The four HMAC fields in the editor cover all common variants:
|
||||
|
||||
| Provider | Header | Prefix | Encoding | Algorithm |
|
||||
|---|---|---|---|---|
|
||||
| GitHub | `X-Hub-Signature-256` | `sha256=` | hex | SHA-256 |
|
||||
| Stripe | `Stripe-Signature` | (none — but Stripe's format is multipart, see below) | hex | SHA-256 |
|
||||
| Slack | `X-Slack-Signature` | `v0=` | hex | SHA-256 |
|
||||
| Generic / custom | configurable | configurable | configurable | SHA-1 / SHA-256 / SHA-512 |
|
||||
|
||||
**Stripe** is special: their `Stripe-Signature` header has the format `t=<timestamp>,v1=<sig>,v0=<sig>`, where `v1` is HMAC-SHA256 of `<timestamp>.<body>`. Webhook Server's straight HMAC check doesn't match Stripe's signed-with-timestamp scheme. Workarounds:
|
||||
|
||||
- Use **Bearer auth** on Stripe webhooks instead, since you already control the secret
|
||||
- Or do unauthenticated + IP allowlist + a script-side signature check using their official validation library
|
||||
|
||||
For everything that's "GitHub-shaped" (signed body, raw HMAC), the built-in HMAC mode is the right pick.
|
||||
Reference in New Issue
Block a user