
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 схлопывает сегменты ../ до маршрутизации. Но URL-закодированные разделители переживают сопоставление с маршрутом и декодируются ещё ровно один раз на пути к вызову файловой системы, восстанавливая обход уже после всех проверок.
Неаутентифицированный злоумышленник мог прочитать любой файл, доступный на чтение процессу приложения, — включая его собственное окружение, где хранится секрет подписи 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