Author SHA1 Message Date
sneak 251cb3d3d3 1.0.0b1 - testing remains
check / check (push) Successful in 9s
Reviewed-on: #111
2026-08-30 04:04:59 +02:00
8 changed files with 52 additions and 96 deletions
+22 -59
View File
@@ -7,13 +7,6 @@ services, durably stores them, and delivers them to configured targets
with retry support, logging, and observability. Category: infrastructure with retry support, logging, and observability. Category: infrastructure
/ web service. License: MIT. / web service. License: MIT.
Each entrypoint is a version 4 UUID served at `/webhook/{uuid}`, and
that UUID is the entrypoint's only credential. webhooker does not use
shared secrets, HMAC signatures or token headers on the receiver, and
will not add them — read
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)
before deploying one.
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
@@ -92,8 +85,7 @@ them at once. A variable already present in the real environment wins
over the file's value for the same name. over the file's value for the same name.
The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev` The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev`
or `prod` (default: `prod`; `dev` must be set explicitly). The setting or `prod` (default: `dev`). The setting controls exactly one behavior:
controls exactly one behavior:
| Behavior | `dev` | `prod` | | Behavior | `dev` | `prod` |
| -------- | ----------------------- | ---------------- | | -------- | ----------------------- | ---------------- |
@@ -135,7 +127,7 @@ TTY detection, and security headers are always applied.
| Variable | Description | Default | | Variable | Description | Default |
| ----------------------- | ----------------------------------- | -------- | | ----------------------- | ----------------------------------- | -------- |
| `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `prod` | | `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `dev` |
| `PORT` | HTTP listen port | `8080` | | `PORT` | HTTP listen port | `8080` |
| `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) | | `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) |
| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` | | `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` |
@@ -401,9 +393,9 @@ the bucket is. See [Rate Limiting](#rate-limiting).
The remedy is to set `TRUSTED_PROXIES` to your reverse proxy's The remedy is to set `TRUSTED_PROXIES` to your reverse proxy's
address, which restores per-client buckets. webhooker logs a warning address, which restores per-client buckets. webhooker logs a warning
at startup whenever `TRUSTED_PROXIES` is empty, in every environment — at startup whenever `TRUSTED_PROXIES` is empty, in every environment —
the warning does not depend on `WEBHOOKER_ENVIRONMENT`, because an not only when `WEBHOOKER_ENVIRONMENT=prod`, because that variable
operator who never configured the deployment is precisely the one at defaults to `dev` and an operator who never set it is precisely the
risk. The warning is informational when nothing proxies to the one at risk. The warning is informational when nothing proxies to the
process: with no proxy in front, the peer address is the client's own process: with no proxy in front, the peer address is the client's own
and the buckets are already per-client. See and the buckets are already per-client. See
[Rate Limiting](#rate-limiting) for what each limit shares. [Rate Limiting](#rate-limiting) for what each limit shares.
@@ -755,15 +747,15 @@ reports.
that. that.
2. **Set `WEBHOOKER_ENVIRONMENT=prod`, and make sure the proxy sends 2. **Set `WEBHOOKER_ENVIRONMENT=prod`, and make sure the proxy sends
`X-Forwarded-Proto`.** These are two requirements, not one. The `X-Forwarded-Proto`.** These are two requirements, not one. The
environment setting decides CORS and nothing else: `dev` answers environment setting decides CORS and nothing else: the default
every origin with `Access-Control-Allow-Origin: *` (without `dev` answers every origin with `Access-Control-Allow-Origin: *`
credentials), which a server-rendered production deployment has no (without credentials), which a server-rendered production
use for, and `prod` — the default — disables it. Cookie `Secure` deployment has no use for. Cookie `Secure` and the strict
and the strict Origin/Referer mode are **not** tied to it — they Origin/Referer mode are **not** tied to it — they are decided per
are decided per request from the transport, which behind a proxy request from the transport, which behind a proxy means the
means the `X-Forwarded-Proto` header. The block below sets it; `X-Forwarded-Proto` header. The block below sets it; without it
without it every request is read as plaintext and cookies ship every request is read as plaintext and cookies ship without
without `Secure`. See [Configuration](#configuration). `Secure`. See [Configuration](#configuration).
3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate 3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate
limiter keys on the connecting peer, which behind a proxy is the limiter keys on the connecting peer, which behind a proxy is the
proxy on every request: all clients collapse into one global bucket proxy on every request: all clients collapse into one global bucket
@@ -1157,38 +1149,14 @@ backups at rest and restrict who can read them.
## The entrypoint URL is the authentication secret ## The entrypoint URL is the authentication secret
**The entrypoint UUID is the credential, and it is the only one.** The receiver verifies nothing about an inbound request. The UUID in an
webhooker mints a version 4 UUID per entrypoint and serves it at entrypoint's URL is its credential: anyone who holds that URL can
`/webhook/{uuid}`. Possession of that URL is the authentication: submit events to it, and the receiver checks nothing else about the
anyone who holds it can submit events to the entrypoint, and the sender. Treat an entrypoint URL the way you would treat an API token.
receiver verifies nothing else about the sender.
There is no shared secret, no HMAC signature, no bearer token and no There is no way to rotate the UUID in place. To retire one, delete the
second factor on the receiver, and none will be added. This was entrypoint (or deactivate it, which answers `410`) and create a new
considered and rejected; the implementation that existed was removed one, then point the sender at the new URL.
in [PR #279](https://git.eeqj.de/sneak/webhooker/pulls/279), closing
[issue #67](https://git.eeqj.de/sneak/webhooker/issues/67) and
[issue #241](https://git.eeqj.de/sneak/webhooker/issues/241). A
proposal to reintroduce any of them — including as "defence in depth"
alongside the UUID — is answered by this section. Inbound signature
headers a sender sends anyway (`X-Hub-Signature` and its
per-provider equivalents) are stored and forwarded as ordinary
headers; nothing checks them.
What that means for an operator:
- **The URL is a capability, so treat it as a secret.** Keep it out of
logs, ticket bodies, chat messages and screenshots. Anyone who reads
it anywhere can post events as that sender.
- **Rotating means minting a new entrypoint, not changing a key.**
There is no way to rotate the UUID in place. To retire one, delete
the entrypoint (or deactivate it, which answers `410`) and create a
new one, then point the sender at the new URL.
- **A sender that cannot be given a secret URL is a constraint on that
integration, not a reason to change this.** If a service only
supports signed payloads to a well-known URL, raise it as its own
problem — pick a different integration path, or accept that it
cannot be used. It is not grounds to reintroduce shared secrets.
## Entrypoints ## Entrypoints
@@ -2899,10 +2867,6 @@ check, see [The login endpoint](#the-login-endpoint).
### Authentication ### Authentication
- **Webhook receiver:** the entrypoint UUID in the URL, and nothing
else. No shared secret, no HMAC signature, no token header, and none
will be added — see
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret).
- **Web UI:** Cookie-based sessions using gorilla/sessions with - **Web UI:** Cookie-based sessions using gorilla/sessions with
encrypted cookies. Sessions are configured with HttpOnly, SameSite encrypted cookies. Sessions are configured with HttpOnly, SameSite
Lax, and Secure whenever the request is on TLS — the flag follows the Lax, and Secure whenever the request is on TLS — the flag follows the
@@ -2942,8 +2906,7 @@ check, see [The login endpoint](#the-login-endpoint).
mode mode
- **The entrypoint URL is the receiver's only credential.** Nothing - **The entrypoint URL is the receiver's only credential.** Nothing
about an inbound request is verified; possession of the UUID about an inbound request is verified; possession of the UUID
authorises submission, and no shared secret or signature check will authorises submission (see
be added alongside it (see
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)) [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret))
- **SSRF prevention** for HTTP delivery targets: private/reserved IP - **SSRF prevention** for HTTP delivery targets: private/reserved IP
ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked
+7 -9
View File
@@ -585,14 +585,12 @@ func resolveMetricsAuth() (string, string, error) {
) )
} }
// resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to prod // resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to
// when it is unset so a deployment that forgets the variable is not // dev, and rejects unrecognised values.
// silently permissive; dev must be set explicitly. It rejects
// unrecognised values.
func resolveEnvironment() (string, error) { func resolveEnvironment() (string, error) {
environment := os.Getenv("WEBHOOKER_ENVIRONMENT") environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
if environment == "" { if environment == "" {
environment = EnvironmentProd environment = EnvironmentDev
} }
if environment != EnvironmentDev && if environment != EnvironmentDev &&
@@ -774,10 +772,10 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
// everyone else's wrong passwords, and the receiver's limits become // everyone else's wrong passwords, and the receiver's limits become
// service-wide ceilings. // service-wide ceilings.
// //
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT: an // The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT. That
// operator who never configured the deployment is exactly the case it // variable defaults to dev, so gating on it would silence the warning
// exists to catch, so the exposure it announces is independent of the // for exactly the operator who forgot to configure the deployment —
// environment setting. // the case it exists to catch.
// //
// The default of trusting nobody is deliberate — trusting forwarded // The default of trusting nobody is deliberate — trusting forwarded
// headers from arbitrary peers lets any client choose its own bucket — // headers from arbitrary peers lets any client choose its own bucket —
+11 -10
View File
@@ -44,9 +44,9 @@ func TestEnvironmentConfig(t *testing.T) {
isProd bool isProd bool
}{ }{
{ {
name: "default is prod", name: "default is dev",
isDev: false, isDev: true,
isProd: true, isProd: false,
}, },
{ {
name: "explicit dev", name: "explicit dev",
@@ -848,10 +848,10 @@ func TestEgressAllowlistWarning(t *testing.T) {
// tells an operator a deployment behind a reverse proxy shares one // tells an operator a deployment behind a reverse proxy shares one
// rate-limit bucket between every client, which turns the receiver // rate-limit bucket between every client, which turns the receiver
// limits into service-wide ceilings and collapses login failure // limits into service-wide ceilings and collapses login failure
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any // counting. It must fire whenever TRUSTED_PROXIES is empty,
// environment: the warning does not depend on WEBHOOKER_ENVIRONMENT, // in any environment: WEBHOOKER_ENVIRONMENT defaults to dev, so gating
// since an operator who never configured the deployment is exactly the // on it would silence the warning for exactly the operator who never
// one it exists to catch. It stays quiet once proxies are named. // configured the deployment. It stays quiet once proxies are named.
func TestSharedRateLimitBucketWarning(t *testing.T) { func TestSharedRateLimitBucketWarning(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -871,9 +871,10 @@ func TestSharedRateLimitBucketWarning(t *testing.T) {
expectWarning: false, expectWarning: false,
}, },
{ {
// An internet-exposed deployment whose operator set // The default environment. An internet-exposed
// WEBHOOKER_ENVIRONMENT=dev has exactly the exposure // deployment whose operator never set
// the warning announces. // WEBHOOKER_ENVIRONMENT lands here and has exactly
// the exposure the warning announces.
name: "dev without trusted proxies warns", name: "dev without trusted proxies warns",
environment: config.EnvironmentDev, environment: config.EnvironmentDev,
expectWarning: true, expectWarning: true,
+2 -4
View File
@@ -17,12 +17,12 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/banner" "sneak.berlin/go/webhooker/internal/banner"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
const ( const (
dataDirPerm = 0750
randomPasswordLen = 16 randomPasswordLen = 16
sessionKeyLen = 32 sessionKeyLen = 32
) )
@@ -185,9 +185,7 @@ func (d *Database) connect() error {
// caller's decision. // caller's decision.
func (d *Database) connectTo(dataDir string) error { func (d *Database) connectTo(dataDir string) error {
// Ensure the data directory exists before opening the database. // Ensure the data directory exists before opening the database.
// datadir.DirPerm is the single source of the directory mode; this err := os.MkdirAll(dataDir, dataDirPerm)
// package creates the directory too, since either may run first.
err := os.MkdirAll(dataDir, datadir.DirPerm)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"creating data directory %s: %w", "creating data directory %s: %w",
+2 -4
View File
@@ -13,7 +13,6 @@ import (
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
@@ -54,9 +53,8 @@ func NewWebhookDBManager(
log: params.Logger.Get(), log: params.Logger.Get(),
} }
// Create data directory if it doesn't exist. datadir.DirPerm is the // Create data directory if it doesn't exist
// single source of the directory mode; either package may run first. err := os.MkdirAll(m.dataDir, dataDirPerm)
err := os.MkdirAll(m.dataDir, datadir.DirPerm)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"creating data directory %s: %w", "creating data directory %s: %w",
+4 -6
View File
@@ -29,11 +29,9 @@ import (
// process that was killed with SIGKILL blocks nothing. // process that was killed with SIGKILL blocks nothing.
const LockFileName = "webhooker.lock" const LockFileName = "webhooker.lock"
// DirPerm is the mode DATA_DIR is created with. It is the single // dirPerm is the mode Acquire creates DATA_DIR with. It matches what
// source of that mode: internal/database consumes it rather than // internal/database uses, since whichever runs first creates it.
// keeping its own copy, so the two packages that both create the const dirPerm = 0o750
// directory cannot drift into disagreeing about its permissions.
const DirPerm = 0o750
// ErrLocked reports that another live process holds the data // ErrLocked reports that another live process holds the data
// directory. Callers that need to know whether a deployment is running // directory. Callers that need to know whether a deployment is running
@@ -66,7 +64,7 @@ func Acquire(dir string) (*Lock, error) {
return nil, ErrNoDir return nil, ErrNoDir
} }
err := os.MkdirAll(dir, DirPerm) err := os.MkdirAll(dir, dirPerm)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"creating data directory %s: %w", dir, err, "creating data directory %s: %w", dir, err,
+1 -2
View File
@@ -170,8 +170,7 @@ func TestDelivery_CrossOriginRedirectDropsOriginScopedHeaders(
// Stripping must not fire within the configured origin, or every // Stripping must not fire within the configured origin, or every
// destination that redirects its own path would lose its // destination that redirects its own path would lose its
// credential and start answering 401 — and would lose the inbound // credential and start answering 401 — and would lose the inbound
// signature header the target endpoint verifies. webhooker's own // signature the receiver verifies.
// receiver verifies no signature; it only forwards the header.
func TestDelivery_SameOriginRedirectKeepsOriginScopedHeaders( func TestDelivery_SameOriginRedirectKeepsOriginScopedHeaders(
t *testing.T, t *testing.T,
) { ) {
+3 -2
View File
@@ -380,8 +380,9 @@ func csrfTookStrictPath(
// TestCSRF_ForwardedProtoSpellingsTakeStrictPath runs the header // TestCSRF_ForwardedProtoSpellingsTakeStrictPath runs the header
// spellings a real proxy emits through the middleware. The environment // spellings a real proxy emits through the middleware. The environment
// is set to dev -- the permissive setting -- to pin that the routing is // is dev -- the DEFAULT when WEBHOOKER_ENVIRONMENT is unset -- to pin
// a per-request transport decision and owes nothing to configuration. // that the routing is a per-request transport decision and owes
// nothing to configuration.
func TestCSRF_ForwardedProtoSpellingsTakeStrictPath(t *testing.T) { func TestCSRF_ForwardedProtoSpellingsTakeStrictPath(t *testing.T) {
t.Parallel() t.Parallel()