Sync from GitHub main: v0.1.1 + v0.1.2 + wiki sync (#3)
Sync Wiki / sync (push) Has been cancelled
CI / build (push) Has been cancelled

This commit was merged in pull request #3.
This commit is contained in:
2026-05-08 11:14:17 -04:00
parent fe42f2f908
commit b17d832842
34 changed files with 2109 additions and 146 deletions
+131
View File
@@ -0,0 +1,131 @@
# Network & security
This page covers what's exposed by Webhook Server, how to lock it down, and what's safe to change vs. leave alone.
## What's listening
By default the service binds Kestrel to **all interfaces on TCP 8080**. There are two endpoints relevant to outsiders:
- `GET|POST /hook/<slug>` — fires a configured endpoint
- `GET /healthz` — returns `{"ok": true}` for monitoring
- `GET /favicon.ico` — returns 204 to keep browser logs clean
Plus the admin named pipe `\\.\pipe\WebhookServerAdmin`, which is **only available locally** to processes running as SYSTEM or in the Administrators group.
## Reducing the network exposure
### Bind only to specific NICs
By default the server listens on every IP the host has — useful on a single-NIC desktop, dangerous on a multi-NIC server where one NIC faces the internet.
In the GUI: **Server → Settings → Network**. Untick "Listen on all interfaces" and tick the specific addresses you want. Save. The service restarts automatically and rebinds.
Common patterns:
- **Internal-only**: tick the LAN IP(s), leave loopback ticked too if anything on the box itself calls the hook
- **Loopback-only**: tick `127.0.0.1` and `::1`. Useful when a reverse proxy on the same host fronts the public traffic.
- **One specific IP for hooks**: tick a single IP that you've documented as the webhook endpoint. Helps when you have a multi-homed server and want clear network segmentation.
### Per-endpoint IP allowlist
Each endpoint has an **IP allowlist** field. Empty means anyone reachable can call it. Non-empty means deny-by-default — only the listed IPs / CIDRs are allowed:
```
192.168.1.0/24
10.42.0.5
fd00::/8
```
Mixing IPv4 and IPv6 entries is fine. The check runs **before authentication**, so a blocked IP gets a fast 403 without burning CPU on HMAC validation.
### Trusted proxies (X-Forwarded-For)
If the server sits behind a reverse proxy (nginx / IIS / Caddy / Cloudflare Tunnel), the inbound `RemoteIpAddress` will always be the proxy. To make the IP allowlist evaluate the original client instead, configure **Server → Settings → Trusted proxies** with the IP(s) of the proxy:
```
10.0.0.5
```
When the inbound connection comes from that IP and includes an `X-Forwarded-For` header, the leftmost entry of the header is treated as the effective client IP for the allowlist check.
If `Trusted proxies` is empty (default), `X-Forwarded-For` is **ignored entirely**. This is the safe default — it prevents anyone from spoofing their IP by adding the header themselves.
## Authentication options
| Mode | When to use | What the caller sends |
|---|---|---|
| **None** | Internal-only on a trusted LAN, or a hook that's safe to fire repeatedly with no side effects | Nothing |
| **Bearer** | Simple authentication. Pick a long random secret and treat it as a password. | `Authorization: Bearer <secret>` |
| **HMAC** | Anything where the body matters and you want tamper-evidence: GitHub webhooks, Stripe events, signed callbacks | A header (default `X-Hub-Signature-256`) containing `sha256=<hex digest>` of the request body keyed by your shared secret |
For **None**, lean hard on the IP allowlist — that's your only defense.
For **Bearer**, generate the secret with `[Convert]::ToBase64String((1..32 | %{ Get-Random -Maximum 256 }))` or any password manager. 32+ bytes of entropy. The token sits in `Authorization` headers; HTTPS is **strongly recommended** so it doesn't traverse the network in clear text.
For **HMAC**, the secret never traverses the network — only the digest does. This is what GitHub / Stripe / Slack use, and it's the right pick for inbound webhooks from internet-facing services. Configure the four fields to match the sender:
- **Algorithm**: usually SHA256
- **Header name**: e.g. `X-Hub-Signature-256` (GitHub), `X-Slack-Signature` (Slack), `Stripe-Signature` (Stripe — needs different format)
- **Prefix**: `sha256=` for GitHub-style, none for raw hex
- **Encoding**: hex (most senders) or base64 (some Slack-derived implementations)
## HTTPS
HTTP-only is fine for fully-internal use. For anything reachable beyond a trusted LAN, enable HTTPS.
In **Server → Settings → HTTPS**:
- **PFX file**: path to a `.pfx` and its password. Easiest if you got a cert from your internal CA or generated a self-signed one with `New-SelfSignedCertificate`.
- **Cert store thumbprint**: the SHA-1 thumbprint of a certificate already imported into `LocalMachine\My`. Best for production where IT manages the cert lifecycle (auto-renewal, revocation).
The **HTTPS port** defaults to 8443. Both HTTP and HTTPS can be active simultaneously — change `HTTP port` and `HTTPS port` independently.
After saving HTTPS settings the service restarts and rebinds. There is briefly a "Disconnected" state in the GUI while that happens (13 seconds).
### Using Let's Encrypt
The server doesn't speak ACME directly. Two practical options:
1. **Reverse proxy approach** — run nginx / Caddy / IIS in front of Webhook Server. The proxy handles Let's Encrypt; Webhook Server stays HTTP-only on loopback. Configure `Trusted proxies` so allowlists still work on the original client IP.
2. **External cert renewal** — use [`win-acme`](https://www.win-acme.com/) to obtain certs and place them in `LocalMachine\My`. Configure HTTPS by **thumbprint** in the GUI. When `win-acme` rotates the cert it produces a new thumbprint, so you'll need to update the GUI; or have a small scheduled task that calls the admin pipe to update the binding (advanced, undocumented for now).
## Secrets at rest
All secrets — bearer tokens, HMAC keys, PFX passwords, RunAs passwords — are encrypted in `config.json` using **DPAPI with the `LocalMachine` scope**:
- The same machine can decrypt them under any account (so changing the service account doesn't break secret access).
- Copying `config.json` to a different machine **doesn't carry the secrets** — DPAPI LocalMachine binds to the host's machine key. This is by design and protects against config exfiltration.
- The GUI displays decrypted secrets in plaintext for an admin user. This is intentional. Anyone who can connect to the admin pipe is already SYSTEM-equivalent on the host; pretending otherwise just makes secret recovery harder.
For backup-and-restore across machines, you'd need to either:
- Re-enter all secrets on the new host (use the **Export config → manual secret re-entry** flow)
- Bind a custom DPAPI scope (not currently supported — would require a v0.x feature request)
## The admin pipe
`\\.\pipe\WebhookServerAdmin` carries the GUI's commands to the service. Its security descriptor allows full control to:
- `NT AUTHORITY\SYSTEM`
- `BUILTIN\Administrators`
Everyone else gets denied at the OS level — there's no auth layer in the protocol itself because the ACL is the auth layer. UAC token splitting means a non-elevated process owned by an Admin user is **also denied** (because the user's standard token has Admins as deny-only). That's why the GUI exe is manifested with `requireAdministrator` — it auto-elevates so the pipe accepts the connection.
If you ever need to grant pipe access to another local group (e.g., a custom `WebhookOperators` group), edit `src/WebhookServer.Core/Ipc/PipeSecurityFactory.cs` and add an `AddAccessRule` for that group. Currently no GUI configures this.
## Threat model summary
What you're protected against, by default:
- **Random scanners hitting your hooks** — solved by IP allowlists (when configured), auth (when configured), and HTTPS (when configured)
- **Replay of inbound requests** — HMAC signs the body, so a captured request can't be modified, but it CAN be replayed. If that matters, include a timestamp in the body and reject old timestamps in your script.
- **Credential leaks** — secrets at rest are DPAPI-encrypted, machine-bound; they don't travel with `config.json`
- **Privilege escalation via the admin pipe** — pipe ACL excludes non-admins
- **Local user spoofing the source IP** — `X-Forwarded-For` is ignored unless you explicitly trust a proxy
What you're NOT protected against — these are out of scope for this server:
- Compromise of an admin account on the host (game over — they own everything)
- A malicious script you configured (you wrote it; the server just runs it)
- DoS via volume of requests — there's no rate limiting in v0.x
- Memory dump of the running service revealing decrypted secrets — DPAPI protects at-rest only