Mask target config on the source detail page (closes #113)
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
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.
This commit was merged in pull request #114.
This commit is contained in:
299
internal/delivery/target_config_view_test.go
Normal file
299
internal/delivery/target_config_view_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user