Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86224d4982 | ||
|
|
39afa69bfc | ||
|
|
888eaf526b |
@@ -7,6 +7,13 @@ services, durably stores them, and delivers them to configured targets
|
||||
with retry support, logging, and observability. Category: infrastructure
|
||||
/ 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
|
||||
|
||||
### Prerequisites
|
||||
@@ -85,7 +92,8 @@ them at once. A variable already present in the real environment wins
|
||||
over the file's value for the same name.
|
||||
|
||||
The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev`
|
||||
or `prod` (default: `dev`). The setting controls exactly one behavior:
|
||||
or `prod` (default: `prod`; `dev` must be set explicitly). The setting
|
||||
controls exactly one behavior:
|
||||
|
||||
| Behavior | `dev` | `prod` |
|
||||
| -------- | ----------------------- | ---------------- |
|
||||
@@ -127,7 +135,7 @@ TTY detection, and security headers are always applied.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ----------------------- | ----------------------------------- | -------- |
|
||||
| `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `dev` |
|
||||
| `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `prod` |
|
||||
| `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`) |
|
||||
| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` |
|
||||
@@ -393,9 +401,9 @@ the bucket is. See [Rate Limiting](#rate-limiting).
|
||||
The remedy is to set `TRUSTED_PROXIES` to your reverse proxy's
|
||||
address, which restores per-client buckets. webhooker logs a warning
|
||||
at startup whenever `TRUSTED_PROXIES` is empty, in every environment —
|
||||
not only when `WEBHOOKER_ENVIRONMENT=prod`, because that variable
|
||||
defaults to `dev` and an operator who never set it is precisely the
|
||||
one at risk. The warning is informational when nothing proxies to the
|
||||
the warning does not depend on `WEBHOOKER_ENVIRONMENT`, because an
|
||||
operator who never configured the deployment is precisely 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
|
||||
and the buckets are already per-client. See
|
||||
[Rate Limiting](#rate-limiting) for what each limit shares.
|
||||
@@ -747,15 +755,15 @@ reports.
|
||||
that.
|
||||
2. **Set `WEBHOOKER_ENVIRONMENT=prod`, and make sure the proxy sends
|
||||
`X-Forwarded-Proto`.** These are two requirements, not one. The
|
||||
environment setting decides CORS and nothing else: the default
|
||||
`dev` answers every origin with `Access-Control-Allow-Origin: *`
|
||||
(without credentials), which a server-rendered production
|
||||
deployment has no use for. Cookie `Secure` and the strict
|
||||
Origin/Referer mode are **not** tied to it — they are decided per
|
||||
request from the transport, which behind a proxy means the
|
||||
`X-Forwarded-Proto` header. The block below sets it; without it
|
||||
every request is read as plaintext and cookies ship without
|
||||
`Secure`. See [Configuration](#configuration).
|
||||
environment setting decides CORS and nothing else: `dev` answers
|
||||
every origin with `Access-Control-Allow-Origin: *` (without
|
||||
credentials), which a server-rendered production deployment has no
|
||||
use for, and `prod` — the default — disables it. Cookie `Secure`
|
||||
and the strict Origin/Referer mode are **not** tied to it — they
|
||||
are decided per request from the transport, which behind a proxy
|
||||
means the `X-Forwarded-Proto` header. The block below sets it;
|
||||
without it every request is read as plaintext and cookies ship
|
||||
without `Secure`. See [Configuration](#configuration).
|
||||
3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate
|
||||
limiter keys on the connecting peer, which behind a proxy is the
|
||||
proxy on every request: all clients collapse into one global bucket
|
||||
@@ -1149,14 +1157,38 @@ backups at rest and restrict who can read them.
|
||||
|
||||
## The entrypoint URL is the authentication secret
|
||||
|
||||
The receiver verifies nothing about an inbound request. The UUID in an
|
||||
entrypoint's URL is its credential: anyone who holds that URL can
|
||||
submit events to it, and the receiver checks nothing else about the
|
||||
sender. Treat an entrypoint URL the way you would treat an API token.
|
||||
**The entrypoint UUID is the credential, and it is the only one.**
|
||||
webhooker mints a version 4 UUID per entrypoint and serves it at
|
||||
`/webhook/{uuid}`. Possession of that URL is the authentication:
|
||||
anyone who holds it can submit events to the entrypoint, and the
|
||||
receiver verifies nothing else about the sender.
|
||||
|
||||
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.
|
||||
There is no shared secret, no HMAC signature, no bearer token and no
|
||||
second factor on the receiver, and none will be added. This was
|
||||
considered and rejected; the implementation that existed was removed
|
||||
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
|
||||
|
||||
@@ -2867,6 +2899,10 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
|
||||
### 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
|
||||
encrypted cookies. Sessions are configured with HttpOnly, SameSite
|
||||
Lax, and Secure whenever the request is on TLS — the flag follows the
|
||||
@@ -2906,7 +2942,8 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
mode
|
||||
- **The entrypoint URL is the receiver's only credential.** Nothing
|
||||
about an inbound request is verified; possession of the UUID
|
||||
authorises submission (see
|
||||
authorises submission, and no shared secret or signature check will
|
||||
be added alongside it (see
|
||||
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret))
|
||||
- **SSRF prevention** for HTTP delivery targets: private/reserved IP
|
||||
ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked
|
||||
|
||||
@@ -585,12 +585,14 @@ func resolveMetricsAuth() (string, string, error) {
|
||||
)
|
||||
}
|
||||
|
||||
// resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to
|
||||
// dev, and rejects unrecognised values.
|
||||
// resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to prod
|
||||
// when it is unset so a deployment that forgets the variable is not
|
||||
// silently permissive; dev must be set explicitly. It rejects
|
||||
// unrecognised values.
|
||||
func resolveEnvironment() (string, error) {
|
||||
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
|
||||
if environment == "" {
|
||||
environment = EnvironmentDev
|
||||
environment = EnvironmentProd
|
||||
}
|
||||
|
||||
if environment != EnvironmentDev &&
|
||||
@@ -772,10 +774,10 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
|
||||
// everyone else's wrong passwords, and the receiver's limits become
|
||||
// service-wide ceilings.
|
||||
//
|
||||
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT. That
|
||||
// variable defaults to dev, so gating on it would silence the warning
|
||||
// for exactly the operator who forgot to configure the deployment —
|
||||
// the case it exists to catch.
|
||||
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT: an
|
||||
// operator who never configured the deployment is exactly the case it
|
||||
// exists to catch, so the exposure it announces is independent of the
|
||||
// environment setting.
|
||||
//
|
||||
// The default of trusting nobody is deliberate — trusting forwarded
|
||||
// headers from arbitrary peers lets any client choose its own bucket —
|
||||
|
||||
@@ -44,9 +44,9 @@ func TestEnvironmentConfig(t *testing.T) {
|
||||
isProd bool
|
||||
}{
|
||||
{
|
||||
name: "default is dev",
|
||||
isDev: true,
|
||||
isProd: false,
|
||||
name: "default is prod",
|
||||
isDev: false,
|
||||
isProd: true,
|
||||
},
|
||||
{
|
||||
name: "explicit dev",
|
||||
@@ -848,10 +848,10 @@ func TestEgressAllowlistWarning(t *testing.T) {
|
||||
// tells an operator a deployment behind a reverse proxy shares one
|
||||
// rate-limit bucket between every client, which turns the receiver
|
||||
// limits into service-wide ceilings and collapses login failure
|
||||
// counting. It must fire whenever TRUSTED_PROXIES is empty,
|
||||
// in any environment: WEBHOOKER_ENVIRONMENT defaults to dev, so gating
|
||||
// on it would silence the warning for exactly the operator who never
|
||||
// configured the deployment. It stays quiet once proxies are named.
|
||||
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
|
||||
// environment: the warning does not depend on WEBHOOKER_ENVIRONMENT,
|
||||
// since an operator who never configured the deployment is exactly the
|
||||
// one it exists to catch. It stays quiet once proxies are named.
|
||||
func TestSharedRateLimitBucketWarning(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -871,10 +871,9 @@ func TestSharedRateLimitBucketWarning(t *testing.T) {
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
// The default environment. An internet-exposed
|
||||
// deployment whose operator never set
|
||||
// WEBHOOKER_ENVIRONMENT lands here and has exactly
|
||||
// the exposure the warning announces.
|
||||
// An internet-exposed deployment whose operator set
|
||||
// WEBHOOKER_ENVIRONMENT=dev has exactly the exposure
|
||||
// the warning announces.
|
||||
name: "dev without trusted proxies warns",
|
||||
environment: config.EnvironmentDev,
|
||||
expectWarning: true,
|
||||
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/banner"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/datadir"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
dataDirPerm = 0750
|
||||
randomPasswordLen = 16
|
||||
sessionKeyLen = 32
|
||||
)
|
||||
@@ -185,7 +185,9 @@ func (d *Database) connect() error {
|
||||
// caller's decision.
|
||||
func (d *Database) connectTo(dataDir string) error {
|
||||
// Ensure the data directory exists before opening the database.
|
||||
err := os.MkdirAll(dataDir, dataDirPerm)
|
||||
// datadir.DirPerm is the single source of the directory mode; this
|
||||
// package creates the directory too, since either may run first.
|
||||
err := os.MkdirAll(dataDir, datadir.DirPerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"creating data directory %s: %w",
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/datadir"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
@@ -53,8 +54,9 @@ func NewWebhookDBManager(
|
||||
log: params.Logger.Get(),
|
||||
}
|
||||
|
||||
// Create data directory if it doesn't exist
|
||||
err := os.MkdirAll(m.dataDir, dataDirPerm)
|
||||
// Create data directory if it doesn't exist. datadir.DirPerm is the
|
||||
// single source of the directory mode; either package may run first.
|
||||
err := os.MkdirAll(m.dataDir, datadir.DirPerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"creating data directory %s: %w",
|
||||
|
||||
@@ -29,9 +29,11 @@ import (
|
||||
// process that was killed with SIGKILL blocks nothing.
|
||||
const LockFileName = "webhooker.lock"
|
||||
|
||||
// dirPerm is the mode Acquire creates DATA_DIR with. It matches what
|
||||
// internal/database uses, since whichever runs first creates it.
|
||||
const dirPerm = 0o750
|
||||
// DirPerm is the mode DATA_DIR is created with. It is the single
|
||||
// source of that mode: internal/database consumes it rather than
|
||||
// keeping its own copy, so the two packages that both create the
|
||||
// directory cannot drift into disagreeing about its permissions.
|
||||
const DirPerm = 0o750
|
||||
|
||||
// ErrLocked reports that another live process holds the data
|
||||
// directory. Callers that need to know whether a deployment is running
|
||||
@@ -64,7 +66,7 @@ func Acquire(dir string) (*Lock, error) {
|
||||
return nil, ErrNoDir
|
||||
}
|
||||
|
||||
err := os.MkdirAll(dir, dirPerm)
|
||||
err := os.MkdirAll(dir, DirPerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"creating data directory %s: %w", dir, err,
|
||||
|
||||
@@ -170,7 +170,8 @@ func TestDelivery_CrossOriginRedirectDropsOriginScopedHeaders(
|
||||
// Stripping must not fire within the configured origin, or every
|
||||
// destination that redirects its own path would lose its
|
||||
// credential and start answering 401 — and would lose the inbound
|
||||
// signature the receiver verifies.
|
||||
// signature header the target endpoint verifies. webhooker's own
|
||||
// receiver verifies no signature; it only forwards the header.
|
||||
func TestDelivery_SameOriginRedirectKeepsOriginScopedHeaders(
|
||||
t *testing.T,
|
||||
) {
|
||||
|
||||
@@ -380,9 +380,8 @@ func csrfTookStrictPath(
|
||||
|
||||
// TestCSRF_ForwardedProtoSpellingsTakeStrictPath runs the header
|
||||
// spellings a real proxy emits through the middleware. The environment
|
||||
// is dev -- the DEFAULT when WEBHOOKER_ENVIRONMENT is unset -- to pin
|
||||
// that the routing is a per-request transport decision and owes
|
||||
// nothing to configuration.
|
||||
// is set to dev -- the permissive setting -- to pin that the routing is
|
||||
// a per-request transport decision and owes nothing to configuration.
|
||||
func TestCSRF_ForwardedProtoSpellingsTakeStrictPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user