package delivery import ( "net/url" "slices" "strings" "sneak.berlin/go/webhooker/internal/database" ) // RedactionMarker stands in for a target credential found in // text the target's remote peer chose. const RedactionMarker = "(redacted)" // Redactor removes one target's own credential material from // text that target's remote peer chose: a delivery response // body, or a delivery error stored before the delivery path // learned to mask the URLs it embeds. // // It removes byte-identical echoes of strings taken from the // target's stored configuration, and nothing else. Anything // the remote re-encodes survives: JSON "\/" escaping (what // PHP's json_encode emits by default), percent-encoding, HTML // entities, and an echo of only part of a path. It cannot // remove a secret the remote invented. // // The zero Redactor removes nothing, which is what a caller // holding no target for a delivery gets. type Redactor struct { secrets []string } // NewRedactor builds the redactor for one target. func NewRedactor(t *database.Target) Redactor { // Drop empty strings here rather than at the site that // produced one. strings.ReplaceAll with an empty old string // inserts the marker at every byte boundary, so a single // empty secret destroys every body and error the target // renders; filtering at the collection point means no field // added to targetSecrets later can reintroduce that. // url.Parse("https://@example.com/in") is the known // producer: a non-nil User whose String is "". secrets := slices.DeleteFunc( targetSecrets(t), func(s string) bool { return s == "" }, ) // Longest first, so replacing a secret that is contained // in a longer one cannot leave a fragment of the longer // one behind. Configured headers arrive in map order, so // the sort is also what makes the result deterministic. slices.SortFunc(secrets, func(a, b string) int { if d := len(b) - len(a); d != 0 { return d } return strings.Compare(a, b) }) return Redactor{secrets: secrets} } // Redact replaces every occurrence of the target's credential // material in s. func (r Redactor) Redact(s string) string { if s == "" { return s } for _, secret := range r.secrets { s = strings.ReplaceAll(s, secret, RedactionMarker) } return s } // RedactCut redacts s, which its caller has already cut to a // byte budget, and additionally drops any tail of s that is a // proper prefix of a secret. // // The cut lands wherever the remote's padding puts it, so the // remote chooses where inside the credential it falls. The // severed prefix left behind equals no secret, so plain // Redact would render it verbatim. func (r Redactor) RedactCut(s string) string { s = r.Redact(s) if n := r.secretPrefixSuffix(s); n > 0 { return s[:len(s)-n] + RedactionMarker } return s } // secretPrefixSuffix returns the length of the longest suffix // of s that is a proper prefix of one of the secrets, or 0 // when there is none. func (r Redactor) secretPrefixSuffix(s string) int { longest := 0 for _, secret := range r.secrets { // Proper prefixes only: a whole secret at the tail was // already replaced by Redact. n := min(len(secret)-1, len(s)) for ; n > longest; n-- { if strings.HasSuffix(s, secret[:n]) { longest = n break } } } return longest } // targetSecrets returns the credential-bearing strings a // target's configuration carries. // // The destination URL contributes. Its path, query and // userinfo are the credential for both target types that have // one — an incoming-webhook URL is a bearer token, which is // why MaskURL elides exactly those parts — and they are the // material this service actually sends, so a remote that // echoes the request back echoes them. // // Configured request headers contribute their values, but // only for the credential-shaped names isCredentialHeaderName // picks out. That is the same class-based rule applied to // URLs: an echoed Accept or User-Agent still renders, an // echoed Authorization does not. func targetSecrets(t *database.Target) []string { if t == nil { return nil } switch t.Type { case database.TargetTypeSlack: cfg, err := parseSlackConfig(t.Config) if err != nil { return nil } return urlSecrets(cfg.WebhookURL) case database.TargetTypeHTTP: cfg, err := parseHTTPConfig(t.Config) if err != nil { return nil } return append( urlSecrets(cfg.URL), headerSecrets(cfg.Headers)..., ) case database.TargetTypeDatabase, database.TargetTypeLog: // Neither has a destination URL, so neither has // anything to redact. return nil default: return nil } } // urlSecrets returns the substrings of a destination URL that // must not survive into a rendered page: the whole URL, the // parts of it MaskURL elides, and any userinfo. // // No length floor is applied to the path, and none to the // userinfo. A short path or a four-byte username is treated as // a credential exactly like a long one, because the field takes // an arbitrary URL and no part of it can be assumed non-secret — // the same rule MaskURL applies. headerSecrets does carry a // floor, and the difference is deliberate: a header is picked // out by a name-shaped guess and its value may be ordinary // text, whereas a URL's path and userinfo are credential // material by position. func urlSecrets(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } secrets := []string{raw} parsed, err := url.Parse(raw) if err != nil { return secrets } if parsed.Path != "" && parsed.Path != "/" { requestURI := parsed.RequestURI() secrets = append(secrets, requestURI) if escaped := parsed.EscapedPath(); escaped != requestURI { secrets = append(secrets, escaped) } } if parsed.User != nil { secrets = append(secrets, parsed.User.String()) if pw, ok := parsed.User.Password(); ok && pw != "" { secrets = append(secrets, pw) } } return secrets } // minHeaderSecretBytes is the shortest header value treated as // a credential. Unlike a URL path, a header value can be a // couple of bytes long, and redacting those would scatter the // marker through ordinary response text for no gain. const minHeaderSecretBytes = 4 // headerSecrets returns the values of the configured headers // whose names are credential-shaped. func headerSecrets(headers map[string]string) []string { var secrets []string for name, value := range headers { value = strings.TrimSpace(value) if len(value) < minHeaderSecretBytes { continue } if isCredentialHeaderName(name) { secrets = append(secrets, value) } } return secrets } // isCredentialHeaderName classifies a header by its name. The // value is never inspected, so the rule is the same // class-based one MaskURL applies to a destination URL. // // The fragments are short on purpose, and match anywhere in // the name, so abbreviations an operator might use are covered // too: X-Sig, X-Pass, X-HMAC. That over-matches — a header // named X-Design contains "sig" — and over-matching is the // safe direction here: the cost is a marker where an echoed // header value would have rendered. func isCredentialHeaderName(name string) bool { name = strings.ToLower(strings.TrimSpace(name)) // Names that carry a credential by definition. switch name { case "authorization", "proxy-authorization", "cookie": return true } // What operators call their own credential headers: // X-Api-Key, X-Hub-Signature, X-Auth-Token. for _, fragment := range []string{ "auth", "credential", "hmac", "key", "pass", "secret", "sig", "token", } { if strings.Contains(name, fragment) { return true } } return false }