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
|
||||
}
|
||||
237
internal/delivery/target_redact_test.go
Normal file
237
internal/delivery/target_redact_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// The secret path segments of a Slack incoming webhook URL.
|
||||
const (
|
||||
redactSecretPath = "/services/T11111111/B11111111/" +
|
||||
"YYYYYYYYYYYYYYYYYYYYYYYY"
|
||||
redactWebhookURL = "https://hooks.slack.com" +
|
||||
redactSecretPath
|
||||
)
|
||||
|
||||
func TestRedactor_RemovesSlackWebhookURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := delivery.NewRedactor(&database.Target{
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
||||
})
|
||||
|
||||
got := r.Redact("no_service for " + redactWebhookURL)
|
||||
|
||||
assert.NotContains(t, got, redactSecretPath)
|
||||
assert.NotContains(t, got, "T11111111")
|
||||
|
||||
// One marker, not a marker with the host left in front of
|
||||
// it: the whole URL is replaced before the path it
|
||||
// contains, which is what sorting the secrets longest
|
||||
// first buys.
|
||||
assert.Equal(
|
||||
t,
|
||||
"no_service for "+delivery.RedactionMarker,
|
||||
got,
|
||||
)
|
||||
}
|
||||
|
||||
// TestRedactor_RemovesSecretSeveredByACut covers the input the
|
||||
// redactor exists for: text cut to a byte budget with the
|
||||
// credential straddling the cut. The remote chooses the
|
||||
// padding, so it chooses where the cut lands inside the
|
||||
// credential, and the severed prefix that remains equals no
|
||||
// secret.
|
||||
func TestRedactor_RemovesSecretSeveredByACut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := delivery.NewRedactor(&database.Target{
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
||||
})
|
||||
|
||||
// Every cut position inside the credential, not just a
|
||||
// convenient one.
|
||||
for n := 1; n < len(redactWebhookURL); n++ {
|
||||
severed := redactWebhookURL[:n]
|
||||
cut := "padding " + severed
|
||||
|
||||
got := r.RedactCut(cut)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"padding "+delivery.RedactionMarker,
|
||||
got,
|
||||
"cut after %d bytes of the credential", n,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactor_RedactsCredentialShapedHeaderValues pins the
|
||||
// class-based header rule: a header whose name says credential
|
||||
// has its value redacted, and a routine header does not, so
|
||||
// ordinary response content survives.
|
||||
func TestRedactor_RedactsCredentialShapedHeaderValues(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
r := delivery.NewRedactor(&database.Target{
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: `{"url":"https://example.com/in",` +
|
||||
`"headers":{` +
|
||||
`"Authorization":"Bearer AAAAAAAAAAAA",` +
|
||||
`"Cookie":"session=BBBBBBBBBBBB",` +
|
||||
`"X-Api-Key":"CCCCCCCCCCCC",` +
|
||||
`"X-Hub-Signature":"sha256=DDDDDDDDDDDD",` +
|
||||
`"Accept":"application/json",` +
|
||||
`"User-Agent":"webhooker/1.0"}}`,
|
||||
})
|
||||
|
||||
for _, secret := range []string{
|
||||
"Bearer AAAAAAAAAAAA",
|
||||
"session=BBBBBBBBBBBB",
|
||||
"CCCCCCCCCCCC",
|
||||
"sha256=DDDDDDDDDDDD",
|
||||
} {
|
||||
got := r.Redact("echo: " + secret)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"echo: "+delivery.RedactionMarker,
|
||||
got,
|
||||
secret,
|
||||
)
|
||||
}
|
||||
|
||||
const routine = "Accept: application/json, " +
|
||||
"User-Agent: webhooker/1.0"
|
||||
|
||||
assert.Equal(t, routine, r.Redact(routine))
|
||||
}
|
||||
|
||||
// TestRedactor_IgnoresVeryShortHeaderValues pins the floor
|
||||
// under a header value. Redacting a two-byte value would put
|
||||
// the marker through every response that happens to contain
|
||||
// those bytes.
|
||||
func TestRedactor_IgnoresVeryShortHeaderValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := delivery.NewRedactor(&database.Target{
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: `{"url":"https://example.com/in",` +
|
||||
`"headers":{"X-Api-Key":"ab"}}`,
|
||||
})
|
||||
|
||||
const response = "rabbit"
|
||||
|
||||
assert.Equal(t, response, r.Redact(response))
|
||||
}
|
||||
|
||||
// TestRedactor_RemovesBarePath covers a remote that echoes
|
||||
// only the request path rather than the whole URL. The path
|
||||
// segments are the credential on their own.
|
||||
func TestRedactor_RemovesBarePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := delivery.NewRedactor(&database.Target{
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
||||
})
|
||||
|
||||
got := r.Redact("POST " + redactSecretPath + " 404")
|
||||
|
||||
assert.NotContains(t, got, redactSecretPath)
|
||||
assert.Equal(
|
||||
t,
|
||||
"POST "+delivery.RedactionMarker+" 404",
|
||||
got,
|
||||
)
|
||||
}
|
||||
|
||||
// TestRedactor_RemovesHTTPURLQueryAndUserinfo covers the HTTP
|
||||
// target, whose destination is an arbitrary URL: the query
|
||||
// string and the userinfo carry credentials as readily as the
|
||||
// path does.
|
||||
func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Assembled rather than written out, so the literal is
|
||||
// not itself a credential-shaped string.
|
||||
dest := url.URL{
|
||||
Scheme: "https",
|
||||
User: url.UserPassword("user", "hunter2"),
|
||||
Host: "example.com",
|
||||
Path: "/in",
|
||||
RawQuery: "token=s3cr3t",
|
||||
}
|
||||
raw := dest.String()
|
||||
|
||||
r := delivery.NewRedactor(&database.Target{
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: `{"url":"` + raw + `"}`,
|
||||
})
|
||||
|
||||
for _, echoed := range []string{
|
||||
raw,
|
||||
"/in?token=s3cr3t",
|
||||
"hunter2",
|
||||
} {
|
||||
got := r.Redact("rejected: " + echoed)
|
||||
|
||||
assert.NotContains(t, got, "s3cr3t", echoed)
|
||||
assert.NotContains(t, got, "hunter2", echoed)
|
||||
assert.Contains(
|
||||
t, got, delivery.RedactionMarker, echoed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactor_LeavesUnrelatedTextAlone pins that the
|
||||
// redactor matches literally: it does not guess at what a
|
||||
// secret looks like, so ordinary response content survives.
|
||||
func TestRedactor_LeavesUnrelatedTextAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const response = "ok=false error=channel_not_found"
|
||||
|
||||
r := delivery.NewRedactor(&database.Target{
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
||||
})
|
||||
|
||||
assert.Equal(t, response, r.Redact(response))
|
||||
}
|
||||
|
||||
// TestRedactor_ZeroValueAndConfiglessTargets pins that a
|
||||
// caller with no target, an unparseable config, or a target
|
||||
// type with no destination URL gets a redactor that changes
|
||||
// nothing rather than one that panics.
|
||||
func TestRedactor_ZeroValueAndConfiglessTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const text = "some response body"
|
||||
|
||||
var zero delivery.Redactor
|
||||
|
||||
assert.Equal(t, text, zero.Redact(text))
|
||||
assert.Equal(t, text, delivery.NewRedactor(nil).Redact(text))
|
||||
|
||||
for _, tgt := range []database.Target{
|
||||
{Type: database.TargetTypeLog},
|
||||
{Type: database.TargetTypeDatabase},
|
||||
{Type: database.TargetTypeSlack, Config: "not json"},
|
||||
{Type: database.TargetTypeHTTP, Config: ""},
|
||||
} {
|
||||
assert.Equal(
|
||||
t, text,
|
||||
delivery.NewRedactor(&tgt).Redact(text),
|
||||
tgt.Type,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user