Open URLs safely¶
This guide covers the discipline of routing all URL-opening through
OpenURL, handling the two failure kinds, and building mailto: URLs from
user-influenced data without opening a header-injection hole.
Route everything through OpenURL — never the OS opener¶
The security guarantee only holds if there is a single gate. Anywhere your code
needs to open a URL, call OpenURL:
if err := browser.OpenURL(ctx, target); err != nil {
return errors.Wrap(err, "opening documentation URL")
}
Do not reach the OS handler by any other route. In particular, never call:
github.com/cli/browser.OpenURLdirectly, orexec.Command("open" | "xdg-open" | "rundll32", ...).
Each such call is a URL that bypasses the scheme allowlist, the length bound,
and the control-character check. One gate is auditable; scattered openers are
not. OpenURL already delegates to cli/browser internally after validating,
so you lose nothing by going through it.
Handle the two failure kinds¶
OpenURL distinguishes hygiene failures from scheme failures with two sentinel
errors. Match them with errors.Is — never string-compare:
switch err := browser.OpenURL(ctx, target); {
case err == nil:
// opened
case errors.Is(err, browser.ErrDisallowedScheme):
// Parsed, but the scheme is not https/http/mailto. This is usually a
// sign the input is hostile or misconfigured — surface it, do not retry.
return errors.WithHint(err, "only https, http, and mailto URLs can be opened")
case errors.Is(err, browser.ErrInvalidURL):
// Empty, longer than MaxURLLength, control chars, or unparseable.
return errors.WithHint(err, "the URL is malformed")
default:
// Validation passed; the OS opener itself failed (e.g. no browser on a
// headless host). The returned error is already wrapped with context.
return err
}
The context's error is returned instead of opening if ctx is already cancelled
when OpenURL runs. The underlying opener is not context-aware, so cancellation
only takes effect before the open, not after the browser process has spawned.
Do not rely on the error to log the URL¶
OpenURL never logs the URL, and the errors it returns carry a hint but not the
raw URL. If you want to tell the user which URL failed, extract the scheme and
host from your own copy of the string rather than expecting the error to
carry it:
if u, perr := url.Parse(target); perr == nil {
log.Printf("could not open %s://%s", u.Scheme, u.Host)
}
Log the scheme and host, not the full URL — the path and query can carry tokens or personal data.
Building mailto: URLs from user input¶
mailto: is on the allowlist, and OpenURL validates the scheme and overall
shape — but it cannot detect header-injection inside a mailto: URL. A
raw cc=, bcc=, subject=, or body= value taken from user-influenced data
can smuggle in extra headers or recipients.
When you construct a mailto: URL from anything user-influenced, escape every
parameter value with url.QueryEscape before assembling the string:
subject := url.QueryEscape(userSubject)
body := url.QueryEscape(userBody)
target := fmt.Sprintf("mailto:%s?subject=%s&body=%s",
url.QueryEscape(recipient), subject, body)
if err := browser.OpenURL(ctx, target); err != nil {
return errors.Wrap(err, "opening mail client")
}
Escaping is the caller's responsibility — the gate deliberately does not try to
parse and re-encode mailto: bodies for you.
See also¶
- Test without launching a browser — inject a fake opener so tests never spawn a real one.
- Threat model & the validation gate — why the single-gate discipline matters.