http/ip_canonical.ts

IP address canonicalization โ€” collapse equivalent string forms into a single key per RFC 5952 (IPv6) plus the dotted form for IPv4-mapped IPv6 addresses.

Why this exists. Without canonicalization, the four representations ::1, ::01, ::0001, and 0:0:0:0:0:0:0:1 are the same IPv6 address but produce four distinct strings โ€” so an attacker rotating equivalent forms behind a trusted-passthrough proxy could defeat per-IP rate limiting (each form gets a fresh bucket) and pollute audit_log.ip forensics. The collision can extend to IPv4-mapped IPv6 forms (::ffff:127.0.0.1 vs 0:0:0:0:0:ffff:7f00:1 vs the bare 127.0.0.1) โ€” three keys for one address.

Canonicalization runs through {@link canonicalize_ip} which:

1. Lowercases and char-set filters (IP_LITERAL_CHARS) โ€” non-IP strings ('unknown', 'attacker:controlled', '::1\n') pass through unchanged so downstream strict validators can still reject them. 2. Parses via Hono's convertIPv*ToBinary family. 3. Re-emits the canonical RFC 5952 string (lowercase hex, longest-zero-run compressed, IPv4-mapped emitted in the dotted form mandated by RFC 5952 ยง5). 4. Strips the ::ffff: prefix from dotted IPv4-mapped forms so the bucket collapses to plain IPv4 โ€” the strip moves AFTER canonicalization because the dotted form is the only form the strip can recognize symmetrically.

Mirrors zzz_server::proxy::normalize_ip (landed 2026-05-16) which uses the same parse-then-canonicalize-then-strip ordering for the same rate-limit-key-poisoning surface.

Declarations
#

3 declarations

view source

canonicalize_ip
#

http/ip_canonical.ts view source

(ip: string): string

Canonicalize an IP address string.

Returns the RFC 5952 canonical form for parseable IPv4 or IPv6 input. Returns the input unchanged (only lowercased) when the input is non-IP ('unknown'), malformed ('attacker:controlled', '::1\n'), or any string the strict char-set filter rejects.

Idempotent. canonicalize_ip(canonicalize_ip(x)) === canonicalize_ip(x) for every input.

Order-safe for IPv4-mapped IPv6. The ::ffff: prefix strip runs AFTER the canonical emit because the canonical form of an IPv4-mapped IPv6 address is the dotted form (::ffff:127.0.0.1, not ::ffff:7f00:1). Stripping before canonicalize would miss the full-hex form. Closes the normalize_ipv4_mapped_collapse_is_order_safe test from the Rust port.

ip

type string

returns

string

examples

canonicalize_ip('::0001') // โ†’ '::1' canonicalize_ip('0:0:0:0:0:0:0:1') // โ†’ '::1' canonicalize_ip('2001:0DB8::0001') // โ†’ '2001:db8::1' canonicalize_ip('::ffff:127.0.0.1') // โ†’ '127.0.0.1' canonicalize_ip('0:0:0:0:0:ffff:7f00:1') // โ†’ '127.0.0.1' canonicalize_ip('::ffff:1') // โ†’ '::ffff:1' (NOT IPv4-mapped โ€” group[5] is 0, not ffff) canonicalize_ip('127.0.0.1') // โ†’ '127.0.0.1' canonicalize_ip('not-an-ip') // โ†’ 'not-an-ip' (passes through) canonicalize_ip('::1\n') // โ†’ '::1\n' (fails char-set; passes through) canonicalize_ip('203.0.113.1:8080') // โ†’ '203.0.113.1:8080' (passes through; validate_ip_strict rejects)

IP_LITERAL_CHARS
#

http/ip_canonical.ts view source

RegExp

Allowed character set for a bare IP literal.

Covers the union of IPv4 (digits + .), IPv6 (hex digits + :), and IPv4-mapped IPv6 forms (::ffff:127.0.0.1). Anything outside this set โ€” brackets, whitespace, control bytes, letters gโ€“z โ€” disqualifies the input from parsing.

Same regex proxy.ts's validate_ip_strict uses; exported here so both modules can share one source of truth.

ipv6_bigint_to_canonical
#

http/ip_canonical.ts view source

(bits: bigint): string

Convert a 128-bit IPv6 binary value into its RFC 5952 canonical string form.

- IPv4-mapped (groups[0..5] = 0, groups[5] = 0xffff) emits the ::ffff:a.b.c.d dotted form per RFC 5952 ยง5. - Otherwise: lowercase hex with no leading zeros per group (ยง4.1), the longest run of consecutive zero groups (โ‰ฅ 2 groups) is replaced with :: (ยง4.2.1, ยง4.2.3), and on equal-length runs the first one wins (ยง4.2.3). Single-zero groups stay as 0 (ยง4.2.2).

Pure helper exported for the test suite to exercise the canonicalization invariants directly without a full convertIPv6ToBinary round-trip.

bits

the 128-bit IPv6 value as bigint. Must satisfy 0n <= bits < 2n ** 128n; throws RangeError otherwise. Silent truncation would mask caller bugs since the bit-extraction loop only consumes the low 128 bits.

type bigint

returns

string

throws

  • when - `bits` is negative or exceeds 128 bits

Imported by
#