Kestra CVE-2026-49869 endsWith で許可リストを書くと全開放になる

オーケストレーション基盤の Kestra OSS に、未認証のリモート攻撃者がワーカーコンテナ内で root として任意コードを実行できる脆弱性が見つかっている。CVE-2026-49869 である。CVSS 3.1 は 10.0(Critical)で、1.0.45 と 1.3.21 で修正されている。CISA KEV には 2026-09-02 付で収載され、是正期限は 2026-09-05 とされた。収載から3日である。

原因の書かれ方が、この件の要点をそのまま言い当てている。NVD の記述はこうだ。AuthenticationFilterrequest.getPath().endsWith("/configs") を使って公開設定エンドポイントを Basic 認証の対象から除外していた。この検査が完全一致ではなく接尾辞の一致であるため、最後のセグメントが configs である任意の API パスが認証を完全に回避する

つまり脆弱性の本体は、暗号でもパーサでもなく、1行の文字列判定の書き方にある。

この記事では、なぜ endsWith が許可リストの照合に向かないのかを仕組みから読み解き、最後に Node.js で同じ形を再現して、判定を完全一致に変えると何が止まるのかを確かめるスクリプトを置く。Kestra を立てる必要はない。

何が起きるのか

Kestra は、ワークフローを定義して実行するための基盤である。API 経由でワークフローを作成し、実行できる。当然この API は認証で守られている。

ただし1本だけ例外があった。設定を返す公開エンドポイント /configs である。ログイン画面を描くために、認証前のブラウザから読めなければならない。だから認証フィルタの中で、このパスだけを対象外にする必要があった。

その除外を、パスが /configs で終わるかどうかで判定していた。

結果として、攻撃者は自分でパスの末尾を /configs に合わせるだけでよくなる。守りたかった API のパスの後ろに /configs を足した形が通ってしまえば、認証は一度も行われない。

そして Kestra は、シェルや Python を実行するプラグイン(plugin-script-shellplugin-script-python など)を既定で有効にした状態で出荷される。認証を回避してワークフローを作成・実行できるということは、その中に任意のシェルコマンドを書けるということだ。実行主体はワーカーコンテナの root である。

「認証回避」から「root での RCE」まで、追加の条件が1つも要らない。CVSS が 10.0 になっているのはこのためだ。

なぜ endsWith だと破れるのか

ここが仕組みの核心だ。許可リストの照合には、素朴に作るとほぼ必ず踏む落とし穴がある。

「この文字列を含むか」と「この文字列であるか」は、別の問いだということだ。

除外を書きたい実装者は、たいてい次のように考える。守りたいのは /configs という1本のエンドポイントだから、パスがそれかどうかを見ればいい、と。そして「それかどうか」を確かめる手段として、手近な endsWith を掴む。

素直な発想だが、この判定には前提がある。アプリのルーティングが、/configs で終わるパスを他に1本も持たないという前提だ。しかも、その前提は将来にわたって成り立ち続けなければならない。

REST の API は、階層を掘るほどセグメントが増える。/api/v1/executions の下に何が生えるかは、認証フィルタを書いた人には決められない。さらに悪いことに、攻撃者はルーティングに存在しないパスも投げられる。ルータが最終的にどう解決するかとは無関係に、フィルタの側は先に endsWith で「これは公開エンドポイントだ」と判断してしまう。

判定と、その判定が守っているつもりの対象が、別々に動いている。

CWE の付き方もそれを示している。この CVE には CWE-287(不適切な認証)だけでなく CWE-184(不完全な危険リストの利用) が付いている。許可リストに漏れがあった、という話ではない。照合の方法そのものが、リストの意味を壊していたという話だ。

Node.js で同じ形を再現する

言葉で読むより、動かしたほうが早い。認証フィルタの骨格だけを取り出して、判定を差し替えられるようにする。

前提は Node.js 18 以上だけである(node --version で確認できる。以下は標準モジュールのみを使い、npm install は不要)。

allowlist-demo.js として保存する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
'use strict';

// 認証が要らない公開エンドポイント(1本だけのつもり)
const PUBLIC_PATH = '/configs';

// (1) 壊れている判定: 末尾一致
function isPublicBySuffix(path) {
return path.endsWith(PUBLIC_PATH);
}

// (2) 直した判定: 完全一致
function isPublicByExact(path) {
return path === PUBLIC_PATH;
}

// 認証フィルタの骨格。公開パスなら素通り、それ以外は資格情報を見る
function handle(path, credentials, isPublic) {
if (isPublic(path)) return 'ALLOW (public)';
if (credentials) return 'ALLOW (authenticated)';
return 'DENY';
}

const requests = [
'/configs', // 本来の公開エンドポイント
'/api/v1/executions', // 守られるべき API
'/api/v1/executions/configs', // 末尾を合わせただけの細工
'/api/v1/flows/configs', // 同上
'/configs/../api/v1/flows', // 末尾は合っていない
];

for (const path of requests) {
const bySuffix = handle(path, null, isPublicBySuffix);
const byExact = handle(path, null, isPublicByExact);
const flag = bySuffix !== byExact ? ' <-- 判定が食い違う' : '';
console.log(path.padEnd(30), '| endsWith:', bySuffix.padEnd(20), '| exact:', byExact.padEnd(20), flag);
}

実行する。

1
node allowlist-demo.js

出力はこうなる。

