check / check (push) Waiting to run
The Redactor treated a target URL's request URI as a secret only when the URL had a path other than "/", so a response echoing the request line for https://example.com/?token=... or https://example.com?token=... showed the token on the event log and the event's page. urlSecrets now treats the query string, and the request URI that carries it, as secrets whenever the URL has one, whatever its path. With the event's query string passed on, only the target's own part is masked. Model: opus-5-5
327 lines
8.9 KiB
Go
327 lines
8.9 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,
|
|
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_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,
|
|
Config: `{"url":"` + dest + `"}`,
|
|
})
|
|
|
|
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,
|
|
Config: `{"webhookUrl":"` + redactWebhookURL + `"}`,
|
|
})
|
|
|
|
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,
|
|
Config: `{"url":"` + dest + `"}`,
|
|
})
|
|
|
|
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_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,
|
|
)
|
|
}
|
|
}
|