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.
248 lines
6.4 KiB
Go
248 lines
6.4 KiB
Go
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
|
|
}
|