All checks were successful
check / check (push) Successful in 3m30s
A delivery target URL is itself a credential: a Slack incoming webhook URL is a bearer token. Three paths still reproduced it in full. Transport failures were the worst of them. net/http embeds the request URL in every *url.Error it returns, so any DNS, TLS, timeout or dial failure wrote the whole webhook URL into DeliveryResult.Error — on disk, in the per-webhook database, behind a json tag that a REST API would serialize. maskURL moves to url_mask.go and is exported as MaskURL, and maskURLError joins it: it rebuilds the *url.Error with the URL masked, keeping the operation and the wrapped cause, so a refused connection still reads differently from a DNS failure or a timeout and errors.Is/As/Timeout still work. It is applied where the errors are raised — executeHTTPRequest, shared by the Slack and HTTP targets, and the request-construction paths — so downstream wrapping is safe by construction. url.Parse embeds the URL too, so ValidateTargetURL's parse branch gets the same treatment; its error is logged and shown. The SSRF rejection log now records only the masked URL, and loadTargetMap hands the event log page TargetViews and a delivery projection instead of raw target rows, so the stored config blob has no path to that template either.
62 lines
1.8 KiB
Go
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,
|
|
}
|
|
}
|