All checks were successful
check / check (push) Successful in 9s
Go embeds the request URL in *url.Error, so any transport failure — DNS, TLS, refused, timeout, SSRF dial block — persisted the full Slack webhook URL into the per-webhook SQLite database via DeliveryResult.Error. That field is tagged json:"error,omitempty", so a future REST API would have served it. maskURLError rebuilds the error preserving Op and the wrapped cause, so DNS vs TLS vs timeout still read differently and errors.Is/As and Timeout() keep working; only path, query and userinfo are dropped. Applied where the errors are born, which covers both the Slack and HTTP targets. url.Parse embeds the URL too, so ValidateTargetURL's parse branch gets the same treatment. The SSRF rejection log now logs the masked URL, and source_logs.html receives view types rather than raw rows, so no config blob is reachable from that template. MaskURL is now the single masker for the whole tree.
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)
|
|
}
|