1 Commits
Author SHA1 Message Date
sneak ebc8ce2463 Keep the entrypoint UUID out of webhooker's own logs (closes #531)
check / check (push) Canceled after 0s
On the receiver route the access log and the per-entrypoint 429 line
name the route pattern whatever the status. A request chi never routed,
such as a dev-mode CORS preflight to the receiver, logs (unmatched).
The access log keeps only the scheme and host of a Referer. The
received line names the entrypoint and webhook IDs, and the
unknown-entrypoint line logs no path. The pattern is
middleware.ReceiverRoute, which routes.go registers the receiver with.

The README says the proxy's access log holds the UUID and must be
protected like the URL, and that a log target writes the request's
headers as received. Its access-log, rate-limit, Sentry and log-size
passages now match the code.

Model: opus-5-5
2026-10-15 12:23:17 +00:00
3 changed files with 74 additions and 10 deletions
+17 -7
View File
@@ -803,9 +803,10 @@ comes up, serves pages, and is broken in a way nothing reports.
logs `$remote_addr`; do not replace it with one that drops the client
address, and retain those logs as long as you would want to answer a question
about traffic. That log also records the full request path, so it holds the
entrypoint UUID of every event a sender posts. webhooker's own log never
does. Protect the proxy's log as you would the entrypoint URLs themselves:
anyone who can read it can post events to every entrypoint in it (see
entrypoint UUID of every event a sender posts. webhooker's own log does not,
apart from the line a `log` target writes. Protect the proxy's log as you
would the entrypoint URLs themselves: anyone who can read it can post events
to every entrypoint in it (see
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)).
### nginx
@@ -1166,10 +1167,14 @@ 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. webhooker's own log lines never carry the UUID:
they name a request to the receiver by the route pattern `/h/{uuid}`, and the
`webhook request received` line names the entrypoint by its `entrypoint_id`.
The reverse proxy's access log does carry the UUID, in the request path, so it
post events as that sender. Apart from a `log` target's line, webhooker's own
log lines never carry the UUID: they name a request to the receiver by the
route pattern `/h/{uuid}`, the `webhook request received` line names the
entrypoint by its `entrypoint_id`, and the access log keeps only the scheme
and host of a `Referer`. A `log` target writes the request's headers as
received, so a `Referer` naming the entrypoint URL appears in its line, as it
does when a sender has followed a redirect from `http://` to `https://`. The
reverse proxy's access log does carry the UUID, in the request path, so it
must be protected like the URL itself (see
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
- **Rotating means minting a new entrypoint, not changing a key.** There is no
@@ -2470,6 +2475,11 @@ to, `notice`, which names the line a page shows after an action, and the event
log's `show`, which picks the events it lists. A query string sent to an
entrypoint is not lost either: the event stores it, and the event log shows it.
The `referer` field keeps only the scheme and host of the request's `Referer`,
and logs `(redacted)` for one without them, because its path can be an
entrypoint URL: a sender that follows the `http://` to `https://` redirect in
the nginx example sends the URL it was redirected from.
Client-supplied request content does not leave the host by the other route
either. The Sentry SDK attaches the request to every event it captures,
independently of the access log, and `SendDefaultPII=false` does not cover all
+31 -2
View File
@@ -347,11 +347,13 @@ func oversizedValue(ch string) string {
}
// oversizedHeaders fills every client-supplied header the access log
// reads with the same value.
// reads with the same value. The Referer carries it as the host of an
// absolute URL, because the access log keeps only a Referer's scheme
// and host.
func oversizedHeaders(value string) map[string]string {
return map[string]string{
"User-Agent": value,
"Referer": value,
"Referer": "http://" + value,
"X-Request-Id": value,
}
}
@@ -739,6 +741,33 @@ func TestAccessLog_ReceiverPreflightNeverLogsTheUUID(t *testing.T) {
assert.NotContains(t, buf.String(), knownEntrypointUUID)
}
// TestAccessLog_RefererNeverLogsTheUUID sends receiver requests whose
// Referer is the entrypoint URL, as a sender's is after it follows a
// redirect from http:// to https://. The referer field keeps only the
// scheme and host, and a Referer without them is logged as a marker.
func TestAccessLog_RefererNeverLogsTheUUID(t *testing.T) {
t.Parallel()
m, buf := capturingMiddleware(t)
router := accessLogRouter(m)
for _, referer := range []string{
"http://webhooker.example/h/" + knownEntrypointUUID + "?src=ci",
"/h/" + knownEntrypointUUID,
} {
assert.Equal(t, http.StatusOK, getWithHeaders(
t, router, "/h/"+knownEntrypointUUID,
map[string]string{"Referer": referer},
))
}
entries := accessLogEntries(t, buf)
require.Len(t, entries, 2)
assert.Equal(t, "http://webhooker.example", entries[0]["referer"])
assert.Equal(t, "(redacted)", entries[1]["referer"])
assert.NotContains(t, buf.String(), knownEntrypointUUID)
}
func TestAccessLog_ServerErrorKeepsConcreteURL(t *testing.T) {
t.Parallel()
+26 -1
View File
@@ -48,6 +48,10 @@ const (
// pick the size of the line it writes.
redactedQuery = "?(redacted)"
// redactedReferer stands in for a Referer the access log cannot
// reduce to its scheme and host; see accessLogReferer.
redactedReferer = "(redacted)"
// maxLogRequestIDBytes bounds the request id, which is also
// client-supplied: chi's RequestID middleware passes an inbound
// X-Request-Id header through verbatim. Its generated form is an
@@ -338,6 +342,27 @@ func accessLogURL(r *http.Request, status int) string {
return pattern
}
// accessLogReferer returns the value for the access log's referer
// field: the Referer's scheme and host, never its path or query. The
// path can be an entrypoint URL, which carries the UUID: a sender that
// follows a redirect from http:// to https:// sends the URL it was
// redirected from, and a browser sends the 404 page's URL under /h/
// when it fetches that page's stylesheet. A Referer without both a
// scheme and a host is logged as redactedReferer.
func accessLogReferer(r *http.Request) string {
referer := r.Referer()
if referer == "" {
return ""
}
parsed, err := url.Parse(referer)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return redactedReferer
}
return parsed.Scheme + "://" + parsed.Host
}
// Logging returns middleware that logs each HTTP request with
// timing and metadata.
func (s *Middleware) Logging() func(http.Handler) http.Handler {
@@ -388,7 +413,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
requestID, maxLogRequestIDBytes,
),
"referer", logfield.Truncate(
r.Referer(), logfield.MaxBytes,
accessLogReferer(r), logfield.MaxBytes,
),
"proto", r.Proto,
"remoteIP", RemoteIP(r),