Что должен дать вам статический анализ на теоретической основе
Статический анализ охватывает целый ряд методов. Линтеры находятся на одном конце этого спектра: они разбирают код в синтаксическое дерево и сопоставляют его с шаблонами в этом дереве — например, вызов eval() с чем-либо, кроме строкового литерала, динамическое значение, присвоенное напрямую в .innerHTML, команда оболочки, собранная путем конкатенации переменной, или строковый литерал, похожий на зашитый в код ключ API. Такой подход недорог в разработке и выполнении и позволяет находить реальные баги, но он обнаруживает только те шаблоны, которые кто-то догадался запрограммировать. Он не знает, что делает код, он знает только то, как он выглядит.
Далее по этому спектру идут инструменты, которые делают реальный шаг вперед и выполняют анализ на основе графа вызовов: какая функция вызывает какую и по каким ребрам между ними может передаваться значение. В языке, где функции являются обычными значениями, которые передаются в качестве обратных вызовов и сохраняются в объектах, построение точного графа вызовов упирается в ту же проблему «курицы и яйца», что и сам анализ, который он призван поддерживать: разрешение точки вызова часто требует понимания того, на что может указывать переменная, а ответ на этот вопрос обычно предполагает, что граф вызовов уже существует. Инструменты, пытающиеся получить граф вызовов простыми методами, в итоге сильно приближают его, упуская реальные ребра или создавая несуществующие. И даже относительно полный граф сам по себе не отслеживает, какой именно вызов породил какой конкретный возврат: распространение значения по нему без этого контекста может привести к тому, что оно якобы покидает функцию через возврат, принадлежащий совершенно другому вызову той же функции.
Анализ потоков данных методом абстрактной интерпретации — это строгая математическая база для приближенного определения того, что программа делает на самом деле, с формальными определениями того, что значит надежность или точность анализа. «Источник», «сток» и «санитизатор» — это собственный словарь анализа утечек (taint analysis), построенный на этом фундаменте для решения одного конкретного вопроса: может ли значение в одной точке программы достичь другой. Движок, построенный на такой теории, вообще не сопоставляет синтаксис. Он вычисляет, может ли значение из заданного источника действительно дойти до заданного стока, независимо от того, что именно делает код. Вызовы разрешаются по ходе анализа, а не на предварительном отдельном этапе: цель точки вызова следует из того, что абстрактное состояние уже знает о вызываемом значении, что позволяет обойти проблему «курицы и яйца», а не страдать от нее, а сходимость гарантируется через объединения над абстрактной решеткой ценой некоторого снижения точности.
Это различие перестает быть академическим в тот момент, когда код перестает быть похож на пример из учебника. Синтаксическое правило для случая «загрязненное значение попадает в res.send()», написанное для пятистрочного примера, ломается в тот же миг, когда значение деструктурируется, упаковывается заново в объект или передается через второй файл, поскольку ничто из этого не меняет того, как выглядит дерево в любом сопоставленном узле — оно просто перемещает значение туда, куда правило не было рассчитано заглядывать. У семантического анализа такой проблемы нет, поскольку он изначально ничего не сопоставлял; он все так же задает тот же вопрос — может ли это значение достичь этого стока, — независимо от того, сколько промежуточных шагов находится между ними.
Самое сложное — это реальный JavaScript и TypeScript
Большая часть бэкенд-кода на JavaScript и TypeScript пишется вовсе не для того, чтобы его было легко анализировать. Его пишут так, чтобы его было легко поддерживать: логика разбита по модулям, обработка запросов декомпозирована на уровни маршрутизации и цепочки промежуточного ПО (middleware), а повторяющаяся логика интерфейсов или формирования ответов вынесена в общие вспомогательные функции, принимающие один конфигурационный объект вместо длинного списка параметров. Такие фреймворки, как Express, поощряют именно такой подход, и это хорошая инженерная практика. Это же превращает единственное загрязненное значение в движущуюся цель: к тому моменту, когда параметр запроса доберется туда, где он фактически используется, он вполне мог пройти через несколько вызовов функций, быть упакован в объект вместе с дюжиной посторонних полей, пересечь границу модуля через обычный импорт и собраться заново в новую строку посредством интерполяции шаблонных литералов.
Анализ, который отслеживает только прямые присваивания в рамках одной функции, здесь абсолютно бесполезен. Он либо не найдет проблему, либо, что случается сплошь и рядом, найдет ее, но не сможет восстановить путь, который человек-рецензент сможет реально проверить, что на практике близко к нулю по полезности. Работа с таким классом кода требует моделирования деструктуризации (включая деструктурированные типизированные параметры функций), сокращенного синтаксиса свойств объектных литералов как транзитных элементов, привязки позиционных аргументов к параметрам при вызовах, разрешения импортов через модули ES, распространения возвращаемых значений через несколько вложенных функций, детального отслеживания внутри длинных шаблонных литералов с несколькими точками интерполяции, а также специфичных для фреймворков знаний о том, что именно представляет собой сток.
. Уязвимость, приведенная ниже, попала в проект в рамках одного коммита, объединившего около десятка нес相关ных интеграций: 62 файла, около 16 800 измененных строк, из которых на уязвимый путь приходится всего несколько сотен. Это обычный способ проникновения такого рода багов в кодовую базу — в виде маленького, ничем не примечательного фрагмента гораздо более крупного изменения, большая часть которого вообще к нему не относится. Любой, кто смотрит на этот дифф — будь то человек под давлением дедлайна или агент, которому поручено проверить код на наличие проблем, — должен уделить одному деструктурированному полю, переданному между двумя файлами, столько же внимания, сколько и остальным шестидесяти файлам, запрашивающим это поле одновременно. ИИ-агенты все лучше справляются с написанием такого кода и с беглым просмотром на предмет «здравого смысла», но они также могут потерять нить в большом диффе или с уверенностью заявить, что все в порядке, когда это не так. Быстрый просмотр на предмет того, что «выглядит разумно», и доказательство того, где значение может или не может оказаться — это две разные задачи, и их легко перепутать.
. Обратный вызов FlowiseAI, приведенный ниже, является наглядным примером именно таких условий и демонстрацией того, на что способен лежащий в основе анализ, когда все описанные выше факторы работают в связке от начала и до конца.
Технические детали
Компонент
routes/oauth2/index.ts & routes/oauth2/templates.ts
Эндпоинт
GET /api/v1/oauth2-credential/callback
Требуемая аутентификация
Отсутствует
Класс
Отраженная HTML-инъекция (частично смягчена существующим промежуточным ПО для санитизации)
Появилось в
Одиночный коммит с функцией, объединяющий несвязанные интеграции (62 файла, ~16 800 строк)
Коммит
30c4180d9737f937ec9c943c6241e4039a90e3d4
Статус
Исправлено независимо командой FlowiseAI
Учетные данные Flowise могут передаваться через процесс авторизации OAuth2. Когда провайдер выполняет перенаправление назад, он обращается к описанному выше эндпоинту, реализованному в index.ts. Обработчик считывает строку запроса и при любом сбое генерирует HTML-страницу ошибки с описанием проблемы. Все фрагменты взяты из коммита, в котором присутствовала эта уязвимость.
БраузерGET .../callbackindex.tsобработчик маршрутаtemplates.tsHTML-рендерерБраузерstate=<payload>generateErrorPage()res.send(html)Параметр состояния state из запроса передается из обработчика маршрута в рендерер шаблона и возвращается обратно в теле ответа.
Источник
Сам обработчик представляет собой анонимный callback-метод, переданный в маршрутизирующий API Express, а не функцию, вызываемую напрямую где-либо в этом файле. Он запускается только тогда строка, когда Express вызывает его с реальными объектами req и res для соответствующего запроса, поэтому распознавание req.query в качестве источника здесь означает понимание того, что именно router.get(path, handler) делает с параметром handler. Значения state, error и error_description поступают прямо из строки запроса без какой-либо валидации.
Формирование сообщения
Переменная state внедряется в новую строку с помощью интерполяции шаблонных строк, а затем передается в качестве второго позиционного аргумента в функцию generateErrorPage. Механизму анализа требуется понимать, какой именно параметр привязывается к какому аргументу, поскольку первый и третий аргументы не являются скомпрометированными.
Пересечение границы файла
Скомпрометированный аргумент попадает в параметр message, после чего упаковывается заново с использованием сокращенного синтаксиса литерала объекта (message,) в качестве одного из полей объекта параметров, состоящего из девяти полей. Большинство этих полей представляют собой жестко заданные литералы, не имеющие отношения к запросу.
Повторная деструктуризация в целевом шаблоне (синке)
Функция generateOAuth2ResponsePage выполняет деструктуризацию объекта прямо в своем списке параметров, после чего интерполирует переменную message в шаблонную строку длиной около 70 строк вместе с несколькими чистыми интерполяциями. Возвращаемая строка поднимается обратно через два кадровых фрейма до файла index.ts, присваивается переменной errorHtml и передается в метод res.send().
Анализатор уязвимостей · tssecurity:S5131
Реальный путь потока данных из 16 точек, обнаруженный нашим механизмом, сокращен до 10 значимых переходов. Нажмите на шаг, чтобы увидеть точное место его расположения.
1. Источник: req.query.state
index.ts:159
2. Шаблонная строка `...${state}`
index.ts:190
3. Аргумент №2 функции generateErrorPage(...)
index.ts:188
4. Параметр `message`
templates.ts:112
5. Сокращенное свойство в объекте параметров
templates.ts:118
6. Аргумент функции generateOAuth2ResponsePage(...)
templates.ts:113
7. Деструктуризация из типизированного параметра
templates.ts:21
8. Шаблонная строка `<div class="message">${message}</div>`
templates.ts:69
↑ возвращаемое значение, на 2 кадровых фрейма выше
9. Синки: res.send(errorHtml)
index.ts:195
Ни один из приведенных выше шагов не является экзотическим. Это идиоматический TypeScript — код такого типа любая команда или любой агент-кодер пишет изо дня в день. Именно поэтому он отлично подходит для демонстрации: здесь теория выполняет ту же работу, что и в литературе, будучи примененной к коду, который никто не писал с расчетом на сканер. Для распространения этого загрязнения от источника до синка движку необходимо как минимум следующее:
- Деструктуризация как конструкция, переносящая загрязнение, как в инструкциях const { x } = obj, так и в деструктурированных типизированных параметрах функций.
- Свойства сокращенного литерала объектов в качестве проходных элементов, позволяющие выделить одно загрязненное поле среди восьми незагрязненных собратьев в том же объекте.
- Привязка позиционных аргументов к параметрам при вызове, а не просто констатация того факта, что «эта функция где-то была вызвана с загрязненным аргументом».
- Разрешение через обычный импорт ES, рассматривающее два файла как единый граф вызовов, а не как две независимые единицы.
- Распространение возвращаемого значения через несколько вложенных вызовов функций.
- Детальное отслеживание внутри длинной шаблонной строки, отделяющее одну загрязненную интерполяцию от нескольких незагрязненных, находящихся рядом с ней.
- Знание точек входа с учетом фреймворка: router.get(path, handler) — это то, что вызывает обработчик с реальными объектами req и res во время запроса, что делает req.query источником данных в первую очередь внутри callback-функции, которую никто не вызывает напрямую.
- Знание синков с учетом особенностей фреймворка: метод res.send() в объекте ответа Express отображается как HTML, и это происходит через несколько строк после вызова не имеющего отношения к делу метода res.setHeader(), внутри условной ветки, внутри асинхронного обработчика маршрута и внутри блока try.
Инструмент, основанный на регулярных выражениях или сопоставлении шаблонов для отдельных функций, не сможет продвинуться дальше второго шага. Инструмент с поверхностной межпроцедурной поддержкой теряет ценность при переупаковке литерала объекта или распаковке деструктурированного параметра. Пропуск хотя бы одного из этих элементов приведет к тому, что движок либо отбросит находку, либо сохранит ее, не имея возможности обосновать путь, что для проверяющего лица, решающего, стоит ли доверять отчету, равносильно одному и тому же сбою.
Влияние на реальный мир и исправление
Flowise запускает глобальное промежуточное ПО (middleware) для санитизации каждого параметра запроса на базе библиотеки sanitize-html специально для защиты от XSS. Конфигурация по умолчанию удаляет теги <script> и атрибуты обработчиков событий, но ее белый список по-прежнему разрешает горстку «безопасных» тегов, включая якоря со значениями href http/https. Контролируемый злоумышленником тег <a href="https://evil.example/login"> переживает санитизацию в целости и сохранности и отображается как обычная ссылка в собственном домене Flowise. Это примитив для фишинга, а не выполнение кода, поэтому реальное влияние невелико, даже несмотря на то, что правило отраженного XSS сработало именно так, как должно: сам поток сообщается корректно, просто защитное средство находится на один уровень выше, в коде, который правилу не нужно анализировать, чтобы быть правым. Промежуточное ПО здесь не обходится стороной: его белый список просто не охватывает каждый контекст, в котором может оказаться подобное значение.
Попробуйте · GET /api/v1/oauth2-credential/callback
🔗 Отображается как активная разметка (например, кликабельная ссылка) на реальном домене — таково фактическое поведение в продакшене до исправления.
Отрендеренный ответ
Исходный код необработанного HTML (выдержка)
Эта песочница запускает реальные функции generateOAuth2ResponsePage, generateErrorPage и escapeHtml из обоих коммитов на стороне клиента для любого введенного вами текста. Кроме того, она всегда сначала запускает упрощенную репродукцию поведения по умолчанию для sanitizeMiddleware, соответствующую реальному развертыванию: удаление тегов <script> и их содержимого, сохранение небольшого белого списка тегов, разрешение только http(s)-якорей. Именно поэтому влияние сводится к «примитиву фишинга», а не к полноценному выполнению скриптов, независимо от того, какой код шаблона активен.
Команда FlowiseAI исправила эту уязвимость независимо до нашей публикации. Теперь title, statusIcon, statusText, message и details проходят через функцию явного экранирования HTML перед интерполяцией в шаблон, закрывая уязвимость на уровне самого шаблона, а не полагаясь на то, что вышестоящее средство санитизации угадает правильную политику для каждого нижестоящего синка.
Резюме
Пейлоуд здесь ничем не примечателен — это тег-якорь в сообщении об ошибке. То, что сделало его достойным отслеживания, заключалось в необходимости рассматривать параметр запроса, паттерн контейнера опций (options bag), деструктурированный типизированный параметр, 70-строчную шаблонную строку и связку фреймворка, вызывающую обработчик напрямую, как единый непрерывный конвейер — без потери загрязнения на этом пути и без поднятия тревоги по поводу восьми других совершенно невинных полей, проходящих через тот же объект. Деструктуризация, объекты опций, длинные шаблонные строки и фреймворк, молчаливо вызывающий обработчик, который никто не вызывает напрямую, не являются краевыми случаями; это то, как выглядит обычный TypeScript, независимо от того, написан ли он вручную или с помощью агента ИИ. Движок потока данных должен обрабатывать все это с высокой точностью, шаг за шагом, на точно таком же коде, а не только на игрушечных примерах, которые обычно используются для демонстрации.
Дата
Событие
2025-06-06
Уязвимость добавлена (коммит 30c4180d9737)
2026-02-19
Обнаружено Sonar
2026-02-24
Независимо исправлено командой FlowiseAI (коммит 917e02eb3673) через пять дней после обнаружения и до того, как Sonar связался с ними









