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:
224
internal/delivery/target_config_view.go
Normal file
224
internal/delivery/target_config_view.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// configUnavailable is what a target's configuration renders
|
||||
// as when it is absent, of an unknown type, or does not
|
||||
// parse. The stored blob is never shown as a fallback: it can
|
||||
// hold a credential (a Slack incoming webhook URL is a bearer
|
||||
// token) and a UI that prints it leaks that credential into
|
||||
// browser history, screenshots and screen shares.
|
||||
const configUnavailable = "(unavailable)"
|
||||
|
||||
// urlPathElision stands in for a URL's elided path.
|
||||
const urlPathElision = "/..."
|
||||
|
||||
// ConfigField is one labelled, display-safe value derived
|
||||
// from a target's stored configuration.
|
||||
type ConfigField struct {
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
// TargetView is the display-safe projection of a target for
|
||||
// the UI. It deliberately has no raw configuration field, so
|
||||
// no template — present or future — can render the stored
|
||||
// blob.
|
||||
type TargetView struct {
|
||||
ID string
|
||||
Name string
|
||||
Type database.TargetType
|
||||
Active bool
|
||||
Config []ConfigField
|
||||
}
|
||||
|
||||
// NewTargetViews projects targets for rendering, replacing
|
||||
// each stored configuration blob with named, display-safe
|
||||
// fields.
|
||||
func NewTargetViews(
|
||||
targets []database.Target,
|
||||
) []TargetView {
|
||||
views := make([]TargetView, 0, len(targets))
|
||||
|
||||
for i := range targets {
|
||||
t := &targets[i]
|
||||
|
||||
views = append(views, TargetView{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Type: t.Type,
|
||||
Active: t.Active,
|
||||
Config: targetConfigFields(t),
|
||||
})
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
// targetConfigFields returns the display-safe fields for a
|
||||
// target's configuration. Anything it cannot parse becomes
|
||||
// the neutral placeholder.
|
||||
func targetConfigFields(
|
||||
t *database.Target,
|
||||
) []ConfigField {
|
||||
switch t.Type {
|
||||
case database.TargetTypeSlack:
|
||||
return slackConfigFields(t.Config)
|
||||
case database.TargetTypeHTTP:
|
||||
return httpConfigFields(t)
|
||||
case database.TargetTypeDatabase:
|
||||
return databaseConfigFields(t.Config)
|
||||
case database.TargetTypeLog:
|
||||
// The log target takes no configuration.
|
||||
return nil
|
||||
default:
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
}
|
||||
|
||||
// unavailableConfigFields is the neutral placeholder shown
|
||||
// for a configuration that could not be presented.
|
||||
func unavailableConfigFields() []ConfigField {
|
||||
return []ConfigField{{
|
||||
Label: "Configuration",
|
||||
Value: configUnavailable,
|
||||
}}
|
||||
}
|
||||
|
||||
// slackConfigFields describes a Slack target. Only the masked
|
||||
// webhook URL is shown; the full URL is the credential.
|
||||
func slackConfigFields(configJSON string) []ConfigField {
|
||||
cfg, err := parseSlackConfig(configJSON)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
return []ConfigField{{
|
||||
Label: "Webhook URL",
|
||||
Value: cfg.MaskedWebhookURL(),
|
||||
}}
|
||||
}
|
||||
|
||||
// httpConfigFields describes an HTTP target: its destination
|
||||
// and its retry settings. Header values are not shown — they
|
||||
// routinely carry authorization tokens — only how many are
|
||||
// configured.
|
||||
func httpConfigFields(t *database.Target) []ConfigField {
|
||||
cfg, err := parseHTTPConfig(t.Config)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
fields := []ConfigField{{
|
||||
Label: "Destination URL",
|
||||
Value: cfg.URL,
|
||||
}}
|
||||
|
||||
if cfg.Timeout > 0 {
|
||||
fields = append(fields, ConfigField{
|
||||
Label: "Timeout",
|
||||
Value: strconv.Itoa(cfg.Timeout) + "s",
|
||||
})
|
||||
}
|
||||
|
||||
if len(cfg.Headers) > 0 {
|
||||
fields = append(fields, ConfigField{
|
||||
Label: "Headers",
|
||||
Value: fmt.Sprintf(
|
||||
"%d configured", len(cfg.Headers),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return append(fields, retryFields(t)...)
|
||||
}
|
||||
|
||||
// retryFields describes a target's retry settings, which live
|
||||
// on the target row rather than in its configuration blob.
|
||||
func retryFields(t *database.Target) []ConfigField {
|
||||
retries := strconv.Itoa(t.MaxRetries)
|
||||
if t.MaxRetries == 0 {
|
||||
retries += " (fire-and-forget)"
|
||||
}
|
||||
|
||||
fields := []ConfigField{{
|
||||
Label: "Max Retries",
|
||||
Value: retries,
|
||||
}}
|
||||
|
||||
if t.MaxQueueSize > 0 {
|
||||
fields = append(fields, ConfigField{
|
||||
Label: "Max Queue Size",
|
||||
Value: strconv.Itoa(t.MaxQueueSize),
|
||||
})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// databaseConfigFields describes an archive target. Its
|
||||
// configuration is optional, and an absent or empty expiry
|
||||
// means the archive is kept forever. An expiry that is set
|
||||
// but not a valid duration is reported as unavailable rather
|
||||
// than echoed back.
|
||||
func databaseConfigFields(configJSON string) []ConfigField {
|
||||
expiry := archiveExpiryNever
|
||||
|
||||
if configJSON != "" {
|
||||
var cfg databaseTargetConfig
|
||||
|
||||
err := json.Unmarshal([]byte(configJSON), &cfg)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
if cfg.Expiry != "" {
|
||||
if ValidateArchiveExpiry(cfg.Expiry) != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
expiry = cfg.Expiry
|
||||
}
|
||||
}
|
||||
|
||||
return []ConfigField{{
|
||||
Label: "Archive Expiry",
|
||||
Value: expiry,
|
||||
}}
|
||||
}
|
||||
|
||||
// MaskedWebhookURL returns the Slack webhook URL reduced to
|
||||
// its scheme and host, with the path, query and any userinfo
|
||||
// elided. The path segments are the credential, so none of
|
||||
// them is shown: the field accepts an arbitrary URL, so no
|
||||
// segment can be assumed non-secret. A URL that does not
|
||||
// parse into a scheme and host yields the neutral
|
||||
// placeholder, never the raw string.
|
||||
func (c *SlackTargetConfig) MaskedWebhookURL() string {
|
||||
return maskURL(c.WebhookURL)
|
||||
}
|
||||
|
||||
// maskURL renders a URL as scheme plus host with everything
|
||||
// that can carry a secret removed.
|
||||
func maskURL(raw string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" ||
|
||||
parsed.Host == "" {
|
||||
return configUnavailable
|
||||
}
|
||||
|
||||
masked := parsed.Scheme + "://" + parsed.Host
|
||||
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
masked += urlPathElision
|
||||
}
|
||||
|
||||
return masked
|
||||
}
|
||||
Reference in New Issue
Block a user