check / check (push) Waiting to run
A target's settings move from the per-type JSON string in targets.config to columns: url, headers, timeout_seconds, forward_query, archive_expiry and archive_rotation. A column a type does not use stays empty. headers holds the header map as JSON text through GORM's JSON serializer, and archive_expiry holds a time.Duration, 0 meaning never. The forms check each setting once, with the same refusals and messages, and the edit form is filled from the columns, so the per-type config structs, their parsers and the "could not be read" branches are gone. A delivery task carries the six settings in place of the JSON string; tests check that each copy into a task, and back out of one, keeps all six. Model: opus-5-5
336 lines
9.0 KiB
Go
336 lines
9.0 KiB
Go
package delivery_test
|
|
|
|
import (
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"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,
|
|
URL: redactWebhookURL,
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
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,
|
|
URL: redactWebhookURL,
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
// 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,
|
|
URL: "https://example.com/in",
|
|
Headers: map[string]string{
|
|
headerAuthorization: "Bearer AAAAAAAAAAAA",
|
|
"Cookie": "session=BBBBBBBBBBBB",
|
|
probeHeaderName: "CCCCCCCCCCCC",
|
|
"X-Hub-Signature": "sha256=DDDDDDDDDDDD",
|
|
"X-Sig": "EEEEEEEEEEEE",
|
|
"X-Pass": "FFFFFFFFFFFF",
|
|
"X-HMAC": "GGGGGGGGGGGG",
|
|
"X-Credential": "HHHHHHHHHHHH",
|
|
"Accept": "application/json",
|
|
headerUserAgent: "webhooker/1.0",
|
|
},
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
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,
|
|
URL: "https://example.com/in",
|
|
Headers: map[string]string{probeHeaderName: "ab"},
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
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,
|
|
URL: redactWebhookURL,
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
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,
|
|
URL: raw,
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
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_RemovesEchoedQueryOfURLWithoutPath covers an
|
|
// HTTP target URL whose credential is all in its query string.
|
|
// Written with or without the "/", the request line sends it
|
|
// as "/?token=…", and a target passing the event's query string
|
|
// on sends that after an "&". The event's part stays visible:
|
|
// the event's page shows it anyway.
|
|
func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const secret = "s3cr3t"
|
|
|
|
marker := delivery.RedactionMarker
|
|
|
|
// An echoed request line, and what the event log shows of it.
|
|
echoes := map[string]string{
|
|
"POST /?token=" + secret + " HTTP/1.1": "POST " + marker +
|
|
" HTTP/1.1",
|
|
"POST ?token=" + secret + " HTTP/1.1": "POST ?" + marker +
|
|
" HTTP/1.1",
|
|
"POST /?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST " +
|
|
marker + "&a=1&b=2 HTTP/1.1",
|
|
"POST ?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST ?" +
|
|
marker + "&a=1&b=2 HTTP/1.1",
|
|
}
|
|
|
|
for _, dest := range []string{
|
|
"https://example.com/?token=" + secret,
|
|
"https://example.com?token=" + secret,
|
|
} {
|
|
r := delivery.NewRedactor(&database.Target{
|
|
Type: database.TargetTypeHTTP,
|
|
URL: dest,
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
for echoed, want := range echoes {
|
|
assert.Equal(
|
|
t, want, r.Redact(echoed), "%s: %s", dest, 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,
|
|
URL: redactWebhookURL,
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
assert.Equal(t, response, r.Redact(response))
|
|
}
|
|
|
|
// TestRedactor_EmptyUserinfoDoesNotShredTheBody covers a
|
|
// destination URL written with a bare "@" and no userinfo:
|
|
// url.Parse returns a non-nil User whose String is empty. An
|
|
// empty secret in the list would make strings.ReplaceAll
|
|
// insert the marker at every byte boundary, destroying every
|
|
// body and error string the target renders.
|
|
func TestRedactor_EmptyUserinfoDoesNotShredTheBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const dest = "https://@example.com/in"
|
|
|
|
// The premise: this URL really does parse to a non-nil
|
|
// User contributing an empty string.
|
|
parsed, err := url.Parse(dest)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, parsed.User)
|
|
require.Empty(t, parsed.User.String())
|
|
|
|
r := delivery.NewRedactor(&database.Target{
|
|
Type: database.TargetTypeHTTP,
|
|
URL: dest,
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
const body = "ok=false error=channel_not_found"
|
|
|
|
assert.Equal(t, body, r.Redact(body))
|
|
assert.Equal(t, body, r.RedactCut(body))
|
|
|
|
// The real credential material still goes, so filtering the
|
|
// empty string out did not disarm the redactor.
|
|
assert.Equal(
|
|
t,
|
|
"POST "+delivery.RedactionMarker,
|
|
r.Redact("POST "+dest),
|
|
)
|
|
}
|
|
|
|
// TestRedactor_ZeroValueAndURLlessTargets pins that a caller
|
|
// with no target, or a target with no destination URL, gets a
|
|
// redactor that changes nothing rather than one that panics.
|
|
func TestRedactor_ZeroValueAndURLlessTargets(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, MaxAttempts: 1},
|
|
{Type: database.TargetTypeHTTP, MaxAttempts: 1},
|
|
} {
|
|
assert.Equal(
|
|
t, text,
|
|
delivery.NewRedactor(&tgt).Redact(text),
|
|
tgt.Type,
|
|
)
|
|
}
|
|
}
|