6 min read

IPv6 address normalization for approval targets

IPv6 address normalization makes approval targets readable and comparable across bracketed URLs, mapped IPv4 forms, compressed zeros, and zones.

IPv6 address normalization for approval targets

An approval screen that shows a raw IPv6 string asks a human to do parser work under time pressure. That is a design mistake. The system must parse the request into a typed endpoint, reject ambiguity, compare the typed value, and display one stable form that a person can recognize on the next request.

IPv6 notation gives attackers and ordinary software plenty of ways to make the same destination look different: compressed zero fields, leading zeros, brackets required by URLs, an embedded IPv4 tail, and interface zones. None of that makes IPv6 suspect. It does mean that an approval target needs stricter handling than a field that happens to contain a hostname.

One address can arrive wearing several strings

The strings 2001:db8:0:0:0:0:0:9, 2001:0db8::9, and 2001:db8::9 identify the same unscoped IPv6 address. If your approval card stores one string and a later request supplies another, a string comparison says they differ. If your allowlist accepts one spelling but audit search expects another, operators lose the trail precisely when they need it.

IPv6 has eight 16-bit fields. Authors may omit leading zeroes in each field, then replace one consecutive run of all-zero fields with ::. The :: marker is convenient for humans, but it removes information about how many fields the writer omitted. A parser restores the missing fields and returns the only representation that matters for an equality decision: 16 address bytes.

This distinction is routinely blurred: canonical text is for review, while parsed bytes are for comparison. Canonicalization alone is not authorization. It simply ensures that the form shown to a human does not depend on the caller's spelling.

Treat these as separate data items:

  • raw_input: the exact host text and surrounding authority syntax received from the caller
  • address: 16 bytes after strict parsing
  • scope_id: an interface scope when the input lawfully supplied one
  • display_host: a canonical string generated from the typed address
  • port, scheme, and request details: fields that describe the actual action

Keep the raw input in the audit record. It explains what the agent asked for. Do not use it as the comparison token, and do not use it as the only thing on screen.

URI brackets are syntax, not part of the host

An IPv6 literal inside a URI authority must be bracketed because colons already separate a host from a port. RFC 3986 defines the form: https://[2001:db8::9]:8443/v1/jobs. The address is 2001:db8::9; the brackets tell the URI parser where that host ends.

That rule produces a common failure. A developer passes [2001:db8::9] to an address parser, gets a rejection, strips characters until it works, and later accepts a malformed authority because the two parsers no longer agree. The other version of the same bug stores brackets alongside the address, so one record has [2001:db8::9] and another has 2001:db8::9. They should never have reached the same storage field.

Parse at the grammar boundary where the value arrived. For an HTTP target, first parse the full URI with a standards-aware URI parser. Extract its host, port, scheme, path, and query according to that parser. Then send only the host value, without URI brackets, to the IPv6 parser. For a direct SSH host argument, use the grammar documented by the SSH command interface rather than pretending it is a URI.

The order matters. Consider this request:

https://[2001:0db8:0:0:0:0:0:9]:8443/admin

A correct internal result has this shape:

kind: ipv6
address: 20010db8000000000000000000000009
scope_id: null
display_host: 2001:db8::9
scheme: https
port: 8443
path: /admin

An approval UI can now say https://[2001:db8::9]:8443/admin. It should not quietly collapse that into 2001:db8::9, because the port and path are part of what the user evaluates. Conversely, it should not preserve the caller's padded spelling merely because it arrived first.

A bare literal has no brackets. A URI authority with an IPv6 literal has brackets. Keep that rule narrow and predictable.

Canonical display should follow RFC 5952

RFC 5952 recommends lowercase hexadecimal, no leading zeroes in a field, and :: for the longest consecutive run of zero fields. When two zero runs have equal length, it selects the first. It also says that a single zero field should not use ::. Those rules give a human-facing form a stable result.

For example, normalize these values as follows:

2001:0DB8:0000:0000:0000:0000:0000:0009  ->  2001:db8::9
2001:db8:0:1:0:0:0:1                    ->  2001:db8:0:1::1
2001:db8:0:1:0:0:0:0                    ->  2001:db8:0:1::
2001:db8:0:1:0:0:0:2                    ->  2001:db8:0:1::2
0:0:0:0:0:0:0:1                         ->  ::1

