6 Commits

Author SHA1 Message Date
clawbot
c4022c0834 Correct release-blocking documentation inaccuracies (closes #141)
All checks were successful
check / check (push) Successful in 3m12s
Four defects found by the integration review, each of which would have
made the README or the release notes untrue at the moment of tagging.

RETENTION_SWEEP_INTERVAL was absent from the README env table while two
other passages referred to it as documented. Enumerated every variable
read by internal/config from the source (12 in total) rather than by
eye; that was the only one missing.

TODO.md omitted five of the units landed in this milestone (#64, #79,
#90, #113, #118), two of them credential-exposure fixes, which are
precisely the entries a reader of the release notes wants to find. The
list is now derived from git log origin/main..origin/next.

The README sold Replay in the present tense as a core capability while
no redelivery code exists anywhere in the tree, and the roadmap entry
for it had been dropped without it being implemented. Every mention of
replay or redelivery in the README is now either marked planned or
already under a Planned heading: the Rationale item, the Use Cases
bullet, the Event model's field description, and the delivery-semantics
passage on target-type edits, which told an operator that a terminally
failed delivery was recoverable by hand when nothing can recover it.
The comment on failUnretryableRetry made the same claim and is
corrected with it. The roadmap entry is back in TODO.md.

The production JS asset shipped a console.log on load. The rest of the
file and every other shipped asset were checked; that was the only one
(alpine.min.js is vendored and untouched).

No behavioural change: the only non-comment, non-documentation edit is
the deleted console.log.
2026-08-12 11:01:29 +00:00
be576096aa Make the CI gate execute the checks it reports on (closes #119)
All checks were successful
check / check (push) Successful in 2m52s
The workflow writes a build-context fingerprint before calling script/cibuild, so a code commit invalidates the COPY layer of the lint and builder stages and the checks really run, while a docs-only commit still replays from cache. A superseding run also rewrites the exact failure/Has been cancelled status left on commits that were never tested to skipped, so cancellation no longer reads as red. script/cibuild itself is untouched.
2026-08-12 13:00:51 +02:00
3941f0b0ff Require a positive RETENTION_SWEEP_INTERVAL (closes #140)
All checks were successful
check / check (push) Successful in 6s
A non-positive value reached time.NewTicker in the retention reaper and the archive sweeper, panicking both goroutines after startup had already reported success. envPositiveDuration now rejects it in loadFromEnv, matching how PORT and RECEIVER_RATE_LIMIT fail. SESSION_IDLE_TIMEOUT keeps treating non-positive as disabled, which is guarded at every use site.
2026-08-12 12:46:39 +02:00
543005c0c2 Update TODO.md for the completed 1.0.0 milestone
All checks were successful
check / check (push) Successful in 5s
Records the trusted-proxy gating, hop cap and bounded scan, and corrects the Workflow section, which still described branching from main and committing TODO.md alongside the work.
2026-08-12 12:20:34 +02:00
9bfd033a29 Bound X-Forwarded-For scanning allocation to the hop cap (closes #133)
All checks were successful
check / check (push) Superseded by a newer commit; never tested
forwardedClientAddr now walks the header values in reverse with strings.LastIndexByte instead of joining and splitting, so allocation is bounded by the 64-hop cap rather than by header length: 1.6 MB per call becomes 16 bytes for a 1 MB chain. Semantics are unchanged, verified by differential testing against the previous implementation.
2026-08-12 12:19:14 +02:00
fd6397154a Cap the X-Forwarded-For hop walk at 64 entries (closes #124)
All checks were successful
check / check (push) Successful in 7s
The walk now keeps only the rightmost 64 hops, so an attacker-supplied chain cannot burn unbounded CPU in the rate-limit key function. Running off the end of the truncated slice falls back to the peer address, the same fail-closed direction the rest of the function takes. Also corrects the unparseable-RemoteAddr comment, which overclaimed about Unix-socket peers.
2026-08-12 11:53:48 +02:00
13 changed files with 415 additions and 49 deletions

View File

@@ -1,3 +1,6 @@
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
# that keeps the check stages from replaying a cached pass. See the lint
# stage of the Dockerfile.
.git/ .git/
bin/ bin/
*.md *.md

View File

@@ -11,5 +11,53 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
with:
# The fingerprint step below needs history to find the last commit
# that touched the Docker build context.
fetch-depth: 0
- name: Neutralize superseded run statuses
# Gitea cancels the in-flight run when another commit is pushed to the
# same branch and records the cancellation as `failure`, so a commit
# that was never tested reads red. The cancellation is unconditional
# server-side for push events and cannot be disabled from a workflow
# file, so the superseding run rewrites those statuses to `skipped`.
# Only the exact cancellation status is touched; a real failure is
# left alone.
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
ctx='check / check (push)'
for sha in $(git rev-list --max-count=20 "${GITHUB_SHA}^" || true); do
latest="$(curl -sf "${api}/commits/${sha}/status" | jq -r \
--arg c "$ctx" \
'[.statuses[] | select(.context == $c)][0] // empty
| "\(.status)|\(.description)"')" || continue
[ "$latest" = 'failure|Has been cancelled' ] || continue
curl -sf -X POST "${api}/statuses/${sha}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg c "$ctx" '{
context: $c,
state: "skipped",
description: "Superseded by a newer commit; never tested"
}')" >/dev/null
echo "neutralized superseded status on ${sha}"
done
- name: Fingerprint the build context
# `.dockerignore` keeps docs out of the build context, so a docs-only
# commit legitimately replays the whole image from cache and stays
# cheap. Every other commit writes a new fingerprint into the context,
# which invalidates the `COPY . .` layer of both check stages: a
# commit that was never linted, formatted-checked, tested and built
# cannot report success from cache.
run: |
set -eu
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
- name: Build Docker image (runs make check) - name: Build Docker image (runs make check)
run: script/cibuild run: script/cibuild

3
.gitignore vendored
View File

@@ -42,3 +42,6 @@ data/
# Temporary files # Temporary files
tmp/ tmp/
temp/ temp/
# CI cache barrier, written into the build context by the check workflow
.ci-fingerprint

View File

@@ -12,7 +12,11 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source code # Copy source code. In CI the context also carries .ci-fingerprint, whose
# value changes with every commit that touches the build context (see
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
# below cannot report success by replaying a cached pass. Do not add it to
# .dockerignore.
COPY . . COPY . .
# Run formatting check and linter # Run formatting check and linter
@@ -36,7 +40,8 @@ WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source code # Copy source code, including the .ci-fingerprint cache barrier described in
# the lint stage above.
COPY . . COPY . .
# Run tests and build # Run tests and build

View File

@@ -93,6 +93,7 @@ TTY detection, and security headers are always applied.
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` | | `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` | | `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
| `SENTRY_DSN` | Sentry error reporting DSN | `""` | | `SENTRY_DSN` | Sentry error reporting DSN | `""` |
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` | | `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint | `120` | | `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint | `120` |
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted | `""` (none) | | `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted | `""` (none) |
@@ -173,8 +174,12 @@ its value and refuses to start, rather than silently running with a
substituted default. `PORT=eighty`, `DEBUG=ture`, and substituted default. `PORT=eighty`, `DEBUG=ture`, and
`RETENTION_SWEEP_INTERVAL=1 hour` all abort startup. `PORT` must `RETENTION_SWEEP_INTERVAL=1 hour` all abort startup. `PORT` must
additionally be a number in the range 165535, additionally be a number in the range 165535,
`RECEIVER_RATE_LIMIT` must be at least 1, and every entry in `RECEIVER_RATE_LIMIT` must be at least 1,
`TRUSTED_PROXIES` must be a CIDR block or a bare IP address. `RETENTION_SWEEP_INTERVAL` must be greater than zero (it is a ticker
period, so `0s` or a negative value would crash the reaper after
startup), and every entry in `TRUSTED_PROXIES` must be a CIDR block or
a bare IP address. `SESSION_IDLE_TIMEOUT` is the exception: a
non-positive value there means idle expiry is disabled, not invalid.
Boolean variables (`DEBUG`, `MAINTENANCE_MODE`) accept exactly the Boolean variables (`DEBUG`, `MAINTENANCE_MODE`) accept exactly the
spellings Go's `strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`, spellings Go's `strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`,
@@ -260,9 +265,10 @@ webhooker solves this by acting as a durable intermediary:
targets simultaneously. This enables patterns like forwarding a targets simultaneously. This enables patterns like forwarding a
GitHub webhook to both a deployment service and a Slack channel. GitHub webhook to both a deployment service and a Slack channel.
5. **Replay** — Stored events can be manually redelivered for debugging 5. **Replay** (not yet implemented) — Every received event is stored in
or testing, without requiring the original sender to fire the webhook full, which is what manual redelivery for debugging or testing will
again. be built on. No redelivery exists today, in the web UI or the API;
see [TODO.md](TODO.md).
### Use Cases ### Use Cases
@@ -272,6 +278,7 @@ webhooker solves this by acting as a durable intermediary:
size, and delivery performance size, and delivery performance
- **Debugging** and introspection of webhook payloads in the web UI - **Debugging** and introspection of webhook payloads in the web UI
- **Replay** of webhook events for application testing and development - **Replay** of webhook events for application testing and development
(planned; not yet implemented)
- **Fan-out** delivery of a single webhook to multiple downstream - **Fan-out** delivery of a single webhook to multiple downstream
targets targets
- **High-availability ingestion** for delivery to less reliable backend - **High-availability ingestion** for delivery to less reliable backend
@@ -497,7 +504,7 @@ A programmatic access credential for API authentication.
#### Event #### Event
A captured incoming webhook request. Stores the complete HTTP request A captured incoming webhook request. Stores the complete HTTP request
data for replay and auditing. data for auditing and for the planned replay capability.
| Field | Type | Description | | Field | Type | Description |
| -------------- | ------ | ----------- | | -------------- | ------ | ----------- |
@@ -779,9 +786,10 @@ unknown) one while one of its deliveries is still `retrying`, both
recovery paths above terminally mark that delivery `failed` and record a recovery paths above terminally mark that delivery `failed` and record a
`DeliveryResult` naming the current target type as the reason, logging it `DeliveryResult` naming the current target type as the reason, logging it
at warn level. The delivery is not re-dispatched under the new type — the at warn level. The delivery is not re-dispatched under the new type — the
operator never asked for that delivery — and the event itself remains operator never asked for that delivery — and while the event itself
stored in the per-webhook event database, so it can be redelivered remains stored in the per-webhook event database, there is no way to
manually. redeliver it: manual redelivery is planned, not implemented (see
[TODO.md](TODO.md)).
### Circuit Breaker (HTTP Targets with Retries) ### Circuit Breaker (HTTP Targets with Retries)
@@ -1104,6 +1112,34 @@ binary is statically linked and runs on Alpine.
`docker build .` is the CI gate — if it passes, the code is formatted, `docker build .` is the CI gate — if it passes, the code is formatted,
linted, tested, and compiled. linted, tested, and compiled.
#### CI gate honesty
A layer cache lets `docker build .` exit 0 in seconds with the lint and
test stages replayed rather than executed, which would make a green
check meaningless. The `check` workflow therefore writes
`.ci-fingerprint` into the build context before building. Its value is
the hash of the last commit that touched the build context, so:
- Any commit that changes code (including a squash merge whose tree
matches an already-built branch) gets a new fingerprint, invalidates
the `COPY . .` layer of both check stages, and really runs
`make fmt-check`, `make lint`, `make test`, and `make build`. A run
that reports success ran them.
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
excludes `*.md` and `LICENSE` from the context anyway — so the image
replays from cache and costs seconds.
The module download layer sits above `COPY . .` and stays cached either
way.
The workflow's first step covers a second way the gate lied: Gitea
cancels an in-flight run when a newer commit lands on the same branch
and records that cancellation as a `failure` status, marking a commit
red that was never tested. Cancellation is unconditional server-side for
push events, so the superseding run rewrites the exact
`Has been cancelled` status to `skipped`. Genuine failures are never
touched.
## TODO ## TODO
See [TODO.md](TODO.md). See [TODO.md](TODO.md).

90
TODO.md
View File

@@ -1,35 +1,94 @@
# Workflow # Workflow
* branch (from `main`) One issue per unit of work, one branch and one PR per issue:
* do the work in Next Step
* move Next Step to the top of Completed Steps * ensure a tracked issue exists with a definition of done
* move the top item of Future Steps into Next Step * branch from `next` (never from `main`)
* commit (`TODO.md` changes in the same commit as the work) * do the work; open a PR based on `next` (never on `main`)
* merge to `main` if the branch is not protected, otherwise open a PR * pass an independent review, then the manager squash-merges into `next`
* push * push; nothing stays local-only
`next` is the branch for the next milestone and must stay green and
mergeable to `main` without notice. One `next` -> `main` PR accumulates
the milestone; releases are cut from `main` separately.
Issue branches do NOT touch this file — the manager maintains it on
`next`. Every branch editing `TODO.md` conflicts with every other
(#112).
# Status # Status
pre-1.0. No git tags exist. main (4f5ecb1) is a working webhook proxy pre-1.0. No git tags exist. `main` (4f5ecb1) is a working webhook proxy
with auth, CSRF/SSRF protections, login rate limiting, Slack target, with auth, CSRF/SSRF protections, login rate limiting, Slack target,
event retention (#63), the database archiving target (#43), the admin event retention (#63), the database archiving target (#43), the admin
password change flow (#65), policy compliance (#6), pinned lint tooling password change flow (#65), policy compliance (#6), pinned lint tooling
(#55), and fail-loud configuration parsing (#80). Note: TODO.md was (#55), and fail-loud configuration parsing (#80).
deliberately deleted from this repo in f9a9569 (2026-03-01, #6); its
content was folded into the README TODO section, which this draft `next` (9bfd033) holds the completed 1.0.0 milestone: every issue in it
reconstructs as of 2026-07-06. is closed, and it is verified green by cache-defeated container runs
rather than by the CI badge, which can pass without executing anything
(#119). Note: TODO.md was deliberately deleted from this repo in f9a9569
(2026-03-01, #6); its content was folded into the README TODO section,
which this draft reconstructs as of 2026-07-06.
# Next Step # Next Step
Manual event redelivery from the web UI (replay is a core promised Tag 1.0.0 from `main` once the milestone PR merges, then repair the CI
capability in the README rationale). gate (#119) before the next cycle's work lands — a gate that can report
success without running is the one thing every other guarantee here
rests on.
# Completed Steps # Completed Steps
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop
cap: the reverse walk cuts entries with `strings.LastIndexByte`
instead of joining and splitting, so a 1 MB header allocates 16 bytes
rather than 1.6 MB per request on the unauthenticated receiver.
Semantics proven unchanged by differential testing against the
previous implementation (#133)
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
attacker-supplied chain cannot burn unbounded CPU in the rate-limit
key function; running off the end falls back to the peer address
(#124)
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR
list: all three rate limiters key on the connection's own address
unless the direct peer is a configured proxy, in which case
`X-Forwarded-For` is walked right to left for the first non-proxy hop.
Default trusts nothing, and a set-but-unparseable value aborts
startup. Before this, any client could mint a fresh bucket or drain
another's by rotating a spoofed header (#88)
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the - 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the
Profile settings placeholder removed, a progressive-enhancement copy Profile settings placeholder removed, a progressive-enhancement copy
button for the entrypoint URL, and retention form copy that states the button for the entrypoint URL, and retention form copy that states the
actual policy (deletion by the reaper, 0 retains forever) (#57) actual policy (deletion by the reaper, 0 retains forever) (#57)
- 2026-08-11 Mask the webhook credential in delivery errors and logs:
Go embeds the request URL in `*url.Error`, so every transport failure
persisted the full Slack webhook URL into the per-webhook event
database via `DeliveryResult.Error`, a field a future REST API would
have served. `maskURLError` drops path, query and userinfo while
preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()`
still work and DNS, TLS and timeout failures still read differently
(#118)
- 2026-08-11 Rate-limit the public webhook receiver endpoint
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus
entrypoint path so one entrypoint cannot exhaust another's budget;
over-limit requests get 429 with `Retry-After`. It was the one
unauthenticated, internet-facing endpoint with no limit at all (#64)
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
`MaxBodySize` is now first in all four form-parsing route groups, so
an oversized request is rejected with 413 instead of being read in
full by the CSRF middleware before any cap applied (#90)
- 2026-08-11 Mask target config on the source detail page, which
rendered the stored blob verbatim and so exposed the Slack
incoming-webhook URL — a bearer credential that cannot be revoked
per-holder. Config reaches the template only as a `TargetView` of
labelled fields, and header values are rendered as a count (#113)
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a
sentinel written in `BeforeSave` so the GORM column default cannot
win the race. Also bounds the reaper's cutoff arithmetic: day counts
above 106751 overflowed `time.Duration` and wrapped the cutoff into
the future, where every row matched and the sweep deleted everything
(#79)
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry - 2026-08-09 Inactivity-based session timeout: sliding idle expiry
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated (`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
requests, with the 7-day absolute cap kept as an independent requests, with the 7-day absolute cap kept as an independent
@@ -84,6 +143,9 @@ capability in the README rationale).
# Future Steps # Future Steps
- Manual event redelivery from the web UI — the "Replay" capability the
README describes as planned. No redelivery code exists anywhere in the
tree; events are stored in full, which is all it would be built on
- Delivery status and retry management UI - Delivery status and retry management UI
- Per-webhook rate limiting in the receiver handler (per-webhook config - Per-webhook rate limiting in the receiver handler (per-webhook config
plus handler enforcement; global limits must not apply to receiver plus handler enforcement; global limits must not apply to receiver

View File

@@ -92,6 +92,7 @@ type Config struct {
SentryDSN string SentryDSN string
// RetentionSweepInterval is how often the retention reaper runs. // RetentionSweepInterval is how often the retention reaper runs.
// Always positive: it becomes a time.NewTicker period.
RetentionSweepInterval time.Duration RetentionSweepInterval time.Duration
// SessionIdleTimeout is the sliding inactivity window after // SessionIdleTimeout is the sliding inactivity window after
@@ -235,6 +236,34 @@ func envDuration(
return d, nil return d, nil
} }
// envPositiveDuration returns the value of the named environment
// variable parsed as a Go duration that must be greater than zero.
// Returns defaultValue if not set. A set value that is unparseable or
// non-positive is a hard error naming the key and the bad value.
//
// This is for durations that reach time.NewTicker, which panics on a
// non-positive period, in a goroutine started after startup has
// already reported success. It is deliberately not used for durations
// where non-positive means "disabled" (SESSION_IDLE_TIMEOUT).
func envPositiveDuration(
key string,
defaultValue time.Duration,
) (time.Duration, error) {
d, err := envDuration(key, defaultValue)
if err != nil {
return 0, err
}
if d <= 0 {
return 0, fmt.Errorf(
"%w: %s must be greater than zero, got %s",
ErrNonPositiveValue, key, d,
)
}
return d, nil
}
// parseCIDR parses one trusted-proxy list entry, which may be a // parseCIDR parses one trusted-proxy list entry, which may be a
// CIDR block ("10.0.0.0/8") or a bare address ("10.0.0.1", treated // CIDR block ("10.0.0.0/8") or a bare address ("10.0.0.1", treated
// as a single-host block). // as a single-host block).
@@ -346,7 +375,7 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
retentionSweepInterval, err := envDuration( retentionSweepInterval, err := envPositiveDuration(
"RETENTION_SWEEP_INTERVAL", "RETENTION_SWEEP_INTERVAL",
defaultRetentionSweepInterval, defaultRetentionSweepInterval,
) )
@@ -354,6 +383,8 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
// Non-positive is "disabled" here, not invalid, so this stays on
// envDuration.
sessionIdleTimeout, err := envDuration( sessionIdleTimeout, err := envDuration(
"SESSION_IDLE_TIMEOUT", "SESSION_IDLE_TIMEOUT",
defaultSessionIdleTimeout, defaultSessionIdleTimeout,

View File

@@ -139,7 +139,11 @@ func TestRetentionSweepInterval(t *testing.T) {
set bool set bool
value string value string
expectError bool expectError bool
expected time.Duration // sentinel, when set, must be wrapped by the startup
// error; every error case must additionally name the
// variable in its message.
sentinel error
expected time.Duration
}{ }{
{ {
name: caseUnsetUsesDefault, name: caseUnsetUsesDefault,
@@ -158,6 +162,24 @@ func TestRetentionSweepInterval(t *testing.T) {
value: "not-a-duration", value: "not-a-duration",
expectError: true, expectError: true,
}, },
{
// A non-positive period panics the ticker in the
// reaper and archive-sweeper goroutines, long after
// startup has reported success, so it has to fail
// here instead.
name: "zero fails startup",
set: true,
value: "0s",
expectError: true,
sentinel: config.ErrNonPositiveValue,
},
{
name: "negative fails startup",
set: true,
value: "-1h",
expectError: true,
sentinel: config.ErrNonPositiveValue,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -175,7 +197,9 @@ func TestRetentionSweepInterval(t *testing.T) {
} }
if tt.expectError { if tt.expectError {
expectStartupError(t) expectStartupErrorFor(
t, "RETENTION_SWEEP_INTERVAL", tt.sentinel,
)
} else { } else {
testRetentionSweepIntervalSuccess(t, tt.expected) testRetentionSweepIntervalSuccess(t, tt.expected)
} }
@@ -281,6 +305,22 @@ func TestSessionIdleTimeout(t *testing.T) {
value: "not-a-duration", value: "not-a-duration",
expectError: true, expectError: true,
}, },
{
// Non-positive is "idle expiry disabled" for this
// variable, not a configuration error: unlike
// RETENTION_SWEEP_INTERVAL it never becomes a ticker
// period.
name: "zero disables idle expiry",
set: true,
value: "0s",
expected: 0,
},
{
name: "negative disables idle expiry",
set: true,
value: "-1h",
expected: -time.Hour,
},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@@ -785,9 +785,9 @@ func (e *Engine) sweepSingleRetry(
// status retrying themselves. Re-dispatching under the new type // status retrying themselves. Re-dispatching under the new type
// would be a delivery the operator never asked for, and leaving // would be a delivery the operator never asked for, and leaving
// the row retrying strands it forever, so the delivery is // the row retrying strands it forever, so the delivery is
// failed with a recorded reason and can be redelivered // failed with a recorded reason. The event stays stored, but
// manually. Logged at warn, not error: this is operator-caused // nothing redelivers it today. Logged at warn, not error: this
// state, not a system fault. // is operator-caused state, not a system fault.
func (e *Engine) failUnretryableRetry( func (e *Engine) failUnretryableRetry(
webhookDB *gorm.DB, webhookDB *gorm.DB,
webhookID string, webhookID string,

View File

@@ -25,6 +25,11 @@ func IPFromHostPort(hp string) string {
return ipFromHostPort(hp) return ipFromHostPort(hp)
} }
// ClientKeyForTest exposes clientKey for testing.
func ClientKeyForTest(m *Middleware, r *http.Request) string {
return m.clientKey(r)
}
// IsClientTLS exposes isClientTLS for testing. // IsClientTLS exposes isClientTLS for testing.
func IsClientTLS(r *http.Request) bool { func IsClientTLS(r *http.Request) bool {
return isClientTLS(r) return isClientTLS(r)

View File

@@ -32,6 +32,13 @@ const (
// receiver rate limit. The configured limit is expressed in // receiver rate limit. The configured limit is expressed in
// requests per minute. // requests per minute.
receiverRateInterval = 1 * time.Minute receiverRateInterval = 1 * time.Minute
// maxForwardedHops bounds how many X-Forwarded-For entries the
// chain walk examines. Real chains are one to three hops, but a
// client can pad the header up to MaxHeaderBytes, so without a
// bound every request pays a walk proportional to whatever the
// client sent.
maxForwardedHops = 64
) )
// normalizeAddr strips the IPv4-in-IPv6 wrapper and any zone from // normalizeAddr strips the IPv4-in-IPv6 wrapper and any zone from
@@ -70,26 +77,49 @@ func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
// a trusted proxy is the client. A hop that cannot be read as a bare // a trusted proxy is the client. A hop that cannot be read as a bare
// address ends the walk: past it the chain is not the shape assumed // address ends the walk: past it the chain is not the shape assumed
// here, so the caller falls back to the peer address. // here, so the caller falls back to the peer address.
//
// Only the last maxForwardedHops entries are examined. A longer chain
// is padding, and running out of hops falls back to the peer address
// the same way an unreadable hop does.
//
// The entries are cut off the right end of each header value in place
// rather than split out of it: the receiver is unauthenticated and a
// client can pad the header up to MaxHeaderBytes, so splitting would
// allocate in proportion to the padding (about 8 MB for a 1 MB
// header) before the cap could discard any of it. Multiple header
// values are walked in reverse for the same reason, since joining
// them copies the whole chain.
func (m *Middleware) forwardedClientAddr( func (m *Middleware) forwardedClientAddr(
r *http.Request, r *http.Request,
) (netip.Addr, bool) { ) (netip.Addr, bool) {
hops := strings.Split( seen := 0
strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",",
)
for _, hop := range slices.Backward(hops) { for _, value := range slices.Backward(
hop = strings.TrimSpace(hop) r.Header.Values("X-Forwarded-For"),
if hop == "" { ) {
continue for last := false; !last && seen < maxForwardedHops; seen++ {
} hop := value
addr, err := netip.ParseAddr(hop) comma := strings.LastIndexByte(value, ',')
if err != nil { if comma < 0 {
return netip.Addr{}, false last = true
} } else {
hop, value = value[comma+1:], value[:comma]
}
if addr = normalizeAddr(addr); !m.isTrustedProxy(addr) { hop = strings.TrimSpace(hop)
return addr, true if hop == "" {
continue
}
addr, err := netip.ParseAddr(hop)
if err != nil {
return netip.Addr{}, false
}
if addr = normalizeAddr(addr); !m.isTrustedProxy(addr) {
return addr, true
}
} }
} }
@@ -113,8 +143,10 @@ func (m *Middleware) clientKey(r *http.Request) string {
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr)) peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
if err != nil { if err != nil {
// Not an address we can reason about; key on the raw // Not an address we can reason about; key on the raw
// value rather than collapsing such peers into one // value, the most specific identity left. On a
// shared bucket. // Unix-socket listener every peer carries the same
// RemoteAddr and so shares one bucket, which is the
// fail-closed direction.
return r.RemoteAddr return r.RemoteAddr
} }

View File

@@ -8,7 +8,10 @@ import (
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"os" "os"
"runtime"
"strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
@@ -568,6 +571,105 @@ func TestRateLimitKey_ChainWalkSkipsClientPrepended(t *testing.T) {
) )
} }
// TestRateLimitKey_LongChainCapsWalkAndFallsBackToPeer covers the
// hop-walk cap. A client behind the trusted proxy can pad
// X-Forwarded-For with tens of thousands of trusted-looking hops,
// which costs a walk proportional to the padding and, once the walk
// runs off the left end of the chain, reaches the entry the client
// put there. Capping the walk stops both: the key falls back to the
// peer address, so rotating the head of the chain mints no bucket,
// and the run does not scale with the chain length.
func TestRateLimitKey_LongChainCapsWalkAndFallsBackToPeer(
t *testing.T,
) {
t.Parallel()
// 50k hops is roughly 0.9 MB, within the default
// MaxHeaderBytes.
const hops = 50000
padding := strings.Repeat(", 10.0.0.2", hops-1)
start := time.Now()
assertSharedBucket(
t, trustedProxies("10.0.0.0/8"), "10.0.0.1:44444",
func(i int) map[string]string {
return map[string]string{
headerXFF: fmt.Sprintf("9.9.9.%d%s", i+1, padding),
}
},
"a padded X-Forwarded-For chain must fall back to the "+
"peer address, not reach the client-controlled entry "+
"at the head of the chain",
)
assert.Less(
t, time.Since(start), 2*time.Second,
"the capped walk must not scale with the chain length",
)
}
// TestRateLimitKey_LongChainAllocationIsBounded is the allocation
// half of the hop cap. Capping the walk still left every request
// paying for the whole header the client sent, because the chain was
// split before it was capped: about 8 MB of []string for the 1 MB a
// default MaxHeaderBytes allows, on the unauthenticated receiver.
//
// Bytes are the measurement, not allocation count: strings.Split of a
// 1 MB chain is a single allocation, so testing.AllocsPerRun scores
// it as cheap. The test is deliberately sequential — it reads
// process-wide counters, and Go runs this package's parallel tests
// only after the sequential ones finish.
//
//nolint:paralleltest // reads process-wide allocation counters
func TestRateLimitKey_LongChainAllocationIsBounded(t *testing.T) {
// 100k hops of ", 10.0.0.2" is roughly 1 MB.
const (
hops = 100000
iterations = 50
maxBytesPerCall = 4096
)
m := rateLimitMiddleware(t, &config.Config{
TrustedProxies: trustedProxies("10.0.0.0/8"),
})
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, loginPath, nil,
)
req.RemoteAddr = "10.0.0.1:44444"
req.Header.Set(
headerXFF, "9.9.9.9"+strings.Repeat(", 10.0.0.2", hops),
)
var before, after runtime.MemStats
var key string
runtime.ReadMemStats(&before)
for range iterations {
key = middleware.ClientKeyForTest(m, req)
}
runtime.ReadMemStats(&after)
perCall := (after.TotalAlloc - before.TotalAlloc) / iterations
assert.Less(
t, perCall, uint64(maxBytesPerCall),
"a %d-byte X-Forwarded-For must not allocate in proportion "+
"to its length, but cost %d bytes per call",
len(req.Header.Get(headerXFF)), perCall,
)
assert.Equal(
t, "10.0.0.1", key,
"the padded chain must still fall back to the peer address",
)
}
// TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer proves // TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer proves
// the receiver limiter uses the same gated key function as the // the receiver limiter uses the same gated key function as the
// POST limiters. // POST limiters.

View File

@@ -1,5 +1,4 @@
// Webhooker client-side JavaScript // Webhooker client-side JavaScript
console.log("Webhooker loaded");
// Copy-to-clipboard, as progressive enhancement. // Copy-to-clipboard, as progressive enhancement.
// //