
CVE-2026-19264 — Критическая неаутентифицированная уязвимость path traversal, приводящая к полному захвату экземпляра в Postiz (< 2.22.1). Технический разбор: обход порядка декодирования, эскалация через JWT_SECRET и анализ исправления в апстриме.

Автор: Krithik Babu P (@DarkLycn1976)
Опубликовано: 2026-08-10
CVE: CVE-2026-19264
Серьёзность: Критическая — CVSS 4.0 9.3 / CVSS 3.1 9.8
CWE: CWE-22 — Некорректное ограничение пути в пределах ограниченного каталога
Уязвимые версии: gitroomhq/postiz-app < 2.22.1
Исправлено в: v2.22.1
Postiz отдавал локально хранящиеся медиафайлы через маршрут, который склеивал сегменты пути из URL с каталогом загрузок и стримил результат обратно — без нормализации пути, проверки изоляции и аутентификации.
Очевидный traversal-пейлоад возвращает 404, потому что Next.js схлопывает сегменты маршрутизации. Но и декодируются ещё ровно один раз на пути к вызову файловой системы, восстанавливая обход уже после всех проверок.
../Неаутентифицированный злоумышленник мог прочитать любой файл, доступный на чтение процессу приложения, — включая его собственное окружение, где хранится секрет подписи JWT. Поскольку Postiz подписывает токены сессий этим секретом и выдаёт их без срока действия (expiry claim), его получение превращает примитив чтения файлов в постоянную подделываемую сессию под любым пользователем, включая администратора.
Один неаутентифицированный GET-запрос — и полный захват экземпляра.
Postiz — это открытая платформа для планирования публикаций в социальных сетях — на момент написания примерно 34 000 звёзд на GitHub — построенная на фронтенде Next.js и бэкенде NestJS. Она широко используется для самостоятельного размещения (self-hosting) агентствами и небольшими командами для управления подключёнными аккаунтами соцсетей, запланированным контентом и биллингом.
Самостоятельно размещённые (self-hosted) инсталляции могут хранить загруженные медиафайлы локально, а не в объектном хранилище. Это поведение управляется одной переменной окружения:
STORAGE_PROVIDER=local
Именно это значение поставляется в .env.example, поэтому большинство self-hosted-инсталляций работают именно с ним, если только администраторы сознательно не настроили S3 или Cloudflare R2.
Когда активно локальное хранилище, next.config.js переписывает публичный путь /uploads/:path* на внутренний API-маршрут:
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
Два свойства делают этот маршрут интересным ещё до рассмотрения какой-либо ошибки:
[[...path]] означает, что все оставшиеся компоненты пути приходят в виде массива, который обработчик волен интерпретировать.Когда STORAGE_PROVIDER имеет любое значение, кроме local, перезапись указывает на /404, и обработчик недостижим. Этот конфигурационный переключатель — единственное, что отделяет инсталляцию от данной уязвимости.
Обработчик до версии v2.22.1:
export const GET = async (request: NextRequest, context) => {
const { path } = await context.params;
const filePath =
process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
const response = createReadStream(filePath);
const fileStats = statSync(filePath);
// ... stream the file back to the caller
};
Три дефекта в четырёх строках:
path.normalize(), path.resolve() — не вызывается ни одна. Какие бы сегменты ни пришли, они склеиваются как есть.filePath по-прежнему находится внутри UPLOAD_DIRECTORY.+ '/' + обрабатывает компоненты как текст, а не как путь с семантикой.Результат попадает напрямую в createReadStream(), и байты стримятся вызывающему с MIME-типом, определённым по имени файла. Нет ни списка разрешённых расширений, ни фильтра содержимого.
Атака из учебника выглядит так:
GET /uploads/../../../etc/passwd
На Postiz это возвращает 404, и именно этот 404 — вся причина, по которой эта ошибка так долго оставалась необнаруженной.
Next.js нормализует путь запроса во время маршрутизации. Сырые сегменты ../ схлопываются до того, как роутер решает, какой обработчик вызвать. К моменту, когда запрос достигает catch-all, обход уже устранён — либо путь резолвится в место без подходящего маршрута, либо обратно внутрь /uploads, но уже без точечных сегментов.
Для того, кто тестирует быстро, этот 404 читается как «фреймворк сам это обрабатывает». Это настоящая, работающая защита. Проблема не в том, что её нет, — а в том, на каком этапе конвейера она срабатывает.
Сопоставление с маршрутом и обработчик запроса выполняют разное количество проходов процентного декодирования.
Если разделители процентно закодированы, во время сопоставления с маршрутом эта последовательность не является разделителем пути. %2e%2e%2f — просто непрозрачная строка, инертный текст, которого нормализатору незачем касаться. Она беспрепятственно проходит маршрутизацию, попадает под catch-all и декодируется по пути в params обработчика, где снова становится ../.
На этом этапе она конкатенируется с UPLOAD_DIRECTORY и передаётся в createReadStream() — в обход маршрутизации, в обход нормализации, в обход всех проверок, которые могли бы её остановить.
Рабочие варианты:
GET /uploads/%2e%2e%2fsecretdir%2fsecret.txt → 200, file outside the upload directory
GET /uploads/..%2f..%2f..%2fetc%2fpasswd → 200
GET /uploads/%2e%2e%2f%2e%2e%2f...%2fetc%2fpasswd → 200, returned the real /etc/passwd
Двойное кодирование не работает — %252e остаётся литеральным на протяжении единственного прохода декодирования и никогда не становится точкой. Ровно один слой кодирования — это оптимальная точка, что служит полезным напоминанием: «закодируй сильнее» — не стратегия.
Инвариант, который стоит запомнить:
Проверка, выполняющаяся до завершения декодирования, не защищает sink.
Примитив чтения файлов сам по себе — это уровень High. Критическим его делает то, до чего он добирается.
Шаг 1 — читаем окружение. Собственная конфигурация Node-процесса лежит на диске в корне развёртывания. Из .env можно получить, среди прочего:
JWT_SECRET — ключ подписи токенов сессийDATABASE_URL — полные учётные данные PostgresШаг 2 — подделываем сессию. Postiz подписывает токены сессий ключом JWT_SECRET алгоритмом HS256 через jsonwebtoken. Критически важно, что токены выдаются без expiresIn, поэтому подделанный токен действителен бессрочно.
Шаг 3 — становимся кем угодно. Аутентификационное промежуточное ПО заново извлекает пользователя из базы данных по claim'у id. Оно сознательно не доверяет таким claim'ам из токена, как isSuperAdmin, — хороший дизайн, — но это усиление защиты не имеет значения, если вы можете подписывать произвольный id. Подпись { id: <victim user id> } создаёт сессию, неотличимую от легитимного входа:
read .env → JWT_SECRET → sign({ id: victim }) → authenticated as victim, forever
Я проверил это на реальной логике верификации проекта с использованием настоящей зависимости jsonwebtoken: токен, подписанный восстановленным секретом, был принят, а тот же токен, подписанный неверным секретом, был отклонён. Контрольный случай важен — без него у вас предположение, а не находка.
Шаг 4 — параллельный путь. Одного DATABASE_URL достаточно для прямого доступа к Postgres: прочитать все подключённые аккаунты или напрямую переключить флаг администратора.
Никакого пароля. Никакого предварительного доступа. Никакого взаимодействия с пользователем. Один неаутентифицированный HTTP-запрос.
CVSS 4.0 9.3 AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 3.1 9.8 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
PR:N и UI:N — две метрики, которые делают основную работу. Маршрут не требует ни сессии, ни взаимодействия с жертвой — атакующий действует в одиночку, по сети, против конфигурации по умолчанию.
Единственный честный ограничитель — конфигурационный переключатель: инсталляции на S3 или R2 не уязвимы, поскольку маршрут переписывается на /404. Это уменьшает число затронутых, но не серьёзность для тех, кто в их числе, — а local поставляется по умолчанию.
Патч мейнтейнеров (7936062) — восемь строк, и его стоит прочитать, потому что он корректен так, как эти исправления часто не бывают:
+import { resolve, sep } from 'path';
...
- const filePath =
- process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
+ const base = resolve(process.env.UPLOAD_DIRECTORY!);
+ const filePath = resolve(base, (path ?? []).join('/'));
+ // Confine reads to UPLOAD_DIRECTORY. resolve() collapses any `..` segments
+ // (including URL-decoded ones), so this blocks every path-traversal variant.
+ if (filePath !== base && !filePath.startsWith(base + sep)) {
+ return new NextResponse('Not found', { status: 404 });
+ }
Две вещи, которые здесь сделаны правильно:
resolve() схлопывает .. после завершения декодирования, поэтому неважно, как обход был протащен через маршрутизацию. Теперь проверка находится там, где опасность.base + sep, а не с base. Наивный filePath.startsWith(base) принял бы /app/uploads-evil/x за путь внутри /app/uploads — классический обход через совпадение префикса. Добавление разделителя закрывает эту возможность, а условие filePath !== base сохраняет допустимость самого каталога.Это правильная форма проверки изоляции: сначала resolve, затем сравнение с базой и завершающим разделителем.
Всё время в UTC, 2026-07-20, если не указано иное.
| Время | Событие |
|---|---|
| 05:55 | Уведомление об уязвимости отправлено команде Postiz |
| 07:44 | Подтверждено и проверено мейнтейнерами |
| 12:18 | Исправление закоммичено, проверено и опубликовано |
| 2026-08-07 14:13 | CVE-2026-19264 присвоен организацией Postiz (CNA) |
| 2026-08-07 14:15 | Опубликован GitHub Security Advisory |
Шесть часов двадцать три минуты от сообщения до выпущенного патча — на открытом проекте без программы bug bounty. У меня были отчёты, которые месяцами лежали без ответа в организациях с выделенными командами безопасности. Признательность Enno Gelhaus за координацию и Nevo David за устранение уязвимости.
Проверка, проходящая ваш тест, не означает, что она находится в правильном месте. 404 был настоящим. Next.js действительно схлопывает ../. Защита просто срабатывала до того, как ввод был полностью декодирован, а значит, она защищала маршрутизатор, а не вызов файловой системы. Когда вы находите защитную меру, спрашивайте, когда она выполняется относительно sink, — а не только, существует ли она.
Кодирование — это слой, и слои снимаются с разной скоростью. Каждый раз, когда два компонента конвейера запроса расходятся во мнении о том, сколько раз нужно декодировать, разрыв между ними эксплуатируем. Сопоставители маршрутов, промежуточное ПО и обработчики часто расходятся во мнениях.
Оценивайте примитив чтения файлов по тому, до чего может дотянуться процесс, а не по самому примитиву. «Произвольное чтение файлов» звучит как раскрытие информации. Критическим оно стало потому, что окружение читаемо, секрет из него подписывал сессии, а эти сессии никогда не истекали. Проследите всю цепочку, прежде чем оценивать.
Бессрочные токены превращают утечку в постоянную компрометацию. Раскрытие ключа подписи при короткоживущих токенах — плохой день. Без expiresIn ситуация невосстановима без ротации секрета — и большинство операторов даже не узнают, что им это нужно.
Проводите контрольный тест. Проверка того, что токен, подписанный неверным секретом, отклоняется, — это то, что отличает подтверждённую находку от предположительной.
Если вы самостоятельно размещаете Postiz:
JWT_SECRET скомпрометированным, если вы запускали уязвимую версию на публично доступном хосте с STORAGE_PROVIDER=local. Выполните его ротацию. Поскольку токены не имеют срока действия, ротация — единственный способ аннулировать возможные подделки.DATABASE_URL и всех OAuth-секретов подключённых провайдеров, хранящихся в том же окружении.GET-запросов к /uploads/, содержащих %2e или %2f.Исследование проведено независимо и раскрыто вендору в рамках скоординированного раскрытия. Опубликовано после выхода исправления и публикации уведомления. Никакие сторонние системы не затрагивались — вся валидация выполнялась на локальном экземпляре, собранном из исходного кода проекта.
Этот разбор лицензирован по CC BY 4.0 — вы можете свободно делиться и адаптировать его с указанием авторства. Фрагменты кода из gitroomhq/postiz-app процитированы в целях анализа безопасности и остаются под лицензией того проекта.
Krithik Babu P — @DarkLycn1976