The standard's advice is more useful than it looks. It gives operators one spelling to search for in logs and stops a caller from making 2001:0DB8::9 look like a novel destination after 2001:db8::9 was already approved.

Do not write your own formatter by splitting on colons and counting empty strings. The IPv4 tail form, malformed double compression, and scope syntax make that approach brittle. Use a tested IPv6 parser in the language that performs the action, retain its 16-byte output, and format from those bytes. Test the formatter against RFC 5952 examples plus cases that have equal-length zero runs.

Do not overstate RFC 5952 either. It describes a recommended text representation. It does not decide whether ::ffff:192.0.2.7 and 192.0.2.7 should receive the same authorization. That is a product decision with real consequences.

IPv4-mapped forms need an explicit comparison rule

::ffff:192.0.2.7 is an IPv4-mapped IPv6 address. The final 32 bits carry an IPv4 address, and the preceding pattern identifies the mapped form. Operating systems often expose this form when an application accepts IPv4 connections on an IPv6 socket. It shows up in logs often enough that treating it as a strange edge case guarantees confusion later.

There are two defensible internal models. Choose one for each authorization boundary and document it in the UI.

The first model preserves address families. ::ffff:192.0.2.7 remains a 16-byte IPv6 address with kind ipv6, while 192.0.2.7 remains a four-byte IPv4 address with kind ipv4. They never compare equal. This is the safer default when an approval describes a network connection requested in a particular form, because it avoids silently widening a decision across families.

The second model projects mapped addresses into IPv4 for a narrowly defined peer-identity use case. In that model, the parser records both the original family and an embedded_ipv4 value. Comparison code deliberately says that a mapped form equals its embedded IPv4 address for this one purpose. The audit record still preserves the raw form and explains that the comparison used a projection.

What fails is accidental projection. Many standard libraries offer a convenience method that turns a mapped address into IPv4 and returns no distinction about how it arrived. That is handy for connection logging; it is dangerous if a developer reuses it for an approval token. A rule written for 192.0.2.7 can then approve ::ffff:192.0.2.7 without anyone deciding that it should.

Use test pairs that make the choice visible:

input A: 192.0.2.7
input B: ::ffff:192.0.2.7

strict endpoint comparison: different
explicit peer-identity projection: same IPv4 peer, if the product says so

Do not treat every IPv6 string with a dotted tail as mapped. The parser must validate the full prefix and the placement of the IPv4 portion. RFC 4291 defines IPv4-mapped addresses, and it also permits IPv4-compatible notation in IPv6 text. Your formatter should preserve enough typed information to avoid calling all dotted-tail addresses the same thing.

Zone identifiers belong to a local interface

Verify the trail offline
Verify the encrypted audit chain offline with sp audit verify, without a vault key.

A zone identifier turns an otherwise ambiguous scoped address into a usable local destination. fe80::1%en0 means a link-local address on the interface named en0. Without the zone, a host with more than one network interface cannot know which link the caller means.

RFC 4007 describes this as a scope zone concept, not as decoration appended to an address. The interface name or index is meaningful only to the host that resolves it. On another machine, en0 may name a different interface or none at all. That makes a zone identifier a poor portable approval target.

For a direct local socket action, accept a zone only if the platform parser validates it and the action runs on the same machine. Store the normalized numerical interface index as the comparison value when the OS exposes it. You may also retain the supplied interface name for the audit trail, but names can change when hardware and network configuration change.

For an HTTP URI, the rules are tighter. RFC 6874 specifies that the percent sign introducing a zone identifier is encoded as %25 inside the bracketed literal. A URI therefore uses a form like this:

http://[fe80::1%25en0]/status

A URI parser must decode that at the correct stage. Do not percent-decode the whole URL before parsing, because a generic decoder can change delimiters and make a different URL than the caller supplied. Parse the URI first, extract the host literal, then apply the scoped-literal rules required for that component.

