Skip to content

Vaynerov Technologies

Мы не просто разрабатываем — мы создаём каждую строку кода и пиксель.

Все статьиИстории сборки

Мы атаковали собственный Toolbox: 30 дефектов за сутки

Toolbox вышел с двадцатью пятью утилитами и полным набором зелёных гейтов. Через двадцать четыре часа структурированный самоаудит подтвердил в нём тридцать дефектов — включая один, из-за которого примерно 93% хаба были некликабельны. Вот весь список и объяснение, зачем его публиковать.

Edward AmirainОснователь Vaynerov Technologies
Опубликовано 9 мин чтения
На этой странице
  1. Восемь скептиков и одно правило
  2. Экспонат первый: содержащий блок, который съел страницу
  3. Экспонат второй: синий, который вернулся не тем
  4. Экспонаты с третьего по седьмой: враждебный ввод и ранний уход
  5. Тихие дефекты: ARIA, буфер обмена, пять локалей
  6. Когда интерфейс врёт
  7. Исправление, которому потребовалось исправление

Один коммит, 161 файл, 39,246 строк: Toolbox вышел целиком — двадцать пять утилит, которые всю работу делают в вашем браузере. TypeScript компилировался. ESLint молчал. Продакшен-сборка проходила, тринадцать сквозных тестов и девять дымовых были зелёными, и штука выглядела — на моём экране — законченной.

На следующий день я натравил на неё восемь скептиков и велел ломать. Они подтвердили тридцать дефектов. Худший всё это время сидел на самом фасаде функции: три случайных CSS-объявления схлопнули кликабельную область каждой карточки на хабе до полосы 331 × 24 пикселя — примерно 7% карточки. Около 93% поверхности, куда посетитель реально целится, не делали ничего.

8
проходов состязательного ревью
30
подтверждённых дефектов
37
файлов исправлено, +1,853 / −309
116
XSS-векторов в наборе

Самоаудит /tools 26 июля, через день после запуска. Каждое число здесь — из коммита с исправлениями.

Восемь скептиков и одно правило

Состязательное ревью кода — не второе мнение. Второе мнение читает ваш код так, как вы его писали: от начала к концу, сочувственно, проверяя, сделали ли вы то, что задумали. Атака исходит из того, что вы сделали ровно то, что задумали, и это всё равно неправильно: что обещание на странице не выполняется, что ввод, который никто не станет набирать, уже набран, что компонент размонтировался посреди задачи, что строка нормальна по-английски и абракадабра по-немецки.

Поэтому выпущенная поверхность /tools получила восемь отдельных проходов, каждый охотился на свой класс отказов, а не проходил те же файлы заново. Но ценность всему упражнению придало не нападение.

Ни одна находка не засчитывалась, пока её не воспроизводил второй, независимый прогон. Подозрение бесплатно; воспроизведение — валюта.

Это правило сделало две вещи. Оно убило правдоподобно звучащие находки, которые испаряются под секундомером или отладчиком, — те, что стоили бы мне дня оборонительных переписываний впустую. И оно превратило выживших в то, с чем можно работать без споров: схлопывание области нажатия было не теорией про содержащие блоки, а измеренным прямоугольником в живом окне Chromium. Планку пережили тридцать находок. Коммит с исправлениями тронул 37 файлов, +1,853 строки и −309.

Тридцать подтверждённых дефектов, по одной метке на каждый; выноски называют виды. Опечаток среди них почти нет: почти всё — корректный код с неверным допущением под ним.

Экспонат первый: содержащий блок, который съел страницу

Каждая карточка на хабе Toolbox использует растянутую ссылку: якорь вокруг названия утилиты плюс псевдоэлемент ::after, прибитый ко всем четырём краям, чтобы целью была вся карточка. Паттерн стандартный, и держится он на одном: что псевдоэлемент разрешает своё положение относительно карточки, а не относительно чего-то меньшего между ними.

