From aa1c8e4aa181fec889ab75ea2347cac522440442 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 14 Aug 2026 12:23:36 +0000 Subject: [PATCH] feat: Hide secrets on api and channels --- docsite/docs/vantage/notification-channels.md | 9 ++++ server/internal/api/channels.go | 11 ++++- server/internal/models/channel.go | 44 ++++++++++++++++++ server/internal/services/channels.go | 45 +++++++++++++++++++ web/app/(app)/settings/notifications/page.tsx | 42 ++++++++++++----- web/lib/api.ts | 19 ++++++++ 6 files changed, 156 insertions(+), 14 deletions(-) diff --git a/docsite/docs/vantage/notification-channels.md b/docsite/docs/vantage/notification-channels.md index 91535e2..e1e0373 100644 --- a/docsite/docs/vantage/notification-channels.md +++ b/docsite/docs/vantage/notification-channels.md @@ -64,6 +64,15 @@ Posts the alert as message content. Port `465` uses implicit TLS; anything else uses STARTTLS. +### Credentials are never read back + +The SMTP `password`, the Telegram `token` and the webhook, Slack and Discord +`url`s come back from `GET /api/channels` as `••••••••` — a webhook URL is the +authorisation to post to that channel, so it is treated as a credential like +the rest. Writing that value back unchanged keeps the stored one, which is what +lets you rename a channel without retyping its password. Anything else you send +is written as given, so clearing the field clears the credential. + Alert emails look like the rest of the mail Vantage sends you. ## The message diff --git a/server/internal/api/channels.go b/server/internal/api/channels.go index b8062d7..1bd21b3 100644 --- a/server/internal/api/channels.go +++ b/server/internal/api/channels.go @@ -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 diff --git a/server/internal/models/channel.go b/server/internal/models/channel.go index 10a93db..caf34fe 100644 --- a/server/internal/models/channel.go +++ b/server/internal/models/channel.go @@ -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 +} diff --git a/server/internal/services/channels.go b/server/internal/services/channels.go index 04236a8..d078739 100644 --- a/server/internal/services/channels.go +++ b/server/internal/services/channels.go @@ -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() diff --git a/web/app/(app)/settings/notifications/page.tsx b/web/app/(app)/settings/notifications/page.tsx index f7988df..a83af1b 100644 --- a/web/app/(app)/settings/notifications/page.tsx +++ b/web/app/(app)/settings/notifications/page.tsx @@ -3,7 +3,14 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; -import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api"; +import { + api, + CHANNEL_SECRET_FIELDS, + ChannelInput, + ChannelType, + NotificationChannel, + REDACTED_SECRET, +} from "@/lib/api"; import { Badge, Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui"; import { VulnAlertRulesCard } from "@/components/vulnerabilities/VulnAlertRulesCard"; @@ -67,17 +74,28 @@ function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDon ))} - {CONFIG_FIELDS[type].map((field) => ( -
- - setConfig({ ...config, [field]: e.target.value })} - /> -
- ))} + {CONFIG_FIELDS[type].map((field) => { + // A secret comes back from the API as the sentinel, never as itself. + // The field renders empty rather than showing bullets in a URL box, and + // the sentinel is left sitting in state so an untouched save preserves + // the credential. Typing replaces it; clearing the field back to empty + // is how a credential is removed. + const secret = CHANNEL_SECRET_FIELDS[type].includes(field); + const value = config[field] ?? ""; + const unchanged = secret && value === REDACTED_SECRET; + return ( +
+ + setConfig({ ...config, [field]: e.target.value })} + /> +
+ ); + })} {error &&

{(error as Error).message}

}