|
| 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