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.
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
|
|
}
|