check / check (push) Waiting to run
The Redactor treated a target URL's request URI as a secret only when the URL had a path other than "/", so a response echoing the request line for https://example.com/?token=... or https://example.com?token=... showed the token on the event log and the event's page. urlSecrets now treats the query string, and the request URI that carries it, as secrets whenever the URL has one, whatever its path. With the event's query string passed on, only the target's own part is masked. Model: opus-5-5
276 lines
7.7 KiB
Go
276 lines
7.7 KiB
Go
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; its
|
|
// path, unless that is empty or "/"; its query string, and the
|
|
// request URI that carries it, which a remote echoing the
|
|
// request line shows even when the URL has no path; and its
|
|
// userinfo and password.
|
|
//
|
|
// No length floor is applied to the path, the query string or
|
|
// 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,
|
|
// query string 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 != "/" {
|
|
secrets = append(secrets, parsed.EscapedPath())
|
|
}
|
|
|
|
if parsed.RawQuery != "" {
|
|
secrets = append(secrets, parsed.RequestURI(), parsed.RawQuery)
|
|
}
|
|
|
|
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
|
|
}
|