Skip to content

Commit 1d5ef5c

Browse files
committed
note: ipv6 zones suck lol
Signed-off-by: Xe Iaso <me@xeiaso.net>
1 parent 3c8cb59 commit 1d5ef5c

1 file changed

Lines changed: 100 additions & 0 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
---
2+
title: "IPv6 zones in URLs are a mistake"
3+
desc: "Run away while you still can, it's not too late for you to avoid the curse of knowledge."
4+
date: 2026-06-05
5+
---
6+
7+
IPv6 is weird. One of the more strange parts of the standard is that every interface's link local addresses are in `fe80::whatever`. If you have a machine with two network interfaces, both of them will be in `fe80::`, so if you have a packet destined to `fe80::4`, how do you disambiguate it?
8+
9+
The answer is you use [IPv6 scopes/zones](<https://en.wikipedia.org/wiki/IPv6_address#Scoped_literal_IPv6_addresses_(with_zone_index)>). The exact format of what goes into a zone is OS dependent, but on Linux it's the interface name and on Windows it's the interface ID. This lets the kernel's routing table know how to handle an address range conflict.
10+
11+
On my tower, this would be represented like this:
12+
13+
```text
14+
fe80::4%eth0
15+
```
16+
17+
Where `eth0` is the name of my tower's ethernet device.
18+
19+
When you create a host:port bindhost, you normally separate the hostname and port with a colon. IPv6 uses colons to separate hex groups. In order to disambiguate what's the host and what's the port, you typically format the IPv6 address in square brackets, so `fe80::4` on port 80 would look like this:
20+
21+
```text
22+
[fe80::4]:80
23+
```
24+
25+
And with the right scope it looks like this:
26+
27+
```text
28+
[fe80::4%eth0]:80
29+
```
30+
31+
Now let's get URL encoding into the mix. From high orbit, you can imagine a URL's format as being something like this:
32+
33+
```
34+
<scheme>:[//][<username>[:<password>]@][<hostname>][:<port>][/<path>][?<query>][#<fragment>]
35+
```
36+
37+
An IPv6 zone would then be part of the hostname, just like with that `fe80::4` port 80 example from earlier. So you'd think the URL would be something like this:
38+
39+
```text
40+
http://[fe80::4%eth0]:80
41+
```
42+
43+
But if you try to parse this as a URL in Go, you get an error:
44+
45+
```go
46+
package main
47+
48+
import "net/url"
49+
50+
func main() {
51+
if _, err := url.Parse("http://[fe80::4%eth0]:80"); err != nil {
52+
panic(err)
53+
}
54+
}
55+
```
56+
57+
Yields:
58+
59+
```text
60+
panic: parse "http://[fe80::4%eth0]:80": invalid URL escape "%et"
61+
```
62+
63+
This happens because URLs can't represent all Unicode values, so any values that don't fit into the grammar of a URL become [percent-encoded](https://en.wikipedia.org/wiki/Percent-encoding). This is why sometimes you'll see a `%20` in URLs in the wild; that's encoding the ascii space key, which is invalid in URLs.
64+
65+
In order to work around this, you need to percent-encode the percent sign in the IPv6 zone:
66+
67+
```go
68+
package main
69+
70+
import (
71+
"fmt"
72+
"net/url"
73+
)
74+
75+
func main() {
76+
u, err := url.Parse("http://[fe80::4%25eth0]:80")
77+
if err != nil {
78+
panic(err)
79+
}
80+
fmt.Println(u.Hostname())
81+
}
82+
```
83+
84+
Yields:
85+
86+
```text
87+
fe80::4%eth0
88+
```
89+
90+
In theory, there is guidance for how to properly handle IPv6 zones in user interfaces in [RFC 9884](https://www.rfc-editor.org/rfc/rfc9844.txt), but there's no such guidance for URLs. Go also does not seem to follow this RFC in [net/url](https://pkg.go.dev).
91+
92+
So in the meantime in order for Anubis to point to IPv6 zoned addresses, you need to encode the `%` with percent encoding. This is horrible, but it seems that this is an edge case that applies to other frameworks, programming languages, and libraries:
93+
94+
- https://trac.nginx.org/nginx/ticket/623
95+
- https://github.com/psf/requests/issues/6808
96+
- https://datatracker.ietf.org/doc/html/draft-schinazi-httpbis-link-local-uri-bcp-03 -- Browsers don't currently support IPv6 zones because it breaks the concept of an "origin" which is used for many subtle things, this RFC draft attempts to define an zone origin in IPv6 so that browsers have a leg to stand on
97+
98+
Maybe some day in the future there will be a better option here. In the meantime my policy of not forking the Go standard library means that this somewhat terrible UX for an edge case is acceptable. I hate it, but what can you do?
99+
100+
TL;DR: computers were a mistake.

0 commit comments

Comments
 (0)