All checks were successful
check / check (push) Successful in 6m0s
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 before rendering. Configured HTTP header values are deliberately not redacted; they are as often routine as secret, and replacing them would mangle ordinary responses. Target configuration keeps reaching the template only as a TargetView.
137 lines
3.7 KiB
Go
137 lines
3.7 KiB
Go
package delivery
|
|
|
|
import (
|
|
"net/url"
|
|
"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 matches literally, against strings taken from the
|
|
// target's stored configuration, so it guesses nothing about
|
|
// what a secret looks like. That also bounds what it can
|
|
// promise: it removes the credential this service handed the
|
|
// remote, and 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 {
|
|
return Redactor{secrets: targetSecrets(t)}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// targetSecrets returns the credential-bearing strings a
|
|
// target's configuration carries, longest first so that
|
|
// replacing one never leaves a fragment of a longer one
|
|
// behind.
|
|
//
|
|
// Only 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 HTTP headers are deliberately not included.
|
|
// Their values are as often routine as secret (Accept,
|
|
// User-Agent), and redacting them from remote text would
|
|
// replace ordinary response content with the marker. A remote
|
|
// that echoes an Authorization header into its response body
|
|
// is therefore not covered.
|
|
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 urlSecrets(cfg.URL)
|
|
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
|
|
}
|