Most agent-facing HTTP approval flows should reject scoped literals and tell the caller why: link-local addresses only make sense with a specific local interface. Asking an agent to use a stable DNS name, an unscoped address, or a deliberately configured local endpoint creates an approval that another person can understand. Allow the exception only where the product genuinely operates against local network equipment and shows the interface scope plainly.

A host comparison is smaller than an action approval

Normalizing an address fixes one narrow class of deception. It does not make https://[2001:db8::9]/ equivalent to https://[2001:db8::9]:9443/delete, and it does not make an SSH destination safe because the host text looks familiar.

The typed approval target should keep boundaries that the raw string hides. For HTTP, that usually means at least scheme, host kind and bytes, port after default-port resolution, method, and a request description that exposes the path. Whether headers and body digest belong in the decision depends on the action. If one bearer credential can call several unrelated APIs, the credential identity also belongs in the prompt.

For SSH, distinguish a network endpoint from the remote command. An approval to open an SSH connection does not automatically describe permission to run sudo, alter deployment files, or forward a port. If a tool can make several operations after one connection, the UI must say what the session authorization covers and keep a per-call record of each command.

This is where a clean data model pays off. It prevents a tempting but wrong recommendation: put canonical hosts in a flat allowlist and call the job finished. Flat host lists are popular because they are easy to explain. They fail because a host is only one part of an action, and because a later DNS answer, port, path, or command can change the effect.

A useful approval record can look like this:

transport: https
host_kind: ipv6
host_bytes: 20010db8000000000000000000000009
host_display: 2001:db8::9
scope_id: null
port: 8443
method: POST
path: /v1/releases
credential_ref: deploy-api
raw_authority: [2001:0db8::9]:8443

The raw authority documents the request. The typed fields drive comparison. The display field gives the human a stable sentence to read. Do not reuse one of these fields as a substitute for the others.

Rejection rules must be boring and strict

Follow one audit trail
Sessions and Activity are projected from one encrypted, hash-chained audit log.

A parser should fail closed when input does not fit the grammar for its position. The error message can be helpful, but the system should not repair an address by guessing what the caller intended. A normalizer that accepts almost-valid input creates a second grammar that security reviewers will struggle to reconstruct.

Reject an IP-literal request when it has any of these faults:

  • more than one :: compression marker or too many fields after expansion
  • non-hexadecimal characters in a hexadecimal field
  • an IPv4 tail outside the position allowed by IPv6 text syntax
  • brackets passed to a bare address parser, or missing brackets in a URI authority
  • a zone identifier in a context where the action cannot bind a local interface

Also reject a literal that your URI parser and socket parser interpret differently. This is not theoretical fussiness. Different libraries have made different choices about percent encoding, unusual IPv4 forms, and host parsing over time. One component approving text that another component connects to is an authorization gap.

Build a small cross-layer test corpus. Feed each case through the same URI parser, host extractor, IP parser, formatter, comparator, and transport builder used in production. For accepted input, assert both the canonical display and the actual socket destination. For rejected input, assert that no transport object appears.

A compact corpus should include ::, ::1, a fully expanded address, two equal-length zero runs, a bracketed URI literal with a port, a mapped IPv4 value, a malformed dotted tail, a scoped link-local literal, and an encoded %25 zone in a URI. Add any input shape that agents in your environment have actually produced. Regression tests drawn from real approvals are less glamorous than parser tricks and far more likely to catch the next mistake.

The approval card should expose the normalization

Run SSH without sharing keys
SSH commands run through the bundled sp-ssh helper while the SSH key remains in Sallyport.

A person cannot assess an endpoint if the card hides the part that changed. Show the canonical target prominently, then show the submitted spelling when it differs. A quiet line such as Submitted as [2001:0DB8:0:0:0:0:0:9]:8443 gives a reviewer evidence without making them decode fields in their head.

The card should also make special forms explicit. Label a mapped address as IPv4-mapped IPv6 instead of presenting it as a routine IPv6 literal. Label a scoped address with the interface name and say that it is local to that interface. If the action policy projects a mapped address to IPv4, state that decision in the card. Silent equivalence is where reviewers get surprised.

