Getting started¶
This tutorial walks you through your first safe URL open, end to end. You will
open a valid https URL, then deliberately try a dangerous one and see the
validation gate reject it — and learn how to tell the two failure kinds apart.
Everything happens in one self-contained Go program so you can see each moving
part. By the end you will understand what OpenURL guarantees before it ever
reaches the operating system.
Prerequisites¶
- Go 1.26 or newer.
- A new module to experiment in:
mkdir browser-tutorial && cd browser-tutorial
go mod init example.test/browser-tutorial
go get gitlab.com/phpboyscout/go/browser
Step 1 — Open a valid URL¶
OpenURL takes a context.Context, the raw URL string, and zero or more
options. With a well-formed https URL it validates, then hands the URL to the
OS handler for the user's default browser:
ctx := context.Background()
if err := browser.OpenURL(ctx, "https://example.com/docs"); err != nil {
log.Fatalf("open failed: %v", err)
}
The context is respected up to the moment of opening: if it is already cancelled
when OpenURL runs, the open is skipped and the context's error is returned.
The opener itself is not context-aware — once the OS has spawned a browser,
there is nothing left to cancel.
Step 2 — Watch a dangerous scheme get rejected¶
Now try a scheme that is not on the allowlist. javascript: is exactly the
kind of URL you never want reaching an OS handler:
err := browser.OpenURL(ctx, "javascript:alert(1)")
// err is non-nil, and errors.Is(err, browser.ErrDisallowedScheme) is true.
Only https, http, and mailto pass the gate. file://, data:,
javascript:, and custom protocol handlers all fail with
ErrDisallowedScheme. You can read the current allowlist at runtime:
Step 3 — Tell the two failure kinds apart¶
OpenURL returns two sentinel errors, and you match them with errors.Is:
ErrDisallowedScheme— the URL parsed fine, but its scheme is not permitted.ErrInvalidURL— the URL failed hygiene: empty, longer thanMaxURLLength(8192 bytes), contained an ASCII control character, or could not be parsed.
switch {
case errors.Is(err, browser.ErrDisallowedScheme):
fmt.Println("scheme not allowed — only https, http, mailto")
case errors.Is(err, browser.ErrInvalidURL):
fmt.Println("malformed URL — empty, too long, control chars, or unparseable")
case err != nil:
fmt.Println("the OS opener failed:", err)
}
The complete program¶
package main
import (
"context"
"fmt"
"github.com/cockroachdb/errors"
"gitlab.com/phpboyscout/go/browser"
)
func main() {
ctx := context.Background()
// A valid URL opens in the default browser.
if err := browser.OpenURL(ctx, "https://example.com/docs"); err != nil {
reportOpenError(err)
}
// A disallowed scheme is rejected before the OS ever sees it.
if err := browser.OpenURL(ctx, "javascript:alert(1)"); err != nil {
reportOpenError(err)
}
}
func reportOpenError(err error) {
switch {
case errors.Is(err, browser.ErrDisallowedScheme):
fmt.Println("scheme not allowed — only", browser.AllowedSchemes())
case errors.Is(err, browser.ErrInvalidURL):
fmt.Println("malformed URL:", err)
default:
fmt.Println("OS opener failed:", err)
}
}
Run it:
Success criterion¶
The first OpenURL call opens https://example.com/docs in your default
browser (or returns an OS-opener error if you are on a headless machine with no
browser). The second call prints scheme not allowed — [https http mailto] and
opens nothing. If you never see a browser launch for the javascript: URL, the
gate is doing its job.
Next steps¶
- Make every URL in your codebase go through the gate: Open URLs safely.
- Keep tests from launching real browsers: Test without launching a browser.
- Understand why the gate is shaped the way it is: Threat model & the validation gate.