All checks were successful
check / check (push) Successful in 4m7s
The source detail page rendered each target's stored config blob verbatim. For a slack target that blob contains the 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. Rendering it put the credential into browser history, screenshots and any support screen share. Targets are now projected to a display-safe TargetView that has no raw config field at all, so no template can render the blob. Each type contributes named fields instead: slack shows only a masked webhook URL, http shows its destination, timeout, header count and retry settings, and database shows its archive expiry. Header values are not shown because they routinely carry authorization tokens. Masking is a method on the config type, SlackTargetConfig.MaskedWebhookURL, so it is unit-testable and cannot be bypassed from a template. It reduces the URL to scheme and host, eliding the path, query and any userinfo: the field accepts an arbitrary URL, so no path segment can be assumed non-secret. Any config that is empty, of an unknown type, or fails to parse renders a neutral placeholder — there is no fallback to the stored string on any path. The stored config format and the delivery path are unchanged.
225 lines
5.5 KiB
Go
225 lines
5.5 KiB
Go
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
|
|
}
|