For high-consequence calls, require the human to approve the complete action, not only the canonical host. A compact presentation can still carry the necessary detail:

POST https://[2001:db8::9]:8443/v1/releases
Uses credential: deploy-api
Submitted host spelling: 2001:0DB8:0:0:0:0:0:9

Sallyport follows this separation in the place it matters: the agent never receives the API or SSH secret, while the app performs the action and returns its result. Its per-session authorization can establish who started a run, and its per-call controls can keep sensitive actions visible rather than treating prior approval as a blank check.

Audit records need raw text and normalized meaning

Incident review needs two answers that often conflict if you saved only one representation: What did the agent actually send, and what destination did the transport use? Save both. Then make it clear which value drove the decision.

An audit event should include the raw input, the parsed host kind, canonical display form, address bytes in an unambiguous encoding, scope identity if present, and all action fields covered by approval. Hashing the event after serialization is useful only if the serialization has a stable definition. Otherwise the same semantic event can produce different records because a formatter changed.

Do not erase noncanonical input after deriving the display form. It may show a buggy client, an attempted bypass, or a harmless library quirk that later explains an incident. Keep it as evidence, but do not let search dashboards turn it into a misleading second identity for the same endpoint.

Sallyport's Activity journal and Sessions journal are projected from one encrypted, hash-chained audit log, and sp audit verify checks that chain offline over ciphertext. That structure is especially useful when a reviewer needs to distinguish an odd spelling from a different executed action.

Start with one test before you redesign the whole approval layer: submit the same target in fully expanded and RFC 5952 form. If your system produces different approval identities, different audit search results, or different allow decisions, the parser boundary is still in the wrong place.

FAQ

Can two IPv6 strings refer to the same address?

No. Multiple strings can denote the same 128-bit address because IPv6 permits zero compression, omitted leading zeros, and mixed IPv6 and IPv4 notation. Compare parsed address bytes and relevant scope data, not the text a caller supplied.

Why do IPv6 addresses need brackets in URLs?

Use brackets when an IPv6 literal appears in a URI authority, such as https://[2001:db8::7]:8443/. Do not store the brackets as part of the address itself; they belong to the URI grammar.

What is an IPv4-mapped IPv6 address?

::ffff:192.0.2.7 is an IPv4-mapped IPv6 address. Many APIs produce it when an IPv4 peer arrives through an IPv6 socket, so an approval system must deliberately decide whether it compares as that IPv6 value or as the embedded IPv4 address.

Should I allow a zone identifier in an HTTP approval target?

Usually no. A zone identifier gives a link-local address its interface scope, so it has meaning only on the machine that knows that interface. Ask for a DNS name or an unscoped, routable address for remote HTTP approvals.

What is the canonical IPv6 text format?

Yes. RFC 5952 recommends lowercase hexadecimal, suppression of leading zeros, and the longest run of zero fields compressed with ::, choosing the first run on a tie. Canonical text helps people review targets, but it does not replace byte-level comparison.

Does approving an IPv6 host approve every port on it?

No. A bracketed IPv6 literal is only a host representation inside URI syntax. A port still matters, and the scheme, path, query, method, and credential identity may matter to the action the agent wants approved.

What should happen if an approval target contains a hostname?

Reject it for an IP literal parser, then handle it separately as a hostname if your product supports names. Forcing a hostname through IPv6 normalization creates ambiguous and often unsafe fallback behavior.

Should I store the original IPv6 address text?

Preserve the original request for the audit trail, but show a canonical display value and compare a typed internal value. Those three representations answer different questions: what arrived, what a person saw, and what the system authorized.

Can I normalize malformed IPv6 addresses?

No. A parser should reject malformed brackets, multiple :: markers, invalid hex groups, an IPv4 tail in the wrong position, and a zone identifier where the surrounding grammar forbids it. Treat parser disagreement as a failure, not a reason to guess.

How should an approval system handle DNS names and IPv6?

Resolve DNS separately under rules that fit hostnames, then normalize each returned address for logging and comparison. Do not silently turn a hostname into one approved literal, because later DNS answers can change where the same name leads.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov