Mask the target URL in delivery errors, SSRF logs and log page data (closes #118)
All checks were successful
check / check (push) Successful in 3m30s
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.
This commit is contained in:
61
internal/delivery/url_mask.go
Normal file
61
internal/delivery/url_mask.go
Normal file
@@ -0,0 +1,61 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user