Нормализация IPv6-адресов для целей одобрения
Нормализация IPv6-адресов делает цели одобрения понятными и сопоставимыми для URL в скобках, IPv4-mapped форм, сжатых нулей и зон.

Экран одобрения, который показывает исходную строку IPv6, заставляет человека под давлением времени выполнять работу парсера. Это ошибка проектирования. Система должна разобрать запрос в типизированную конечную точку, отклонить неоднозначности, сравнить типизированное значение и показать одну стабильную форму, которую человек узнает при следующем запросе.
Нотация IPv6 даёт злоумышленникам и обычному ПО множество способов по-разному представить один и тот же адрес назначения: сжатые нулевые поля, ведущие нули, обязательные для URL скобки, встроенный IPv4-хвост и зоны интерфейсов. Это не делает IPv6 подозрительным. Но цель одобрения нужно обрабатывать строже, чем поле, в котором случайно оказалось имя хоста.
Один адрес может прийти в разных строках
Строки 2001:db8:0:0:0:0:0:9, 2001:0db8::9 и 2001:db8::9 обозначают один и тот же IPv6-адрес без области видимости. Если карточка одобрения хранит одну строку, а последующий запрос передаёт другую, строковое сравнение покажет различие. Если allowlist принимает один вариант записи, а поиск по аудиту ожидает другой, операторы теряют след именно тогда, когда он нужен.
В IPv6 восемь 16-битных полей. Автор может опустить ведущие нули в каждом поле, а затем заменить одну последовательность нулевых полей на ::. Маркер :: удобен людям, но скрывает, сколько полей было опущено. Парсер восстанавливает недостающие поля и возвращает единственное представление, важное для проверки равенства: 16 байтов адреса.
Это различие часто размывают: канонический текст нужен для проверки человеком, а разобранные байты - для сравнения. Одна каноникализация не даёт разрешения. Она лишь гарантирует, что форма, которую видит человек, не зависит от варианта записи вызывающего кода.
Считайте это отдельными данными:
raw_input: точный текст хоста и окружающий синтаксис authority, полученные от вызывающего кодаaddress: 16 байтов после строгого разбораscope_id: область видимости интерфейса, если входные данные правомерно её содержалиdisplay_host: каноническая строка, сформированная из типизированного адресаport,schemeи сведения о запросе: поля, описывающие фактическое действие
Храните исходный ввод в аудиторской записи. Он объясняет, что запросил агент. Не используйте его как токен сравнения и не показывайте на экране только его.
Скобки URI - это синтаксис, а не часть хоста
IPv6-литерал внутри URI authority должен быть заключён в квадратные скобки, потому что двоеточия уже разделяют хост и порт. RFC 3986 задаёт такую форму: https://[2001:db8::9]:8443/v1/jobs. Адрес - это 2001:db8::9; скобки говорят URI-парсеру, где заканчивается хост.
Из этого правила возникает распространённая ошибка. Разработчик передаёт [2001:db8::9] парсеру адресов, получает отказ, убирает символы, пока всё не заработает, а затем принимает некорректный authority, потому что два парсера больше не совпадают. Другая версия этой ошибки хранит скобки вместе с адресом, поэтому в одной записи есть [2001:db8::9], а в другой 2001:db8::9. Они вообще не должны были попасть в одно поле хранения.
Разбирайте значение на границе грамматики, через которую оно пришло. Для HTTP-цели сначала разберите полный URI стандартным URI-парсером. Извлеките хост, порт, схему, путь и query по правилам этого парсера. Затем передайте IPv6-парсеру только значение хоста, без URI-скобок. Для прямого аргумента SSH-хоста используйте грамматику, описанную интерфейсом команды SSH, а не притворяйтесь, что это URI.
Порядок важен. Рассмотрим запрос:
https://[2001:0db8:0:0:0:0:0:9]:8443/admin
Корректный внутренний результат выглядит так:
kind: ipv6
address: 20010db8000000000000000000000009
scope_id: null
display_host: 2001:db8::9
scheme: https
port: 8443
path: /admin
Теперь интерфейс одобрения может написать https://[2001:db8::9]:8443/admin. Не следует молча сводить это к 2001:db8::9, ведь порт и путь входят в то, что оценивает пользователь. И наоборот, не сохраняйте вариант с добавленными нулями лишь потому, что он пришёл первым.
У литерала без URI нет скобок. У URI authority с IPv6-литералом скобки есть. Пусть это правило остаётся узким и предсказуемым.
Канонический вид должен соответствовать RFC 5952
RFC 5952 рекомендует строчные шестнадцатеричные символы, отсутствие ведущих нулей в поле и :: для самой длинной последовательности нулевых полей. Если две нулевые последовательности одинаковой длины, выбирается первая. Стандарт также говорит, что для одного нулевого поля нельзя использовать ::. Эти правила дают стабильный результат в форме, которую видит человек.
Например, нормализуйте эти значения так:
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
Совет стандарта полезнее, чем кажется. Он даёт операторам один вариант записи для поиска в журналах и не позволяет вызывающему коду выдать 2001:0DB8::9 за новый адрес назначения после того, как уже одобрили 2001:db8::9.
Не пишите собственный форматтер, разбивая строку по двоеточиям и считая пустые строки. IPv4-хвост, неправильное двойное сжатие и синтаксис области видимости делают такой подход хрупким. Используйте проверенный IPv6-парсер на языке, который выполняет действие, сохраните его 16-байтовый вывод и форматируйте из этих байтов. Проверьте форматтер на примерах из RFC 5952 и случаях с нулевыми последовательностями одинаковой длины.
Не приписывайте RFC 5952 лишнего. Он описывает рекомендованное текстовое представление. Он не решает, должны ли ::ffff:192.0.2.7 и 192.0.2.7 получать одинаковое разрешение. Это продуктовое решение с реальными последствиями.
Для IPv4-mapped форм нужно явное правило сравнения
::ffff:192.0.2.7 - IPv4-mapped IPv6-адрес. Его последние 32 бита содержат IPv4-адрес, а предыдущий шаблон обозначает mapped-форму. Операционные системы часто показывают эту форму, когда приложение принимает IPv4-подключения на IPv6-сокете. Она достаточно часто встречается в журналах, поэтому считать её странным исключением - значит гарантировать путаницу в будущем.
Есть две обоснованные внутренние модели. Выберите одну для каждой границы авторизации и опишите её в интерфейсе.
Первая модель сохраняет семейства адресов. ::ffff:192.0.2.7 остаётся 16-байтовым IPv6-адресом с типом ipv6, а 192.0.2.7 остаётся четырёхбайтовым IPv4-адресом с типом ipv4. Они никогда не сравниваются как равные. Это более безопасный вариант по умолчанию, когда одобрение описывает сетевое подключение, запрошенное в конкретной форме: он не расширяет решение между семействами незаметно.
Вторая модель проецирует mapped-адреса в IPv4 для узко определённого сценария идентификации узла. В этой модели парсер записывает и исходное семейство, и значение embedded_ipv4. Код сравнения намеренно считает mapped-форму равной вложенному IPv4-адресу только для этой цели. Аудиторская запись всё равно хранит исходную форму и поясняет, что при сравнении использовалась проекция.
Плоха случайная проекция. Многие стандартные библиотеки предлагают удобный метод, который превращает mapped-адрес в IPv4 и ничего не сообщает о том, как он пришёл. Это удобно для журналирования подключений, но опасно, если разработчик повторно использует его для токена одобрения. Тогда правило для 192.0.2.7 может одобрить ::ffff:192.0.2.7, хотя никто не принимал такого решения.
Используйте тестовые пары, которые делают выбор очевидным:
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
Не считайте mapped любой строку IPv6 с точечным хвостом. Парсер должен проверить полный префикс и положение IPv4-части. RFC 4291 определяет IPv4-mapped адреса и также допускает IPv4-compatible нотацию в тексте IPv6. Форматтер должен сохранять достаточно типизированной информации, чтобы не называть все адреса с точечным хвостом одним и тем же.
Идентификаторы зоны привязаны к локальному интерфейсу
Идентификатор зоны превращает неоднозначный адрес с областью видимости в пригодный локальный адрес назначения. fe80::1%en0 означает link-local адрес на интерфейсе с именем en0. Без зоны хост с несколькими сетевыми интерфейсами не может понять, какой канал имел в виду вызывающий код.
RFC 4007 описывает это как понятие зоны области видимости, а не как украшение, добавленное к адресу. Имя или индекс интерфейса имеют смысл только для хоста, который их разрешает. На другой машине en0 может обозначать другой интерфейс или не обозначать никакой. Поэтому идентификатор зоны плохо подходит как переносимая цель одобрения.
Для прямого действия с локальным сокетом принимайте зону только тогда, когда её проверяет платформенный парсер, а действие выполняется на той же машине. Если ОС предоставляет его, храните нормализованный числовой индекс интерфейса как значение для сравнения. Можно также сохранить переданное имя интерфейса для аудита, но имена меняются вместе с оборудованием и конфигурацией сети.
Для HTTP URI правила строже. RFC 6874 требует, чтобы знак процента, вводящий идентификатор зоны, внутри литерала в квадратных скобках был закодирован как %25. Поэтому URI использует такую форму:
http://[fe80::1%25en0]/status
URI-парсер должен декодировать это на правильном этапе. Не декодируйте весь URL до разбора: общий декодер может изменить разделители и получить URL, отличный от того, который передал вызывающий код. Сначала разберите URI, извлеките литерал хоста, затем примените правила scoped-литералов, нужные для этого компонента.
Большинство HTTP-потоков одобрения для агентов должны отклонять scoped-литералы и объяснять причину: link-local адреса имеют смысл только с конкретным локальным интерфейсом. Если попросить агента использовать стабильное DNS-имя, адрес без области видимости или намеренно настроенную локальную конечную точку, получится одобрение, понятное другому человеку. Разрешайте исключение только там, где продукт действительно работает с локальным сетевым оборудованием и явно показывает область видимости интерфейса.
Сравнение хостов уже, чем одобрение действия
Нормализация адреса исправляет один узкий класс обмана. Она не делает https://[2001:db8::9]/ эквивалентным https://[2001:db8::9]:9443/delete и не делает SSH-адрес безопасным только потому, что текст хоста выглядит знакомо.
Типизированная цель одобрения должна сохранять границы, скрытые исходной строкой. Для HTTP это обычно как минимум схема, тип и байты хоста, порт после применения порта по умолчанию, метод и описание запроса, раскрывающее путь. Входят ли заголовки и дайджест тела в решение, зависит от действия. Если одни bearer-учётные данные могут вызывать несколько несвязанных API, идентификатор учётных данных тоже должен быть в запросе.
Для SSH различайте сетевую конечную точку и удалённую команду. Одобрение открытия SSH-подключения не означает автоматически разрешение выполнить sudo, изменить файлы развёртывания или перенаправить порт. Если инструмент может выполнить несколько операций после одного подключения, интерфейс должен сказать, что покрывает авторизация сессии, и вести отдельную запись о каждом вызове команды.
Здесь окупается чистая модель данных. Она не даёт сделать заманчивую, но ошибочную рекомендацию: поместить канонические хосты в плоский allowlist и считать задачу решённой. Плоские списки хостов популярны, потому что их легко объяснить. Но хост - лишь одна часть действия, а последующий ответ DNS, порт, путь или команда могут изменить результат.
Полезная запись одобрения может выглядеть так:
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
Исходный authority документирует запрос. Типизированные поля используются для сравнения. Поле отображения даёт человеку стабильную фразу для чтения. Не используйте одно из этих полей вместо остальных.
Правила отклонения должны быть простыми и строгими
Парсер должен отказывать по умолчанию, когда ввод не соответствует грамматике своего положения. Сообщение об ошибке может помочь, но система не должна исправлять адрес, угадывая намерение вызывающего кода. Нормализатор, принимающий почти корректный ввод, создаёт вторую грамматику, которую специалистам по безопасности будет трудно восстановить.
Отклоняйте запрос с IP-литералом при любой из этих ошибок:
- больше одного маркера сжатия
::или слишком много полей после развёртывания - не шестнадцатеричные символы в шестнадцатеричном поле
- IPv4-хвост вне позиции, разрешённой синтаксисом текста IPv6
- скобки, переданные парсеру голого адреса, или отсутствие скобок в URI authority
- идентификатор зоны в контексте, где действие не может привязаться к локальному интерфейсу
Также отклоняйте литерал, который URI-парсер и парсер сокетов трактуют по-разному. Это не теоретическая придирка. Со временем разные библиотеки по-разному обрабатывали percent encoding, необычные формы IPv4 и разбор хостов. Если один компонент одобряет текст, к которому подключается другой, возникает брешь в авторизации.
Соберите небольшой тестовый набор для всех слоёв. Пропустите каждый случай через те же URI-парсер, извлекатель хоста, IP-парсер, форматтер, компаратор и конструктор транспорта, что используются в продакшене. Для принятого ввода проверяйте и каноническое отображение, и фактический адрес сокета. Для отклонённого ввода проверяйте, что объект транспорта не создаётся.
Компактный набор должен включать ::, ::1, полностью развёрнутый адрес, две нулевые последовательности одинаковой длины, URI-литерал в скобках с портом, mapped IPv4-значение, некорректный точечный хвост, scoped link-local литерал и закодированную зону %25 в URI. Добавьте любую форму ввода, которую агенты в вашей среде уже реально создавали. Регрессионные тесты на основе настоящих одобрений менее эффектны, чем трюки с парсерами, но куда вероятнее поймают следующую ошибку.
Карточка одобрения должна показывать нормализацию
Человек не сможет оценить конечную точку, если карточка скрывает изменившуюся часть. Покажите каноническую цель заметно, а при отличии покажите переданный вариант записи. Негромкая строка вроде Передано как [2001:0DB8:0:0:0:0:0:9]:8443 даёт проверяющему доказательство, не заставляя его расшифровывать поля в уме.
Карточка должна явно обозначать и особые формы. Назовите mapped-адрес IPv4-mapped IPv6, а не показывайте его как обычный IPv6-литерал. У scoped-адреса укажите имя интерфейса и скажите, что он локален для этого интерфейса. Если политика действия проецирует mapped-адрес в IPv4, сообщите об этом решении в карточке. Негласная эквивалентность неприятно удивляет проверяющих.
Для вызовов с серьёзными последствиями просите человека одобрить полное действие, а не только канонический хост. Компактное представление всё ещё может содержать нужные детали:
POST https://[2001:db8::9]:8443/v1/releases
Uses credential: deploy-api
Submitted host spelling: 2001:0DB8:0:0:0:0:0:9
Sallyport соблюдает это разделение там, где оно важнее всего: агент никогда не получает API- или SSH-секрет, а приложение выполняет действие и возвращает результат. Авторизация на сессию может установить, кто запустил выполнение, а контроль каждого вызова помогает держать чувствительные действия на виду, не превращая предыдущее одобрение в карт-бланш.
Аудиторским записям нужны исходный текст и нормализованный смысл
При разборе инцидента нужны два ответа, которые часто конфликтуют, если сохранено только одно представление: что именно отправил агент и какой адрес назначения использовал транспорт? Сохраните оба. Затем ясно укажите, какое значение определяло решение.
Аудиторское событие должно включать исходный ввод, тип разобранного хоста, каноническую форму отображения, байты адреса в однозначной кодировке, идентичность области видимости при наличии и все поля действия, покрытые одобрением. Хешировать событие после сериализации полезно лишь тогда, когда у сериализации есть стабильное определение. Иначе одно и то же событие по смыслу может давать разные записи из-за изменения форматтера.
Не удаляйте неканонический ввод после формирования отображаемой формы. Он может указывать на ошибочный клиент, попытку обхода или безобидную особенность библиотеки, которая позже объяснит инцидент. Храните его как доказательство, но не позволяйте поисковым панелям превратить его в обманчивую вторую идентичность той же конечной точки.
Журналы Sallyport Activity и Sessions строятся из одного зашифрованного аудиторского лога с хеш-цепочкой, а sp audit verify офлайн проверяет эту цепочку по шифротексту. Такая структура особенно полезна, когда проверяющему нужно отличить необычный вариант записи от действительно другого выполненного действия.
Начните с одного теста, прежде чем переделывать весь слой одобрения: отправьте одну цель в полностью развёрнутой форме и в форме RFC 5952. Если система создаёт разные идентичности одобрения, разные результаты поиска в аудите или разные решения allow, граница парсинга всё ещё находится не там.
Вопросы и ответы
Могут ли две строки IPv6 обозначать один адрес?
Да. Разные строки могут обозначать один и тот же 128-битный адрес, потому что IPv6 допускает сжатие нулей, пропуск ведущих нулей и смешанную запись IPv6 и IPv4. Сравнивайте разобранные байты адреса и данные об области видимости, а не текст, который прислал вызывающий код.
Зачем IPv6-адресам нужны квадратные скобки в URL?
Используйте квадратные скобки, когда IPv6-литерал находится в URI authority, например https://[2001:db8::7]:8443/. Не храните скобки как часть самого адреса, они относятся к грамматике URI.
Что такое IPv4-mapped IPv6-адрес?
::ffff:192.0.2.7 - это IPv4-mapped IPv6-адрес. Многие API возвращают его, когда IPv4-узел подключается через IPv6-сокет, поэтому система одобрения должна осознанно решить, сравнивать ли его как IPv6-значение или как вложенный IPv4-адрес.
Стоит ли разрешать идентификатор зоны в цели HTTP-одобрения?
Обычно нет. Идентификатор зоны задаёт область видимости link-local адреса на конкретном интерфейсе, поэтому он имеет смысл только на машине, которая знает этот интерфейс. Для удалённых HTTP-одобрений попросите DNS-имя или немаршрутизируемый адрес без области видимости.
Как выглядит канонический текстовый формат IPv6?
RFC 5952 рекомендует строчные шестнадцатеричные символы, удаление ведущих нулей и сжатие самой длинной последовательности нулевых полей через ::, с выбором первой последовательности при равной длине. Канонический текст помогает людям проверять цели, но не заменяет побайтное сравнение.
Означает ли одобрение IPv6-хоста одобрение всех его портов?
Нет. IPv6-литерал в квадратных скобках - лишь представление хоста в синтаксисе URI. Порт по-прежнему важен, как и схема, путь, query, метод и идентификатор учётных данных, если они влияют на действие, которое агент просит одобрить.
Что делать, если цель одобрения содержит имя хоста?
Отклоните его в парсере IP-литералов, а затем обрабатывайте отдельно как имя хоста, если продукт поддерживает имена. Попытка прогнать имя хоста через нормализацию IPv6 создаёт неоднозначное и часто небезопасное резервное поведение.
Нужно ли хранить исходный текст IPv6-адреса?
Сохраните исходный запрос для аудита, но показывайте каноническое значение и сравнивайте типизированное внутреннее значение. Эти три представления отвечают на разные вопросы: что пришло, что увидел человек и что разрешила система.
Можно ли нормализовать некорректные IPv6-адреса?
Нет. Парсер должен отклонять неверные скобки, несколько маркеров ::, недопустимые шестнадцатеричные группы, IPv4-хвост в неправильной позиции и идентификатор зоны там, где его запрещает окружающая грамматика. Расхождение между парсерами - это ошибка, а не повод угадывать намерение.
Как системе одобрения работать с DNS-именами и IPv6?
Разрешайте DNS отдельно по правилам, подходящим для имён хостов, затем нормализуйте каждый полученный адрес для журналирования и сравнения. Не превращайте имя хоста молча в один одобренный литерал, потому что последующие ответы DNS могут изменить адрес назначения.