Skip to content

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-chosen file: 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 0x000x1F (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:

  1. Non-empty — an empty URL is ErrInvalidURL.
  2. LengthMaxURLLength — reject oversize before doing any more work.
  3. No control characters — reject 0x000x1F / 0x7F before parsing.
  4. net/url.Parse succeeds — a parse failure is ErrInvalidURL.
  5. 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. OpenURL validates the scheme and the URL's overall shape, but it cannot detect a malicious cc=, bcc=, subject=, or body= parameter smuggled into a mailto: URL. Callers building mailto: URLs from user-influenced data must url.QueryEscape every 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, OpenURL cannot 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.