Files
webhooker/internal/delivery/target_config_view_test.go
clawbot 15a61173fc
Some checks failed
check / check (push) Has been cancelled
Mask target config on the source detail page (closes #113)
The page rendered the stored target config verbatim, exposing the Slack
incoming-webhook URL, which is a bearer credential: anyone holding it can
post to the channel indefinitely, and it cannot be scoped or revoked
per-holder.

Target config now reaches the template only as a TargetView carrying
labelled fields, so no code path can render the raw blob. maskURL keeps
scheme and host and elides the path, and drops query, fragment and
userinfo; every parse failure yields a neutral placeholder rather than
falling back to the stored string. HTTP header values are never rendered,
only a count.

Rendering change only: the stored config format and the delivery path are
unchanged.
2026-08-11 14:37:09 +02:00

300 lines
6.2 KiB
Go

package delivery_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
const (
// slackSecretPath is the credential-bearing part of a
// Slack incoming webhook URL: everything after the host.
slackSecretPath = "/services/T00000000/B00000000/" +
"XXXXXXXXXXXXXXXXXXXXXXXX"
slackWebhookURL = "https://hooks.slack.com" +
slackSecretPath
viewExampleOrigin = "https://example.com"
viewExampleHook = viewExampleOrigin + "/hook"
viewUnavailable = "(unavailable)"
viewExpiryNever = "never"
)
func TestMaskedWebhookURL(t *testing.T) {
t.Parallel()
tests := map[string]struct {
url string
want string
}{
"slack webhook": {
url: slackWebhookURL,
want: "https://hooks.slack.com/...",
},
"query string dropped": {
url: viewExampleOrigin + "/a?token=secret",
want: viewExampleOrigin + "/...",
},
// Fabricated userinfo in a test URL, not a real
// credential.
//nolint:gosec // G101
"userinfo dropped": {
url: "https://user:pw@example.com/a/b",
want: viewExampleOrigin + "/...",
},
"no path": {
url: viewExampleOrigin,
want: viewExampleOrigin,
},
"root path": {
url: viewExampleOrigin + "/",
want: viewExampleOrigin,
},
"not a url": {
url: "definitely not a url",
want: viewUnavailable,
},
"empty": {
url: "",
want: viewUnavailable,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
cfg := &delivery.SlackTargetConfig{
WebhookURL: tc.url,
}
assert.Equal(
t, tc.want, cfg.MaskedWebhookURL(),
)
})
}
}
// TestMaskedWebhookURL_NeverLeaksPath is the direct
// expression of the rule: whatever the input, the masked
// value never contains a path segment of it.
func TestMaskedWebhookURL_NeverLeaksPath(t *testing.T) {
t.Parallel()
cfg := &delivery.SlackTargetConfig{
WebhookURL: slackWebhookURL,
}
masked := cfg.MaskedWebhookURL()
assert.NotContains(t, masked, "T00000000")
assert.NotContains(t, masked, "B00000000")
assert.NotContains(
t, masked, "XXXXXXXXXXXXXXXXXXXXXXXX",
)
assert.NotContains(t, masked, slackSecretPath)
}
// fieldMap turns a view's config fields into a lookup so
// assertions read by label.
func fieldMap(fields []delivery.ConfigField) map[string]string {
out := make(map[string]string, len(fields))
for _, f := range fields {
out[f.Label] = f.Value
}
return out
}
// viewFor projects a single target and returns its view.
func viewFor(
t *testing.T,
target database.Target,
) delivery.TargetView {
t.Helper()
views := delivery.NewTargetViews(
[]database.Target{target},
)
require.Len(t, views, 1)
return views[0]
}
func TestNewTargetViews_Slack(t *testing.T) {
t.Parallel()
view := viewFor(t, database.Target{
Name: "slack-target",
Type: database.TargetTypeSlack,
Active: true,
Config: `{"webhookUrl":"` +
slackWebhookURL + `"}`,
})
assert.Equal(t, "slack-target", view.Name)
assert.Equal(
t,
map[string]string{
"Webhook URL": "https://hooks.slack.com/...",
},
fieldMap(view.Config),
)
}
func TestNewTargetViews_HTTP(t *testing.T) {
t.Parallel()
view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP,
Config: `{"url":"` + viewExampleHook + `",` +
`"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"}}`,
MaxRetries: 5,
MaxQueueSize: 100,
})
fields := fieldMap(view.Config)
assert.Equal(
t,
map[string]string{
"Destination URL": viewExampleHook,
"Timeout": "30s",
"Headers": "1 configured",
"Max Retries": "5",
"Max Queue Size": "100",
},
fields,
)
// Header values can be credentials and are never shown.
for _, v := range fields {
assert.NotContains(t, v, "sekrit")
}
}
func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
t.Parallel()
view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP,
Config: `{"url":"` + viewExampleHook + `"}`,
})
assert.Equal(
t,
map[string]string{
"Destination URL": viewExampleHook,
"Max Retries": "0 (fire-and-forget)",
},
fieldMap(view.Config),
)
}
func TestNewTargetViews_Database(t *testing.T) {
t.Parallel()
tests := map[string]struct {
config string
want string
}{
"empty config": {config: "", want: viewExpiryNever},
"empty expiry": {config: `{}`, want: viewExpiryNever},
"explicit": {
config: `{"expiry":"720h"}`,
want: "720h",
},
"never literal": {
config: `{"expiry":"` + viewExpiryNever + `"}`,
want: viewExpiryNever,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
view := viewFor(t, database.Target{
Type: database.TargetTypeDatabase,
Config: tc.config,
})
assert.Equal(
t,
map[string]string{"Archive Expiry": tc.want},
fieldMap(view.Config),
)
})
}
}
func TestNewTargetViews_Log(t *testing.T) {
t.Parallel()
view := viewFor(t, database.Target{
Type: database.TargetTypeLog,
Config: "",
})
assert.Empty(t, view.Config)
}
// TestNewTargetViews_Unpresentable proves that no config the
// view cannot present falls back to the stored blob.
func TestNewTargetViews_Unpresentable(t *testing.T) {
t.Parallel()
const blob = `{"webhookUrl":"https://hooks.slack.com` +
slackSecretPath + `"`
tests := map[string]database.Target{
"unknown target type": {
Type: database.TargetType("carrier-pigeon"),
Config: blob,
},
"unparseable json": {
Type: database.TargetTypeSlack,
Config: blob,
},
"empty slack config": {
Type: database.TargetTypeSlack,
},
"slack config without url": {
Type: database.TargetTypeSlack,
Config: `{}`,
},
"unparseable http json": {
Type: database.TargetTypeHTTP,
Config: `{"url":`,
},
"unparseable archive json": {
Type: database.TargetTypeDatabase,
Config: `{"expiry":`,
},
"invalid archive expiry": {
Type: database.TargetTypeDatabase,
Config: `{"expiry":"a fortnight"}`,
},
}
for name, target := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
view := viewFor(t, target)
assert.Equal(
t,
map[string]string{
"Configuration": viewUnavailable,
},
fieldMap(view.Config),
)
})
}
}