Some checks failed
check / check (push) Failing after 2m49s
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. Target configuration keeps reaching the template only as a TargetView. A body that reaches the cap is treated as cut whether or not SQLite is what cut it. The delivery engine stops reading a response at its own cap, which is the same number of bytes this page renders, and the row it writes records that cut length as the whole length, so nothing in the row separates a response that ended at the cap from one severed there. Such a body goes through RedactCut, 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. Its marker says the response reached the recording limit rather than quoting a total the row does not know. 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, and a chunk that fails fails the page rather than rendering the deliveries it covered as never having run. The page renders at most 20 attempts per delivery, counting what it leaves out. static/css/tailwind.css is regenerated with tailwindcss for the utility classes the new markup uses.
247 lines
6.4 KiB
Go
247 lines
6.4 KiB
Go
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",` +
|
|
`"X-Sig":"EEEEEEEEEEEE",` +
|
|
`"X-Pass":"FFFFFFFFFFFF",` +
|
|
`"X-HMAC":"GGGGGGGGGGGG",` +
|
|
`"X-Credential":"HHHHHHHHHHHH",` +
|
|
`"Accept":"application/json",` +
|
|
`"User-Agent":"webhooker/1.0"}}`,
|
|
})
|
|
|
|
for _, secret := range []string{
|
|
"Bearer AAAAAAAAAAAA",
|
|
"session=BBBBBBBBBBBB",
|
|
"CCCCCCCCCCCC",
|
|
"sha256=DDDDDDDDDDDD",
|
|
// Abbreviated names an operator might use.
|
|
"EEEEEEEEEEEE",
|
|
"FFFFFFFFFFFF",
|
|
"GGGGGGGGGGGG",
|
|
"HHHHHHHHHHHH",
|
|
} {
|
|
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,
|
|
)
|
|
}
|
|
}
|