Render delivery attempt detail in the event log (closes #202)
All checks were successful
check / check (push) Successful in 4m16s
All checks were successful
check / check (push) Successful in 4m16s
Expanding a delivery on the event log page now shows each recorded attempt: attempt number, outcome, status code, duration, error and response body. Previously a failure rendered as "target: failed" and diagnosing it meant opening the per-webhook SQLite file by hand. The response body is cut by SQLite via substr over a blob cast, the same projection the event body uses, so an oversized stored response never becomes a Go string. The page reports the cut with a marker. Response bodies and errors are remote content, so both go through a new delivery.Redactor that strips the target's own destination URL, path, query and userinfo, plus the values of credential-shaped request headers, before rendering. A cut body goes through RedactCut as well, which drops any tail that is a proper prefix of a secret: the remote chooses the padding in front of a credential it echoes, so it chooses where the cut falls inside that credential. Target configuration keeps reaching the template only as a TargetView. Redactors are built from an unscoped target load. Deleting a target only soft deletes the row while its deliveries survive, and a scoped load would leave exactly those deliveries rendering unredacted. The views the page lists stay scoped. Attempt loading is chunked so the IN clause cannot exceed SQLite's bound-parameter limit, its error is reported rather than discarded, and the page renders at most 20 attempts per delivery, counting what it leaves out. static/css/tailwind.css is regenerated with the repo's pinned tailwindcss for the utility classes the new markup uses.
This commit is contained in:
247
internal/delivery/target_redact.go
Normal file
247
internal/delivery/target_redact.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// RedactionMarker stands in for a target credential found in
|
||||
// text the target's remote peer chose.
|
||||
const RedactionMarker = "(redacted)"
|
||||
|
||||
// Redactor removes one target's own credential material from
|
||||
// text that target's remote peer chose: a delivery response
|
||||
// body, or a delivery error stored before the delivery path
|
||||
// learned to mask the URLs it embeds.
|
||||
//
|
||||
// It removes byte-identical echoes of strings taken from the
|
||||
// target's stored configuration, and nothing else. Anything
|
||||
// the remote re-encodes survives: JSON "\/" escaping (what
|
||||
// PHP's json_encode emits by default), percent-encoding, HTML
|
||||
// entities, and an echo of only part of a path. It cannot
|
||||
// remove a secret the remote invented.
|
||||
//
|
||||
// The zero Redactor removes nothing, which is what a caller
|
||||
// holding no target for a delivery gets.
|
||||
type Redactor struct {
|
||||
secrets []string
|
||||
}
|
||||
|
||||
// NewRedactor builds the redactor for one target.
|
||||
func NewRedactor(t *database.Target) Redactor {
|
||||
secrets := targetSecrets(t)
|
||||
|
||||
// Longest first, so replacing a secret that is contained
|
||||
// in a longer one cannot leave a fragment of the longer
|
||||
// one behind. Configured headers arrive in map order, so
|
||||
// the sort is also what makes the result deterministic.
|
||||
slices.SortFunc(secrets, func(a, b string) int {
|
||||
if d := len(b) - len(a); d != 0 {
|
||||
return d
|
||||
}
|
||||
|
||||
return strings.Compare(a, b)
|
||||
})
|
||||
|
||||
return Redactor{secrets: secrets}
|
||||
}
|
||||
|
||||
// Redact replaces every occurrence of the target's credential
|
||||
// material in s.
|
||||
func (r Redactor) Redact(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
for _, secret := range r.secrets {
|
||||
s = strings.ReplaceAll(s, secret, RedactionMarker)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// RedactCut redacts s, which its caller has already cut to a
|
||||
// byte budget, and additionally drops any tail of s that is a
|
||||
// proper prefix of a secret.
|
||||
//
|
||||
// The cut lands wherever the remote's padding puts it, so the
|
||||
// remote chooses where inside the credential it falls. The
|
||||
// severed prefix left behind equals no secret, so plain
|
||||
// Redact would render it verbatim.
|
||||
func (r Redactor) RedactCut(s string) string {
|
||||
s = r.Redact(s)
|
||||
|
||||
if n := r.secretPrefixSuffix(s); n > 0 {
|
||||
return s[:len(s)-n] + RedactionMarker
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// secretPrefixSuffix returns the length of the longest suffix
|
||||
// of s that is a proper prefix of one of the secrets, or 0
|
||||
// when there is none.
|
||||
func (r Redactor) secretPrefixSuffix(s string) int {
|
||||
longest := 0
|
||||
|
||||
for _, secret := range r.secrets {
|
||||
// Proper prefixes only: a whole secret at the tail was
|
||||
// already replaced by Redact.
|
||||
n := min(len(secret)-1, len(s))
|
||||
for ; n > longest; n-- {
|
||||
if strings.HasSuffix(s, secret[:n]) {
|
||||
longest = n
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return longest
|
||||
}
|
||||
|
||||
// targetSecrets returns the credential-bearing strings a
|
||||
// target's configuration carries.
|
||||
//
|
||||
// The destination URL contributes. Its path, query and
|
||||
// userinfo are the credential for both target types that have
|
||||
// one — an incoming-webhook URL is a bearer token, which is
|
||||
// why MaskURL elides exactly those parts — and they are the
|
||||
// material this service actually sends, so a remote that
|
||||
// echoes the request back echoes them.
|
||||
//
|
||||
// Configured request headers contribute their values, but
|
||||
// only for the credential-shaped names isCredentialHeaderName
|
||||
// picks out. That is the same class-based rule applied to
|
||||
// URLs: an echoed Accept or User-Agent still renders, an
|
||||
// echoed Authorization does not.
|
||||
func targetSecrets(t *database.Target) []string {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch t.Type {
|
||||
case database.TargetTypeSlack:
|
||||
cfg, err := parseSlackConfig(t.Config)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return urlSecrets(cfg.WebhookURL)
|
||||
case database.TargetTypeHTTP:
|
||||
cfg, err := parseHTTPConfig(t.Config)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return append(
|
||||
urlSecrets(cfg.URL),
|
||||
headerSecrets(cfg.Headers)...,
|
||||
)
|
||||
case database.TargetTypeDatabase, database.TargetTypeLog:
|
||||
// Neither has a destination URL, so neither has
|
||||
// anything to redact.
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// urlSecrets returns the substrings of a destination URL that
|
||||
// must not survive into a rendered page: the whole URL, the
|
||||
// parts of it MaskURL elides, and any userinfo.
|
||||
//
|
||||
// No length floor is applied to the path. A short path is
|
||||
// treated as a credential exactly like a long one, because
|
||||
// the field takes an arbitrary URL and no segment can be
|
||||
// assumed non-secret — the same rule MaskURL applies.
|
||||
func urlSecrets(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
secrets := []string{raw}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return secrets
|
||||
}
|
||||
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
requestURI := parsed.RequestURI()
|
||||
secrets = append(secrets, requestURI)
|
||||
|
||||
if escaped := parsed.EscapedPath(); escaped != requestURI {
|
||||
secrets = append(secrets, escaped)
|
||||
}
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
secrets = append(secrets, parsed.User.String())
|
||||
|
||||
if pw, ok := parsed.User.Password(); ok && pw != "" {
|
||||
secrets = append(secrets, pw)
|
||||
}
|
||||
}
|
||||
|
||||
return secrets
|
||||
}
|
||||
|
||||
// minHeaderSecretBytes is the shortest header value treated as
|
||||
// a credential. Unlike a URL path, a header value can be a
|
||||
// couple of bytes long, and redacting those would scatter the
|
||||
// marker through ordinary response text for no gain.
|
||||
const minHeaderSecretBytes = 4
|
||||
|
||||
// headerSecrets returns the values of the configured headers
|
||||
// whose names are credential-shaped.
|
||||
func headerSecrets(headers map[string]string) []string {
|
||||
var secrets []string
|
||||
|
||||
for name, value := range headers {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < minHeaderSecretBytes {
|
||||
continue
|
||||
}
|
||||
|
||||
if isCredentialHeaderName(name) {
|
||||
secrets = append(secrets, value)
|
||||
}
|
||||
}
|
||||
|
||||
return secrets
|
||||
}
|
||||
|
||||
// isCredentialHeaderName classifies a header by its name. The
|
||||
// value is never inspected, so the rule is the same
|
||||
// class-based one MaskURL applies to a destination URL.
|
||||
func isCredentialHeaderName(name string) bool {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
|
||||
// Names that carry a credential by definition.
|
||||
switch name {
|
||||
case "authorization", "proxy-authorization", "cookie":
|
||||
return true
|
||||
}
|
||||
|
||||
// What operators call their own credential headers:
|
||||
// X-Api-Key, X-Hub-Signature, X-Auth-Token.
|
||||
for _, fragment := range []string{
|
||||
"auth",
|
||||
"key",
|
||||
"password",
|
||||
"secret",
|
||||
"signature",
|
||||
"token",
|
||||
} {
|
||||
if strings.Contains(name, fragment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user