All checks were successful
check / check (push) Successful in 3m30s
A delivery target URL is itself a credential: a Slack incoming webhook URL is a bearer token. Three paths still reproduced it in full. Transport failures were the worst of them. net/http embeds the request URL in every *url.Error it returns, so any DNS, TLS, timeout or dial failure wrote the whole webhook URL into DeliveryResult.Error — on disk, in the per-webhook database, behind a json tag that a REST API would serialize. maskURL moves to url_mask.go and is exported as MaskURL, and maskURLError joins it: it rebuilds the *url.Error with the URL masked, keeping the operation and the wrapped cause, so a refused connection still reads differently from a DNS failure or a timeout and errors.Is/As/Timeout still work. It is applied where the errors are raised — executeHTTPRequest, shared by the Slack and HTTP targets, and the request-construction paths — so downstream wrapping is safe by construction. url.Parse embeds the URL too, so ValidateTargetURL's parse branch gets the same treatment; its error is logged and shown. The SSRF rejection log now records only the masked URL, and loadTargetMap hands the event log page TargetViews and a delivery projection instead of raw target rows, so the stored config blob has no path to that template either.
203 lines
5.0 KiB
Go
203 lines
5.0 KiB
Go
package delivery
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"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)"
|
|
|
|
// 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)
|
|
}
|