1
2
3
4
5
/configs                       | endsWith: ALLOW (public)       | exact: ALLOW (public)       
/api/v1/executions | endsWith: DENY | exact: DENY
/api/v1/executions/configs | endsWith: ALLOW (public) | exact: DENY <-- 判定が食い違う
/api/v1/flows/configs | endsWith: ALLOW (public) | exact: DENY <-- 判定が食い違う
/configs/../api/v1/flows | endsWith: DENY | exact: DENY

資格情報を一切渡していないのに、3行目と4行目が ALLOW になっている。 これが「末尾が configs である任意のパスが認証を回避する」の中身である。

判定を isPublicByExact に変えると、公開したかった1本だけが通り、残りはすべて DENY に戻る。守りたかった性質が、比較演算子ひとつで回復する。

最後の行も見ておくと得るものがある。/configs/../api/v1/flowsendsWith でも通らない。この欠陥は「/configs を含めば通る」ではなく「/configs で終われば通る」だ。 攻撃の形が末尾に限られることは、防御側にとって検知の手掛かりになる。後述するログの見方はここに効く。

自分のコードで同じ形を探す

ここまでを踏まえて、手元のコードで確認できることが3つある。

1つ目。認証やアクセス制御の判定に endsWith / startsWith / includes を使っていないか。

1
git grep -n "endsWith\|startsWith\|indexOf\|includes" -- '*.js' '*.ts' | grep -i "path\|url\|route\|auth"

出てきた箇所を、「これは許可の判定か、それとも表示や分類のための判定か」で仕分ける。許可の判定なら、原則として完全一致か、正規化したうえでの厳密な照合に置き換える。表示の分類なら部分一致で構わない。同じ関数が、使われる場所によって危険度が変わる。

2つ目。除外リストは「例外の集合」として持つ。

1本しかないからと文字列を直接埋め込むと、比較の書き方が現場ごとに揺れる。集合にしておけば、照合の方法を1か所に閉じ込められる。

1
2
const PUBLIC_PATHS = new Set(['/configs', '/health']);
const isPublic = (path) => PUBLIC_PATHS.has(path);

Sethas は完全一致でしか真にならない。照合方法を選ぶ余地を、そもそも残さないのが要点である。

3つ目。パスの正規化をどこで行うか決めておく。

リバースプロキシとアプリでパスの正規化(末尾スラッシュ、パーセントエンコード、.. の解決)が食い違うと、プロキシ側で足した防御が抜ける。今回のようにアプリ側の判定が壊れているとき、プロキシで */configs を落とす緩和は有効だが、それは時間を稼ぐ措置であって修正ではない。正規化の責任をどちらが持つかを決めていないなら、そこが次の穴になる。

Kestra を使っているなら

対応は版を上げることだ。1.0 系なら 1.0.45、1.1 以上 1.3 系なら 1.3.21 以上へ。系統によって上げ先が違う。

更新の段取りに時間がかかるなら、先に経路を閉じる。この欠陥は未認証・ネットワーク経由で成立するので、UI や API をインターネットへ直接出している構成は、更新が終わるまで無認証で root コマンドを実行できる口を公開していることになる。

更新後は、既に踏まれていなかったかを見る。上のデモで確かめたとおり、通ってしまうのは末尾が configs のパスだけである。したがってアクセスログで、

  • 末尾が /configs で、かつ本来の公開設定エンドポイント以外のパスへの 2xx 応答

を探せば範囲を絞れる。あわせて、ワークフロー一覧に身に覚えのないフローが作成・実行されていないか、ワーカーコンテナから外向きに想定外の通信が出ていないかを見る。root で任意コードが動きうる欠陥なので、更新は再侵入を防ぐだけで、既に起きたことは消えない。疑いがあるなら、Kestra に登録した各システムの資格情報は再発行する。

使っていないスクリプト実行プラグインを無効化しておくのも効く。既定で有効という設計自体は変わらないので、次に似た欠陥が出たときの被害の上限がそこで決まる。

まとめ

  • CVE-2026-49869 は、認証フィルタの除外判定を endsWith で書いたために、末尾が configs の任意のパスで認証が丸ごと回避される欠陥である
  • スクリプト実行プラグインが既定で有効なため、認証回避がそのまま ワーカーコンテナ内 root での RCE に繋がる。CVSS 3.1 は 10.0
  • 修正版は 1.0.45 / 1.3.21。KEV 収載日 2026-09-02、是正期限 2026-09-05
  • 一般化できる教訓は1つ。許可の判定に部分一致を使わない。 完全一致か、集合の has で照合方法を1か所に閉じ込める

本記事の CVE の値(CVSS・影響版・修正版・KEV の収載日と期限)は NVD と CISA KEV カタログ(版 2026.09.02)で確認している。掲載中の CVE 一覧は CVE Watch にまとめている。


Kestra CVE-2026-49869 endsWith で許可リストを書くと全開放になる
https://blog.hashito.biz/2026/09/03/kestra-cve-2026-49869-endswith-path-allowlist/
著者
hashito
作成日
2026年9月3日
著作権

このバージョンはその範囲に入るのか

依存の話でいちばん間違えやすいのは ^1.2.3~1.2.3 がどこまでを許すかです(^0.2.3 のようにメジャーが 0 のときは規則が変わります)。範囲とバージョンを貼ると、下限・上限に展開したうえで一致・不一致とその理由を表示するsemver 範囲判定ツールを置いています。ブラウザの中だけで動き、入力はどこにも送信しません。