
著者: Krithik Babu P (@DarkLycn1976)
公開日: 2026-08-10
CVE: CVE-2026-19264
深刻度: Critical - CVSS 4.0 9.3 / CVSS 3.1 9.8
CWE: CWE-22 - 制限付きディレクトリ内のパス名の不適切な制限
影響を受けるバージョン: gitroomhq/postiz-app < 2.22.1
修正バージョン: v2.22.1
Postizは、ローカル保存されたメディアを、URLから供給されたパスセグメントをアップロードディレクトリに連結して結果をストリーミングで返すルートを通じて配信していました。パス正規化も包含チェックも認証もありません。
明白なトラバーサルペイロードは404を返します。Next.jsがルーティングの前に../セグメントを畳み込むためです。しかし、URLエンコードされた区切り文字はルートマッチングを生き残り、ファイルシステム呼び出しへの経路上で正確にもう一度だけデコードされ、すべてのチェックの先でトラバーサルを復元します。
未認証の攻撃者は、アプリケーションプロセスが読み取り可能な任意のファイルを読むことができます。JWT署名シークレットを保持する環境そのものも含みます。Postizはセッショントークンをそのシークレットで署名し、有効期限クレームなしで発行するため、シークレットの入手は、ファイル読み取りプリミティブを、管理者を含む任意のユーザーとしての永続的で偽造可能なセッションへと変換します。
1回の未認証GETリクエストでインスタンスを完全に乗っ取れます。
Postizは、オープンソースのソーシャルメディアスケジュール投稿プラットフォームです(執筆時点で約34,000のGitHubスター)。Next.jsフロントエンドとNestJSバックエンドで構成されています。代理店や小規模チームが、接続したソーシャルアカウント、予約投稿コンテンツ、請求を管理するために広くセルフホストされています。
セルフホスト環境では、アップロードされたメディアをオブジェクトストレージではなくローカルに保存できます。この動作は、単一の環境変数で制御されます:
STORAGE_PROVIDER=local
これは.env.exampleに同梱されている値であり、意図的にS3やCloudflare R2を設定しない限り、ほとんどのセルフホストユーザーがこの設定で実行しています。
ローカルストレージが有効な場合、next.config.jsは公開パス/uploads/:path*を内部APIルートにリライトします:
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
バグが関与する前から、このルートを興味深いものにする2つの性質があります:
[[...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
};
4行の中に3つの欠陥:
path.normalize()もpath.resolve()も呼び出されません。届いたセグメントはそのまま連結されます。filePathが依然としてUPLOAD_DIRECTORY内にあることを検証するものはありません。+ '/' +はコンポーネントを意味を持つパスではなくテキストとして扱います。結果はそのままcreateReadStream()に渡され、ファイル名から推測されたMIMEタイプでバイト列が呼び出し元にストリーミングされます。拡張子の許可リストもコンテンツフィルタもありません。
教科書通りの攻撃は次のとおりです:
GET /uploads/../../../etc/passwd
Postizではこれは404を返します。そして、この404こそが、このバグが発見されるまで生き延びたすべての理由です。
Next.jsはルーティング中にリクエストパスを正規化します。生の../セグメントは、ルーターがどのハンドラを呼び出すかを決定する前に畳み込まれます。リクエストがキャッチオールに到達する頃には、トラバーサルはすでに排除されています。パスがマッチするルートのない場所に解決されるか、ドットセグメントがなくなった状態で/uploads内に解決されるかのどちらかです。
素早くテストする人にとって、その404は*「フレームワークがこれを処理している」*と読めます。これは本物で、機能する防御です。問題はそれが存在しないことではなく、パイプラインのどこで実行されるかです。
ルートマッチングとリクエストハンドラは、パーセントデコードのパス回数が同じではありません。
区切り文字がパーセントエンコードされている場合、そのシーケンスはルートマッチング中はパス区切り文字ではありません。%2e%2e%2fは単なる不透明な文字列、つまり正規化子が触れる理由のない不活性なテキストです。これはルーティングを無傷で通過し、キャッチオールにマッチし、ハンドラの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は単一のデコードパスを経てもリテラルのままで、ドットになることはありません。ちょうど1層のエンコードがスイートスポットであり、「より強くエンコードする」ことが戦略ではないという有用な教訓です。
覚えておくべき不変条件:
デコードが完了する前に実行される制御は、シンクを保護していない。
ファイル読み取りプリミティブだけでもHighです。これをCriticalにしているのは、それが到達できるものです。
ステップ1 - 環境を読む。 Nodeプロセスの設定自体が、デプロイメントルートのディスク上に存在します。.envから得られるものには、とりわけ次のものがあります:
JWT_SECRET - セッショントークンの署名キーDATABASE_URL - Postgresの完全な認証情報ステップ2 - セッションを偽造する。 Postizは、jsonwebtokenを使用してHS256でJWT_SECRETによりセッショントークンに署名します。重要なのは、トークンが**expiresInなしで**発行されることであり、偽造トークンは無期限に有効です。
ステップ3 - 誰にでもなれる。 認証ミドルウェアは、idクレームを使用してデータベースからユーザーを再解決します。トークンのisSuperAdminのようなクレームを意図的に信頼しません。良い設計です。しかし、任意のidに署名できるようになれば、その強化は無意味になります。{ id: <victim user id> }に署名すると、正規のログインと見分けがつかないセッションが生成されます:
read .env → JWT_SECRET → sign({ id: victim }) → authenticated as victim, forever
私はこれを、実際のjsonwebtoken依存関係を使って、プロジェクトの実際の検証ロジックに対して検証しました。回収したシークレットで署名されたトークンは受け入れられ、同じトークンを間違ったシークレットで署名したものは拒否されました。対照実験は重要です。それがなければ、あなたが持っているのは発見ではなく仮定です。
ステップ4 - 並行経路。 DATABASE_URLだけでもPostgresへの直接アクセスに十分です。接続されたすべてのアカウントを読むか、管理者フラグを直接書き換えられます。
パスワード不要。事前アクセス不要。ユーザー操作不要。1回の未認証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が効いている2つのメトリクスです。このルートはセッションも被害者の操作も必要としません。攻撃者は、デフォルト設定に対してネットワーク越しに単独で攻撃します。
正直な制限要因は設定ゲートのみです。S3またはR2上のデプロイメントは、ルートが/404にリライトされるため、露出しません。これは影響を受ける範囲を減らしますが、その中の誰にとっても深刻度は変わりません。そしてlocalが同梱されたデフォルトです。
メンテナによるパッチ(7936062)は8行で、読む価値があります。こうした修正がしばしば正しくないのに対して、これは正しいからです:
+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 });
+ }
このパッチが正しく行っている2つの点:
resolve()はデコード完了後に..を畳み込むため、トラバーサルがどのようにルーティングをすり抜けたかは問題になりません。チェックは今、危険がある場所に置かれています。baseではなくbase + sepと比較する。 単純なfilePath.startsWith(base)は、/app/uploads-evil/xを/app/uploads内として受け入れてしまいます。古典的なプレフィックス一致バイパスです。セパレータを追加することでこれを防ぎ、filePath !== baseの条件によりディレクトリ自体も有効なまま許可されます。これが包含チェックの正しい形です。解決してから、末尾にセパレータを付けたベースと比較する。
時刻はすべてUTC、2026-07-20です(注記がない限り)。
| 時刻 | イベント |
|---|---|
| 05:55 | Postizチームにアドバイザリを報告 |
| 07:44 | メンテナが認識し検証 |
| 12:18 | 修正をコミットし、検証して公開 |
| 2026-08-07 14:13 | Postiz(CNA)によりCVE-2026-19264を採番 |
| 2026-08-07 14:15 | GitHub Security Advisory公開 |
報告からパッチ公開まで6時間23分。バグ報奨金制度のないオープンソースプロジェクトでのことです。私は専任のセキュリティチームを擁する組織で、報告が数か月間放置された経験があります。コーディネートしてくれたEnno Gelhausと、修正対応してくれたNevo Davidに感謝します。
テストに合格する制御は、正しい場所にあるとは限らない。 404は本物でした。Next.jsは本当に../を畳み込みます。その防御は単に、入力のデコードが完了する前に実行されただけで、ファイルシステム呼び出しではなくルーターを守っていたのです。緩和策を見つけたら、それが存在するかどうかだけでなく、シンクに対していつ実行されるかを問うてください。
エンコードは層であり、層はそれぞれ異なる速度で剥がされる。 リクエストパイプライン内の2つのコンポーネントがデコード回数について食い違うときはいつでも、その間のギャップは悪用可能です。ルートマッチャー、ミドルウェア、ハンドラはしばしば食い違います。
ファイル読み取りプリミティブは、プリミティブ自体ではなく、プロセスが到達できるものに基づいて評価する。 「任意のファイル読み取り」は情報漏えいのように聞こえます。しかし、環境が読み取れ、その中のシークレットがセッションに署名し、そのセッションが決して期限切れにならないため、Criticalになりました。スコアリングする前にチェーンを追跡してください。
期限切れのないトークンは、漏えいを永続的な侵害に変える。 短命のトークンでの署名キー漏えいは悪い一日で済みます。しかしexpiresInがなければ、シークレットをローテーションしない限り回復不可能であり、ほとんどの運用者はローテーションが必要だったことすら知らないでしょう。
対照実験を実行する。 間違ったシークレットで署名されたトークンが拒否されることを検証することが、実証された発見と仮定された発見を分けるものです。
Postizをセルフホストしている場合:
JWT_SECRETが漏えいしたと想定してください。影響を受けるバージョンを、STORAGE_PROVIDER=localで公開到達可能なホスト上で実行していた場合。ローテーションしてください。トークンに有効期限がないため、偽造されたものを無効化する唯一の方法はローテーションです。DATABASE_URLの認証情報と、接続済みプロバイダーのOAuthシークレットをローテーションしてください。%2eまたは%2fを含む/uploads/へのGETリクエストを確認してください。本調査は独立して実施され、協調的な開示(coordinated disclosure)に基づいてベンダーに開示されました。修正の公開とアドバイザリの公表後に公開しています。第三者のシステムにはアクセスしておらず、すべての検証はプロジェクト自身のソースから構築したローカルインスタンスに対して実施しました。
この記事はCC BY 4.0の下でライセンスされています。出典を明示すれば、自由に共有・改変できます。gitroomhq/postiz-appからのコード抜粋はセキュリティ分析のために引用されており、そのプロジェクトのライセンスの下にあります。
Krithik Babu P - @DarkLycn1976