Диорамы хаба — чистый CSS-3D, поэтому transform-style: preserve-3d в этой таблице стилей объявление законное и частое. Три из них приземлились не на те элементы, а preserve-3d создаёт содержащий блок для абсолютно позиционированных потомков. Самым внутренним оказался <h3> с названием утилиты, так что оверлей размером с карточку тихо стал оверлеем размером с заголовок: 331 пиксель в ширину, 24 в высоту.

Ничего не выглядело сломанным. Вот над этим стоит посидеть. Карточка рендерилась пиксель в пиксель, состояние наведения срабатывало, курсор над заголовком по-прежнему превращался в указатель, и все автотесты проходили — потому что тест кликает по ссылке по её доступному имени, а сама ссылка была совершенно здорова. Отказ существовал только для того, кто целится мышью в середину карточки, то есть для каждого настоящего посетителя и ни для одного моего прибора.

Всё выглядело правильно. Взгляд — самый слабый прибор из тех, что у меня есть, и первый, за который я хватаюсь.

Исправление — удаление трёх объявлений. Вместе с ними приехал шестнадцатистрочный комментарий-инвариант, объясняющий, почему ничто в этом поддереве не должно создавать содержащий блок: следующим, кто добавит туда 3D-красивость, буду я, через полгода, ничего об этом не помня.

Экспонат второй: синий, который вернулся не тем

Конвертер цветов принимает HEX, RGB, HSL и OKLCH и показывает все четыре. Дайте ему чистый синий, #0000ff, — и он возвращал #0031e5: мимо на 48 из 255 в одном канале, а это не артефакт округления, это другой цвет. OKLCH описывает перцептивное пространство больше sRGB, поэтому сконвертированный цвет регулярно оказывается за пределами того, что экран способен показать, и что-то должно вернуть его обратно. Наше возвращало плохо.

Очевидное лечение — зажать каждый канал в диапазон или взять ближайший отображаемый цвет и жить дальше. Мы не сделали ни того ни другого, потому что прогон показал: короткий путь хуже, чем выглядит. Незащищённый выбор ближайшего превратил бы 76% результатов вне гаммы в видимое срезание каналов. Вместо этого конвертер теперь реализует отображение гаммы из CSS Color 4 §13: снижать цветность двоичным поиском, но останавливаться раньше, когда зажатый результат отличается от кандидата меньше чем на едва заметную разницу (clipDelta ≤ 0.02), — там, где перцептивная честность и отображаемые пиксели сходятся.

Прогон по 140,000 цветов до и после отвечает, стоило ли это писать: худшая ошибка кругового преобразования упала с 47.99/255 до 0.77/255. Меньше одной единицы в байтовом канале — это цвет, движения которого никто не увидит.

Экспонаты с третьего по седьмой: враждебный ввод и ранний уход

