Skip to content

What browser does not do

The gate is narrow on purpose, and a narrow guarantee is only useful if its edges are written down. This page is the list of things people reasonably expect from a module called browser that it does not do — and, where the omission is a decision rather than an oversight, why.

The short version: it checks that a URL is boring before the operating system sees it. It does not judge where the URL points, what happens after it is opened, or whether opening it was a good idea.

Can I add a scheme to the allowlist?

No. Not through configuration, not through an option, not through an environment variable. https, http, and mailto are the whole set, and widening it means changing this module's source and passing a security review.

This is the most common request and the answer will not change, because a configurable allowlist would defeat the point. The value of a single validation gate is that reading one function tells you every scheme that can reach the operating system in the entire program. The moment a config key can add myapp://, that guarantee becomes "whatever the deployment's config file says", and the audit has to follow the config instead of the code.

If you genuinely need a custom scheme — an internal protocol handler, a desktop-app deep link — supply your own Opener and route that scheme around this module entirely, with your own justification for it. Do not try to smuggle it through the gate.

Does it check where the URL points?

No. Once the scheme is on the allowlist, the host is not examined at all. There is no host allowlist, no denylist, no DNS resolution, no check for private or loopback addresses, and no reputation or safe-browsing lookup.

http://localhost:8080, http://169.254.169.254/latest/meta-data/, and https://an-attacker-controls-this.example all pass. So does an internationalised domain that looks exactly like your bank's, and so does a URL containing a right-to-left override that makes it display as something else.

The reasoning is that this is a boundary check on a string, not a policy engine about destinations. Deciding which hosts a user may visit depends entirely on the application — a documentation link, an OAuth redirect and a user-submitted link want three different policies — so it belongs in the application. What the gate promises is narrower and checkable: the operating system will never be asked to dispatch a scheme you did not intend.

Note that this is a different bar from the one an outbound HTTP client has to meet. Nothing here fetches the URL; a human's browser does, in a sandbox, under their control. Server-side request forgery is not the threat being defended against.

Does a URL have to have a host?

No, and this surprises people. https: and https:// are both accepted, because net/url.Parse accepts them and their scheme is on the list. So is mailto: with no address.

The gate's question is "could this string dispatch something dangerous", not "will this URL work". A hostless https: URL is harmless — the browser opens and does nothing useful. If your application needs a real destination, check for one before calling OpenURL.

The same goes for userinfo: https://user:pass@example.com passes untouched. The credentials are not stripped, redacted, or warned about.

Does it protect mailto: from header injection?

No, and this is the sharpest edge in the module. mailto: is on the allowlist, but the gate never parses what comes after it — subject=, body=, cc=, and bcc= are opaque to it. A bcc= smuggled in from user-influenced data reaches the mail client exactly as written.

Escaping every parameter with url.QueryEscape is the caller's job, every time, and there is a worked example in Open URLs safely.

It stays the caller's job because doing it here would mean parsing and re-encoding the URL, and the gate deliberately hands the opener the exact bytes it was given. A module that silently rewrote your URL would be harder to reason about than one that refuses to.

Can I cancel an open once it has started?

No. The context is checked once, immediately before the opener runs. After that there is nothing to cancel: a browser process belongs to the operating system's session, not to your program, and no platform handler offers a "close that window again" call.

Opener has no context.Context parameter for the same reason — a signature that accepted one would imply a cancellation it cannot deliver.

Does it tell me whether the page opened?

Only whether the launcher reported success. A nil return means the platform handler accepted the URL and exited without error. It does not mean a browser window appeared, the page loaded, the user noticed it, or the URL resolved.

There is no callback, no window handle, and no way to poll. If your workflow depends on the user actually arriving somewhere — an OAuth flow, say — you need the other end of that flow to tell you, not this module. Printing the URL alongside the open is a cheap safeguard: it costs a line and rescues every user whose default browser is broken.

Can I open a local file or an HTML string?

Not through this module. cli/browser, the dependency underneath, offers OpenFile and OpenReader; neither is re-exported here, and OpenFile produces a file:// URL that the allowlist would reject anyway.

That is the intended outcome rather than a gap. file:// is on the excluded list precisely because an attacker-chosen local path is a disclosure risk, and a convenience wrapper that produced one would be a hole in the same fence. If you need to show a user a local HTML file, write it somewhere they can open themselves and tell them where it is.

Can I turn opening off globally?

No. There is no global switch, no environment variable, and no package-level opener to replace. WithOpener applies to the single call it is passed to.

A --no-browser flag is a normal thing for a CLI to want, and it belongs in the CLI: check the flag and print the URL instead of calling OpenURL. Keeping the decision at the call site is what stops one part of a program disabling opening for another part that was relying on it — and it is why parallel tests using WithOpener cannot interfere with each other.

Does it log anything?

Nothing. No logger, no telemetry, no metrics, no counter of opens. The module does not import a logging library and none of its errors contain the URL.

If you want an audit trail of URLs opened, wrap OpenURL in your own function and log there — where you can decide how much of the URL is safe to record. Logging the scheme and host, and not the path or query, is the usual answer, because paths and queries carry tokens.

Is this a browser automation library?

No. It opens a URL and stops. It cannot drive a page, read a page, fill a form, take a screenshot, or wait for anything. Nothing in it inspects the browser after launch, and there is no headless mode.

If you need to control a browser, you want a WebDriver or CDP client. If you need to fetch a URL, you want net/http. This module exists for the single moment where a program hands a URL to the human's own browser.

What is not regression-guarded here

Two things this documentation describes are not held in place by a test in this repository:

  • Per-platform opener behaviour. Every test injects a fake opener, so the default path — open, xdg-open, ShellExecute — is never exercised. A cli/browser upgrade could change what runs on Windows or which Linux providers are tried without any test here noticing. See Platform behaviour.
  • The caller discipline. Nothing in this module can stop a caller invoking exec.Command("open", url) directly and bypassing the gate entirely. The guarantee is only as good as the codebase's habit of routing through OpenURL, which is a review and lint concern, not something the code enforces.

See also