Files
webhooker/internal/delivery/url_mask.go
clawbot 7c43e095a6
All checks were successful
check / check (push) Successful in 9s
Mask the webhook credential in delivery errors and logs (closes #118)
Go embeds the request URL in *url.Error, so any transport failure — DNS,
TLS, refused, timeout, SSRF dial block — persisted the full Slack webhook
URL into the per-webhook SQLite database via DeliveryResult.Error. That
field is tagged json:"error,omitempty", so a future REST API would have
served it.

maskURLError rebuilds the error preserving Op and the wrapped cause, so DNS
vs TLS vs timeout still read differently and errors.Is/As and Timeout()
keep working; only path, query and userinfo are dropped. Applied where the
errors are born, which covers both the Slack and HTTP targets. url.Parse
embeds the URL too, so ValidateTargetURL's parse branch gets the same
treatment.

The SSRF rejection log now logs the masked URL, and source_logs.html
receives view types rather than raw rows, so no config blob is reachable
from that template.

MaskURL is now the single masker for the whole tree.
2026-08-11 15:11:57 +02:00

62 lines
1.8 KiB
Go

package delivery
import (
"errors"
"net/url"
)
// urlPathElision stands in for a URL's elided path.
const urlPathElision = "/..."
// MaskURL renders a URL as scheme plus host with everything
// that can carry a secret removed. A delivery target URL is
// itself a credential — a Slack incoming webhook URL is a
// bearer token — so the path, query and userinfo are never
// reproduced, in a page, a log line or a stored error. A URL
// that does not parse into a scheme and host yields the
// neutral placeholder, never the raw string.
func MaskURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" ||
parsed.Host == "" {
return configUnavailable
}
masked := parsed.Scheme + "://" + parsed.Host
if parsed.Path != "" && parsed.Path != "/" {
masked += urlPathElision
}
return masked
}
// maskURLError strips the credential from an error raised
// against a request URL. The net/http and net/url packages
// embed the full request URL in every *url.Error they return,
// so an unmodified transport error persisted into
// DeliveryResult.Error writes the credential to disk.
//
// The masked error keeps the operation and the wrapped cause,
// so a DNS failure still reads differently from a refused
// connection, a TLS handshake failure or a timeout, and Is,
// As, Timeout and Temporary keep working on it. Only the
// path, query and userinfo of the URL are dropped. Errors
// that carry no URL are returned unchanged.
//
// Call it where the error is raised, before any wrapping: it
// replaces the *url.Error itself, so any context wrapped
// around it first would be discarded.
func maskURLError(err error) error {
var urlErr *url.Error
if !errors.As(err, &urlErr) {
return err
}
return &url.Error{
Op: urlErr.Op,
URL: MaskURL(urlErr.URL),
Err: urlErr.Err,
}
}