Превью markdown — инструмент, наиболее открытый вводу, о котором он не просил, и в этот раз ему досталось хуже всех. Таблица на 19 КБ, вставленная в него, порождала HTML-строку на 275 МБ: квадратичное построение встретилось с документом, который состоит из одних разделителей. Патологическая незакрытая [ запускала пересканирование за O(n²): четырнадцать секунд замороженной вкладки, теперь десять миллисекунд. Вложенные цитаты рекурсировали без дна, так что достаточно глубокий документ мог исчерпать стек целиком.

Сами по себе это не дыры в безопасности — рендерер работает без доступа к сети, — но форма у них та же, и тем же проходом по санитайзеру прогнали набор из 116 XSS-векторов. До исправления проходили восемнадцать. После — ноль. Инструмент теперь работает по явным бюджетам: бюджет разделителей, бюджет сканирования ссылок, списываемый только при неудаче, 20,000 ячеек таблицы, 32 уровня вложенности блоков.

ДефектБылоСтало
Область нажатия карточки на хабе331 × 24 px (~7% карточки)вся карточка
Круговое преобразование OKLCH, худшее из 140k цветов47.99 / 2550.77 / 255
Таблица 19 КБ, вставленная в превью markdownHTML-строка на 275 МБпотолок в 20,000 ячеек
Пересканирование при незакрытой скобке14 с10 мс
Набор XSS, 116 векторов18 провалов0
Индикатор пароля на 7 символах (44.1 бита)«Средний», прямо над временем взлома в 15 минутСлабый, из выведенного потолка 46.03 бита

Шесть из тридцати, с числами. Каждый был независимо воспроизведён, прежде чем его признали дефектом.

Вторая половина враждебного ввода — враждебный тайминг, то есть пользователь, который уходит. Три инструмента (gzip, ZIP, regex) утекали Web Workers при размонтировании и продолжали радостно молотить для страницы, на которую никто не смотрит. Причина — React-идиома, которую я уже писал неправильно и, вероятно, ещё напишу: эффект очистки захватывал объекты ref вместо чтения .current в момент разрушения, поэтому разрушал устаревший хэндл, оставляя живого воркера работать.

Из той же семьи: сборщик ZIP проверял свой потолок DEFLATE в 4 ГиБ после того, как всё сжал, поэтому слишком большая пачка выполняла всю дорогую работу и лишь затем прерывалась. Проверка обязана идти до работы — очевидно задним числом и невидимо на ревью, потому что защита существовала и была даже корректной, просто поздней.

Тихие дефекты: ARIA, буфер обмена, пять локалей

У четырёх инструментов кнопка перехода к ошибке была написана как <button role="alert">. Один этот атрибут делает сразу две плохие вещи: он перекрывает роль кнопки, так что вспомогательные технологии больше не объявляют элемент нажимаемым, — а поскольку имя элемента приходило из контекста, а не из текста, элемент не объявлялся вообще никак. Кнопка без подписи и без роли кнопки хуже, чем отсутствие кнопки. Роль переехала на обёртку, где объявлению и место, а кнопка вернулась к тому, чтобы быть кнопкой.

Копирование в буфер обмена молча падало всякий раз, когда браузер отказывал. Теперь оно подсвечивает элемент и сообщает через live-регион — который держится смонтированным и пустым с самого начала, потому что live-регион, вставленный в тот же момент, когда в нём появляется содержимое, объявляется по-разному в разных экранных читалках. Он очищается при следующей попытке, а не по таймеру, чтобы медленный читатель не потерял сообщение на середине фразы.

Дальше локализация — место, где самоаудит и отрабатывает свои деньги, потому что вычитать пять из наших шести языков я не могу. Полоса утилит на главной рендерила английские названия во всех локалях: фолбэк, которого никто не замечал, потому что все, кто на него смотрел, читали по-английски. Парсер cron, объясняющий расписание обычными словами, был неграмотен по-немецки, по-испански и по-португальски. Французскому потребовалось настоящее изменение кода, и его намеренно отложили в отдельный коммит, а не протащили в общую кучу исправлений: диапазоны теперь строятся через отдельные функции для дней недели и месяцев, поэтому по-немецки читается von Montag bis Freitag вместо откровенно неверного am Montag bis Freitag. Английский и русский вывод после этого оказались побайтно теми же — так я и узнал, что рефакторинг тронул ровно то, о чём заявлял.

Когда интерфейс врёт

Три дефекта стоит держать вместе, потому что в каждом продукт с полной уверенностью сообщал пользователю неправду.

Генератор паролей оценивал семисимвольный пароль — 44.1 бита энтропии — как «средний», прямо над собственной оценкой, что этот пароль падёт за пятнадцать минут. Обе строки вычислялись корректно. Просто приходили они из разных мест: время взлома — из настоящей константы, подпись уровня — из порога, который кто-то набрал руками. Поэтому порог перестал быть набранным.

// The Weak ceiling is derived from the same constant the crack-time estimate
// uses, so the two lines can no longer contradict each other: anything a rig
// breaks inside an hour is Weak, by definition.
const WEAK_MAX_BITS = 1 + Math.log2(3600 * GUESSES_PER_SECOND); // 46.03

44.1 бита теперь ниже потолка, поэтому бейдж читается как «слабый», — и он сдвинется сам, если допущение о железе когда-нибудь изменится.

У конвертера временных меток было две собственные лжи округления. Длительность 59.999 секунды отображалась как «через 60 секунд» — предложение, которого не должно существовать. А эпоха с ведущими нулями вроде 0001753372800 повышалась до миллисекунд, потому что единица выводилась из количества цифр, а не из величины: тринадцать символов — значит миллисекунды — значит дата в 1970 году. Теперь определение единицы сравнивает значение с 1e11. Тем временем инструмент UUID падал с ошибкой на небезопасном origin, потому что crypto.randomUUID помечен [SecureContext], а голый вызов — это не мягкая деградация, а стектрейс.

Третья ложь задела больнее всего. Ссылки для шеринга в генераторе градиентов были сломаны с самого момента выхода функции. Предгидратационный экземпляр редактора инициализировал себя состоянием по умолчанию и выстреливал свой отложенный replaceState раньше, чем живой экземпляр успевал прочитать хеш, — поэтому любой градиент, которым кто-то делился, примерно через 400 мс после загрузки откатывался к градиенту по умолчанию. Ссылка работала. Она просто стирала себя у вас на глазах. Регрессионный тест на неё сделан с отрицательным контролем: его сначала прогнали на старом коде и убедились, что он падает, потому что зелёный тест, который остался бы зелёным, — не тест.

Исправление, которому потребовалось исправление

Моя любимая находка всего упражнения пришла из второго прохода по первому кругу починок, и именно из-за неё я больше не считаю «исправлено» состоянием.

Бюджет разделителей в markdown ограничивал работу 5,000 — узлов. Но токенизатор сворачивает серии символов выделения в один узел, поэтому 20,000 звёздочек — это горстка узлов: они спокойно проходят под потолком и разносят стек ровно как раньше. Защита была настоящей, число разумным, а считало оно не то существительное. Теперь оно считает символы, с примерно 1.7× измеренного запаса относительно худшего случая, который мы сумели сконструировать.

Ещё два из того же прохода. Помощник многофайловой загрузки складывался, когда gzip передавал ему undefined вместо blob, — путь ошибки, по которому никто не ходит до того дня, когда по нему идут все. А Wi-Fi-режим генератора QR получил доказательство своего обещания о приватности заново, с нуля: 900 комбинаций настроек и проверка, что предварительный ключ не может утечь в ссылку, которой делятся, ни в одной из них. Не мог. Доказательство мне всё равно было нужно, потому что «ничего из набранного здесь не покидает ваше устройство» — обещание, на котором стоит весь Toolbox, а обещание, проверенное один раз, — это обещание, о котором вы гадаете.

Честное завершение такое: тридцать — это число, которое мы подтвердили, а не число, которое существовало. Самоаудит находит дефекты, которые ваши атакующие догадались поискать, и теперь у меня есть довольно точная карта того, в чём восемь проходов хороши — геометрия CSS, числовые круговые преобразования, враждебная вставка, жизненный цикл, семантика ARIA, грамматика на неанглийских языках, — и никакой карты того, что они упустили. Качество на релизе — не состояние, которого достигают в день выхода. Это скорость, с которой вы находите собственные ошибки раньше, чем это придётся делать кому-то другому.

Собственно, поэтому этот текст и существует. Публиковать список дефектов — странное маркетинговое решение и совершенно прямое инженерное: это тот же аргумент, который я приводил в «Ремесле в эпоху сгенерированного кода», — когда писать код становится дёшево, вся работа сводится к тому, чтобы его проверять. Если хочется посмотреть на архитектуру, в которой нашлись эти тридцать дефектов — воркерный RPC, стриминг, гарантия отсутствия загрузок, — это двадцать пять утилит и ноль загрузок.