Threat model & the validation gate¶
This page explains why browser is shaped the way it is: why every URL open
funnels through a single validation gate, which schemes are dangerous and why
they are blocked, why there is a length bound, why control characters are
rejected, and why callers must never reach the OS opener directly.
The hazard: handing a string to an OS URL handler¶
Opening a URL ultimately means handing a string to a platform handler —
open on macOS, xdg-open on Linux, rundll32 on Windows. Those handlers
dispatch on the URL's scheme, and the set of schemes a desktop registers is
large and open-ended: not just https, but file:, javascript: (in some
contexts), data:, and any number of application-registered custom protocol
handlers.
If the string being opened is influenced by config, a message payload, a CLI flag, or any other data outside the binary, an unchecked open is a real capability leak. The job of this module is to make sure a URL is boring — correct scheme, sane length, no control bytes — before it reaches that handler.
Why a single gate¶
The security property only holds if there is exactly one way to reach the OS
handler. OpenURL is that one way. Every caller in the codebase routes through
it, which means:
- there is a single place to reason about, audit, and test the validation rules; and
- adding a new URL-opening feature cannot silently reintroduce an unchecked path, because the only opener available to callers is the validated one.
Scatter direct cli/browser or exec.Command("open", …) calls around, and each
is a hole that must be found and re-audited independently. One gate collapses
that to one review. This is why the discipline — never call the OS opener
directly — is as important as the code: the code cannot enforce what callers
refuse to route through it.
Defence 1 — the scheme allowlist¶
Only https, http, and mailto are permitted; everything else is rejected
with ErrDisallowedScheme. The allowlist is a closed set for a reason. The
schemes it deliberately excludes are the dangerous ones:
file://— reads local files; an attacker-chosenfile:URL can exfiltrate or probe the local filesystem via the browser.javascript:— executes script in the context of whatever opens it.data:— inlines arbitrary content (HTML, script) with no origin.- custom protocol handlers — any app-registered scheme (
slack:,steam:, bespoke internal handlers) can trigger arbitrary local application behaviour with attacker-controlled arguments.
The comparison is case-insensitive per RFC 3986 (HTTPS, Https, and https
are the same scheme). The allowlist is an unexported package variable, so no
caller can widen it at runtime; AllowedSchemes() returns a fresh copy on every
call, and mutating that copy has no effect on validation. Extending the set
requires a code change and a security review — it is intentionally not a
configuration knob.
Defence 2 — the length bound¶
URLs longer than MaxURLLength (8192 bytes) are rejected with ErrInvalidURL.
The value is chosen conservatively to sit below every supported platform's
command-line length limit — Windows is roughly 32 KB, Linux's ARG_MAX is
around 128 KB, macOS around 256 KB — so a single constant is safe everywhere.
8 KiB is still generous for legitimate URLs, including mailto: links with long
bodies. The bound keeps a pathologically long, attacker-supplied string from
being pushed into a platform handler's argument vector.
Defence 3 — control-character rejection¶
Any ASCII control character — the range 0x00–0x1F (which includes the NUL
byte) and 0x7F — causes the URL to be rejected with ErrInvalidURL before it
reaches any platform handler. Control characters and embedded NULs are exactly
the bytes that can truncate, split, or otherwise confuse argument parsing in the
downstream open / xdg-open / rundll32 invocation. Rejecting them at the
boundary means the handler only ever sees printable, well-formed input. Empty
and unparseable URLs fail the same ErrInvalidURL way.
Validation order¶
The checks run fail-fast in a deliberate order, cheapest and most security-relevant first:
- Non-empty — an empty URL is
ErrInvalidURL. - Length ≤
MaxURLLength— reject oversize before doing any more work. - No control characters — reject
0x00–0x1F/0x7Fbefore parsing. net/url.Parsesucceeds — a parse failure isErrInvalidURL.- Scheme is on the allowlist (case-insensitive) — else
ErrDisallowedScheme.
Only after all five pass does OpenURL check the context for cancellation and
then invoke the opener.
What the gate does not do¶
Two responsibilities stay with the caller:
mailto:header injection.OpenURLvalidates the scheme and the URL's overall shape, but it cannot detect a maliciouscc=,bcc=,subject=, orbody=parameter smuggled into amailto:URL. Callers buildingmailto:URLs from user-influenced data musturl.QueryEscapeevery parameter value themselves (see Open URLs safely).- Context after the open. The underlying opener is not context-aware. Once
the OS has spawned a browser or mail client,
OpenURLcannot cancel it. The context is honoured only for pre-open cancellation — if the enclosing operation was cancelled before the opener ran, the open is skipped and the context's error is returned.
Framework-free by design¶
The opener is an injectable Opener seam (func(rawURL string) error), swapped
via WithOpener, so tests never launch a real browser and unusual deployments
can integrate a custom handler — all behind the same validation gate. The
default delegates to github.com/cli/browser, which selects the platform
handler and invokes it via exec.Command with no shell interpolation.
The dependency graph is kept to just cli/browser and cockroachdb/errors, and
a depfootprint_test.go guard fails the build if any other dependency enters
it. That keeps the gate cheap to adopt anywhere, including in projects that want
nothing to do with a larger framework. go-tool-base routes all of its
URL-opening through this module; the framework wiring lives there, not here.