Browser โ Safe URL Opening¶
pkg/browser is the single entry point for opening URLs in the user's default browser or mail client. Every URL-opening code path in GTB โ and in tools built on GTB โ must route through it rather than invoking the OS handler directly (via github.com/cli/browser.OpenURL, exec.Command("open"|"xdg-open"|"rundll32"), or equivalent).
The package exists to guarantee four invariants that cannot be enforced after a URL has reached the OS:
- Scheme allowlist โ only
https,http,mailto. Dangerous schemes (file:,javascript:,data:,vbscript:, custom protocol handlers) are rejected. - Length bound โ
MaxURLLength = 8192bytes, below the command-line length limit of every supported platform. - Hygiene โ ASCII control characters (0x00โ0x1F, 0x7F) and NUL bytes are rejected.
- No user logging โ the URL is never written to any log surface above DEBUG. Callers surfacing errors to users should reconstruct only the scheme or host from their own copy of
rawURL.
API¶
import "gitlab.com/phpboyscout/go-tool-base/pkg/browser"
err := browser.OpenURL(ctx, "https://example.com")
Validation order¶
Fail-fast in this order:
- Length โค
MaxURLLength - No ASCII control characters (0x00โ0x1F, 0x7F)
net/url.Parsesucceeds- Scheme matches
AllowedSchemes(case-insensitive per RFC 3986) - Context not cancelled
Each failure returns a typed sentinel:
| Failure | Error |
|---|---|
| Empty, too long, control chars, parse failure | ErrInvalidURL |
| Disallowed scheme | ErrDisallowedScheme |
| Context cancelled before opener invoked | ctx.Err() |
| OS URL handler failed | Wrapped underlying error |
Callers can distinguish via errors.Is.
Options¶
OpenURL accepts zero or more Option values. The primary use is testing:
err := browser.OpenURL(ctx, url, browser.WithOpener(func(raw string) error {
// Record the URL for the test to verify.
return nil
}))
WithOpener(nil) is a no-op โ the default opener (github.com/cli/browser.OpenURL) is retained. When multiple WithOpener options are supplied, the last non-nil one wins.
WithOpener is also the extension point for tools that need a custom OS integration (e.g. a sandboxed browser on a kiosk device).
mailto: and caller responsibility¶
OpenURL validates only the scheme and the URL's overall shape. It does not protect against mailto: header injection โ an attacker-controlled subject or body containing &cc= or CR/LF sequences that, if not properly encoded, would add unintended recipients or headers to the resulting email.
Callers constructing mailto: URLs from user-influenced data must url.QueryEscape every parameter value. See EmailDeletionRequestor in pkg/telemetry for the canonical pattern, and the accompanying TestEmailDeletionRequestor_CannotInjectHeaders test for the caller-contract assertion.
Why this package¶
Before this package, two call sites in the codebase opened URLs inconsistently:
pkg/telemetry/deletion.gohad a privateopenURLthat shelled out toopen/xdg-open/rundll32with no scheme validation.pkg/cmd/docs/serve.godelegated togithub.com/cli/browser.OpenURL, also with no scheme validation.
The security audit (2026-04-02, M-5) flagged both paths. This package consolidates them behind a single validated entry point. The underlying OS invocation is still delegated to github.com/cli/browser โ it has robust cross-platform coverage โ but validation happens in GTB before the URL reaches it.
Threat model¶
| Threat | Mitigation |
|---|---|
Arbitrary scheme โ arbitrary handler (file:, javascript:, etc.) |
Scheme allowlist |
| Control characters confusing a platform URL handler | Control-char rejection |
| Oversize URL exceeding OS command-line limit | MaxURLLength check |
| Credential-bearing URL logged via error messages | Package never logs rawURL above DEBUG |
mailto: header injection |
Caller contract (documented + tested in callers) |
Command injection via exec.Command |
N/A โ exec.Command in cli/browser does not invoke a shell |
Fuzz testing¶
FuzzOpenURL feeds arbitrary bytes into OpenURL and asserts every outcome falls into one of the documented categories โ accepted (opener invoked with exact URL), ErrInvalidURL, ErrDisallowedScheme, or a context-cancel error. Panics fail the fuzz. Run locally with:
See also¶
- Spec:
docs/development/specs/2026-04-02-url-scheme-validation.md - Security audit:
docs/development/reports/security-audit-2026-04-02.md(finding M-5)