Validation rules — which URLs are accepted¶
The complete set of checks OpenURL applies, the order it applies them in, and
worked examples of what each one accepts and rejects. Every example on this page
was run against the current implementation.
The five checks, in order¶
Validation is fail-fast: the first check that fails returns immediately and the rest never run. That ordering decides which error you get when a URL breaks more than one rule.
| # | Check | Rejects with |
|---|---|---|
| 1 | URL is not empty | ErrInvalidURL |
| 2 | len(rawURL) ≤ MaxURLLength (8192 bytes) |
ErrInvalidURL |
| 3 | No ASCII control character (0x00–0x1F, 0x7F) anywhere in the string |
ErrInvalidURL |
| 4 | net/url.Parse succeeds |
ErrInvalidURL |
| 5 | Parsed scheme is https, http, or mailto, compared case-insensitively |
ErrDisallowedScheme |
Only after all five pass is the context checked, and only then is the opener
called. A URL with a disallowed scheme and an already-cancelled context returns
ErrDisallowedScheme, not context.Canceled.
What counts as a control character¶
The rejected set is 0x00–0x1F inclusive, plus 0x7F (DEL). That covers NUL,
tab (0x09), CR (0x0D), and LF (0x0A). It is a byte-range test applied to
every rune in the string, wherever it appears — scheme, host, path, query, or
fragment.
Space (0x20) is not a control character and is not rejected by this check.
A space can still fail the URL at check 4 if it lands somewhere net/url.Parse
refuses it, such as inside the host. A space inside the path does not:
https://example.com/a b is accepted and reaches the opener unchanged.
Nothing above 0x7F is rejected here. Non-ASCII characters, including bidi
control characters such as U+202E, pass this check.
How the length limit is counted¶
MaxURLLength is 8192 bytes, measured with len() on the raw string before
any parsing. Multi-byte characters count for every byte they occupy: a URL of
4,110 characters, most of them two-byte runes, measures 8,200 bytes and is
rejected despite being well under 8,192 characters.
The URL at exactly 8192 bytes is accepted; 8193 is not.
Why example.com is a scheme error, not a parse error¶
net/url.Parse is permissive. It accepts a string with no scheme at all,
returning an empty Scheme rather than an error, so the failure surfaces one
check later as ErrDisallowedScheme with scheme "" is not permitted.
This catches people out, because "example.com is not a valid URL" feels like a
hygiene problem. In this package it is a scheme problem, and the error you match
on is ErrDisallowedScheme. The same is true of //example.com/path and
/just/a/path.
If you accept bare hostnames from users, prefix a scheme yourself before calling
OpenURL — the gate will not do it for you, because guessing https:// for an
attacker-supplied string is exactly the kind of rewriting a validation boundary
should not be doing.
Worked examples — accepted¶
| URL | Why it passes |
|---|---|
https://example.com |
The ordinary case. |
https://example.com/path?q=1&r=2#frag |
Query and fragment are not inspected. |
HTTPS://EXAMPLE.COM |
Scheme comparison is case-insensitive per RFC 3986. |
Https://example.com |
Same. |
http://localhost:8080 |
http is on the allowlist; the host is not checked. |
http://[::1]:8080/ |
IPv6 literal, parsed and passed through. |
https://user:pass@example.com |
Userinfo is not inspected or stripped. |
https://exámple.com/ünicode |
Non-ASCII is not a control character. |
https://example.com/a b |
A space in the path parses fine. |
https: |
Scheme-only. net/url.Parse accepts it and the scheme is allowed. |
https:// |
No host at all. Accepted for the same reason. |
mailto: |
Accepted with no address; mailto: bodies are never parsed. |
| An 8192-byte URL | Exactly at the cap. |
https:, https:// and mailto: are the surprising ones: the gate does not
require a host, an address, or anything at all after the scheme. It checks the
scheme and the string's shape, not whether opening the URL will achieve
anything.
Worked examples — rejected with ErrDisallowedScheme¶
| URL | Parsed scheme |
|---|---|
file:///etc/passwd |
file |
javascript:alert(1) |
javascript |
data:text/html,<h1>hi</h1> |
data |
vbscript:MsgBox("hi") |
vbscript |
ftp://ftp.example.com/ |
ftp |
tel:+441234 |
tel |
myapp://callback |
myapp |
example.com |
(empty) |
//example.com/path |
(empty) |
The attached hint names the offending scheme and lists the permitted ones.
Worked examples — rejected with ErrInvalidURL¶
| URL | Which check caught it |
|---|---|
"" |
1 — empty |
| 8193 bytes or more | 2 — length |
https://example.com/\x00path |
3 — NUL |
https://example.com/\r\npath |
3 — CR and LF |
https://example.com/\tpath |
3 — tab |
https://example.com/\x7f |
3 — DEL |
://bad |
4 — missing protocol scheme |
https://example.com |
4 — leading spaces make the parse fail |
https://example.com |
4 — trailing spaces land in the host |
https:// |
4 — space in the host |
h%74tps://example.com |
4 — percent-encoding does not smuggle a scheme past the check |
http://[::1:8080/ |
4 — unclosed IPv6 bracket |
Does validation modify the URL?¶
No. OpenURL parses the URL to read its scheme and throws the parsed value away;
the opener receives the exact string that was passed in, byte for byte. There is
no normalisation, no percent-encoding, no trailing-slash handling, and no
punycode conversion.
That matters if you are comparing what your code asked for against what the browser eventually loaded: any difference came from the platform handler or the browser, not from here.
See also¶
- Errors — the error each rejection produces, and its hint text.
- API reference — the symbols these rules belong to.
- Threat model & the validation gate — why these five checks and not others.