feat: Hide secrets on api and channels
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 8m5s

This commit is contained in:
2026-08-14 12:23:36 +00:00
parent ac61015cc0
commit aa1c8e4aa1
6 changed files with 156 additions and 14 deletions
+9 -2
View File
@@ -34,7 +34,14 @@ func listChannels(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, channels)
// Redacted here rather than in the service: the dispatchers read the same
// documents and need the real credentials, so the masking belongs to the
// boundary that hands them to a client.
out := make([]models.NotificationChannel, 0, len(channels))
for _, ch := range channels {
out = append(out, ch.Redacted())
}
c.JSON(http.StatusOK, out)
}
// createChannel godoc
@@ -69,7 +76,7 @@ func createChannel(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
c.JSON(http.StatusCreated, created.Redacted())
}
// updateChannel godoc
+44
View File
@@ -14,6 +14,28 @@ const (
ChannelTelegram = "telegram"
)
// RedactedSecret is what a channel's secret config values read as over the API.
// It is a sentinel and not merely a mask: a client may write it straight back,
// and the value it stood for is preserved. See NotificationChannel.Redacted.
const RedactedSecret = "••••••••"
// channelSecretKeys names, per channel type, the config entries that are
// credentials rather than settings. A Slack or Discord webhook URL is on this
// list because possession of the URL *is* the authorisation to post to that
// channel — there is nothing else to steal.
var channelSecretKeys = map[string][]string{
ChannelWebhook: {"url"},
ChannelSlack: {"url"},
ChannelDiscord: {"url"},
ChannelTelegram: {"token"},
ChannelSMTP: {"password"},
}
// ChannelSecretKeys reports which config keys of a channel type are secret.
func ChannelSecretKeys(channelType string) []string {
return channelSecretKeys[channelType]
}
type NotificationChannel struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
@@ -24,3 +46,25 @@ type NotificationChannel struct {
Enabled bool `bson:"enabled" json:"enabled"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// Redacted returns a copy with every secret config value replaced by
// RedactedSecret, for handing to a client. Nothing internal uses it: the
// dispatchers read the stored document through GetChannel/GetChannels, so the
// redaction is a property of the API boundary and cannot break delivery.
//
// A set-but-secret key keeps its key, so a caller can still tell configured
// from absent; an empty value is left empty rather than being dressed up as a
// credential that is not there.
func (c NotificationChannel) Redacted() NotificationChannel {
out := c
out.Config = make(map[string]string, len(c.Config))
for k, v := range c.Config {
out.Config[k] = v
}
for _, k := range ChannelSecretKeys(c.Type) {
if out.Config[k] != "" {
out.Config[k] = RedactedSecret
}
}
return out
}
+45
View File
@@ -90,12 +90,57 @@ func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.N
}
func UpdateChannel(instanceID, channelID string, upd bson.M) error {
if cfg, ok := upd["config"].(map[string]string); ok {
merged, err := mergeChannelSecrets(instanceID, channelID, upd, cfg)
if err != nil {
return err
}
upd["config"] = merged
}
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}, bson.M{"$set": upd})
return err
}
// mergeChannelSecrets resolves models.RedactedSecret back to what it stood for.
//
// The API hands out a sentinel rather than the credential, and the UI's edit
// form round-trips whatever it was given, so an ordinary "rename this channel"
// save arrives carrying the sentinel in place of the password. Writing it
// through would replace the credential with eight bullet characters and break
// delivery on the next alert. A value that is not the sentinel is written
// verbatim — including the empty string, which is how a credential is cleared.
func mergeChannelSecrets(instanceID, channelID string, upd bson.M, cfg map[string]string) (map[string]string, error) {
stored, err := GetChannel(instanceID, channelID)
if err != nil {
return nil, err
}
if stored == nil {
return cfg, nil
}
// The secret keys are the ones of the type being saved, which the same
// request may be changing.
channelType := stored.Type
if t, ok := upd["type"].(string); ok && t != "" {
channelType = t
}
out := make(map[string]string, len(cfg))
for k, v := range cfg {
out[k] = v
}
for _, k := range models.ChannelSecretKeys(channelType) {
if out[k] == models.RedactedSecret {
if prev, ok := stored.Config[k]; ok {
out[k] = prev
} else {
delete(out, k)
}
}
}
return out, nil
}
func DeleteChannel(instanceID, channelID string) error {
ctx, cancel := monCtx()
defer cancel()