ローカルLLMのUIとして事実上の標準になっている Open WebUI(GitHub スター 146,800超・2026-07-27 時点の実測)で、ターミナルプロキシ機能に関する3件の Open WebUI 脆弱性 が公表されました。中心にあるのが CVE-2026-59221 で、パストラバーサル対策として入っていた「安定するまで最大8回デコードする」処理を、9回パーセントエンコードした ../ が素通りするというものです。この記事では、一次ソース(GitHub Security Advisory・修正コミット・各タグのソースコード)で事実を裏取りしたうえで、3世代の実コードを取り寄せて実際に叩いた結果と、「自分のインスタンスは条件に当てはまるのか」を判定するための手順を整理します。攻撃の再現手順やPoC(実証コード)そのものは扱いません。
30秒でわかる|この記事のポイント
「CVSS 7.7 = 誰でも侵入できる」ではありません。まず条件を見てください。
・成立条件は3つとも「ターミナルサーバー接続」。管理者がターミナルサーバー接続を設定し、そのユーザーにアクセス権を与えている場合にだけ成立する。接続が0件のインスタンスは、この3件のプロキシ経路に到達できない
・認証は必要。ベクトルは PR:L=ログイン済みアカウントが前提。認証不要のRCEではない。ただし管理者に設定を弱めさせる必要はなく、既にある接続への通常のアクセス権だけで成立する
・中身は「8回デコードの、あと1回」。多重エンコード対策の上限が8回で、上限到達時にそのまま処理を続けていたため、9回エンコードした ../ がまだエンコードされたままチェックを通過し、上流で ../ に戻った
・影響は 0.9.6 以上 0.10.0 未満(CVE-2026-59221)。前段の CVE-2026-54017 は 0.9.5 以下、CVE-2026-59224 は 0.10.0 未満。修正版は 0.10.0、本記事執筆時点の最新は 0.10.2
・更新しても残るものがある。CVE-2026-59224 で指摘された X-User-Id の未署名転送は 0.10.0 でも main でも変わっておらず、上流ターミナルサーバーの隔離は運用側の責任として残る
なお、こうした「取り込んだ値の信頼境界がどこでズレるか」という問題を体系的に押さえたい場合は、サプライチェーンセキュリティ2026|攻撃手法・防御ツール・実践チェックリストを合わせて読んでください。本記事はそのうち「セルフホストの Open WebUI で、多重エンコードという1点がどう境界を壊したか」に絞った実務編にあたります。
Open WebUI 脆弱性 CVE-2026-59221 の時系列
日付は一次ソース(GitHub Releases・Advisory・コミット・NVD/OSV API)の実測値です。表記はいずれもUTCで揃えています。修正が世に出た日と、CVE番号が公になった日の間に3週間以上の開きがある点が、この事案の運用上のポイントです。
| 日付(UTC) | 出来事 | 出典 |
|---|---|---|
| 2026-05-10 | v0.9.5 リリース。_sanitize_proxy_path は1回だけデコードする実装 |
GitHub Releases |
| 2026-06-01 | v0.9.6 リリース。「安定するまで最大8回デコード」ループが入る | GitHub Releases |
| 2026-06-11 | GHSA-r2wg-2mcr-66rv(CVE-2026-54017) 公開。影響 0.9.5 以下、修正 0.9.6 以上 | GitHub Advisory |
| 2026-06-16 | PR #26050 マージ。上限到達時に拒否する2行を追加(terminals.py と models.py) | GitHub PR / commit |
| 2026-06-29 | v0.10.0 リリース(19:17 UTC)。21分後に v0.10.1 | GitHub Releases |
| 2026-07-01 | v0.10.2 リリース(本記事執筆時点の最新) | GitHub Releases |
| 2026-07-02 | GHSA-frvj-c5qp-xj4w(CVE-2026-59221) と GHSA-j657-m4c4-24jq(CVE-2026-59224) 公開 | GitHub Advisory |
| 2026-07-09 | NVD と OSV に CVE-2026-59221 が登録(NVD ステータスは Analyzed) | NVD / OSV API |
この並びで注意したいのは、修正コミットが入った 6月16日、修正版が出た 6月29日、CVE番号が公になった 7月2日、NVD に載った 7月9日という4段階のズレです。責任ある開示の流れとしては自然ですが、防御側の実務では意味が違ってきます。日常的に最新版へ追従していた運用なら、CVE番号を知る前に既に守られていたことになります。逆にバージョンを固定し「CVEアラートが来たら対応する」運用だと、NVD 登録の 7月9日まで約3週間、対象であることに気づけません。
もう一点、前段の CVE-2026-54017 のアドバイザリ本文には「Fix」として8回デコードのループが掲載されています。ただし日付を見ると、v0.9.6 のリリース(6月1日)はアドバイザリ公開(6月11日)より10日早いため、これは「アドバイザリが提案した修正を後から採用した」のではなく、既に 0.9.6 に入っていた修正を、公開時に事後的に記載したものです。順序を取り違えると経緯を読み違えるので、ここは分けて理解しておく必要があります。結果として起きたことは変わりません——その8回デコードが、次の CVE-2026-59221 の対象になりました。
まず自分の環境を判定する:Open WebUI で影響を受ける条件
バージョンを調べる前に確認すべきことがあります。この3件はいずれも、ターミナルサーバー接続が設定されていることが前提です。アドバイザリ本文にも「管理者が設定したターミナルサーバーへのアクセスを与えられた利用者」と明記されており、コード上も has_connection_access(user, connection, ...) の通過が必須です。設定された接続が0件なら、server_id に何を渡してもルートは 404 を返して終わります。多くのセルフホスト構成——手元のOllamaに繋いでチャットするだけの使い方——は、この時点で対象外です。
そもそも「Open WebUI にターミナル機能があること自体を知らなかった」という方も多いはずです。これは管理者が外部のターミナルサーバー(コンテナ内のシェルを提供するサービスや、それを束ねるコーディネーター)を接続として登録しておき、利用者がブラウザ上のUIからそこへ繋いで操作できるようにする仕組みです。Open WebUI 本体はその中継役——リバースプロキシ——として振る舞います。今回の3件は、いずれもこの中継部分の実装に関するもので、チャットやRAG、モデル管理といった中核機能とは独立しています。
判定は次の順で行ってください。
手順1:ターミナルサーバー接続が設定されているか
接続は環境変数、または管理者設定に永続化された設定値のどちらかで入ります。既定値は空配列です。
# 環境変数での設定を確認(既定は空配列 "[]"。未設定=接続なし)
docker exec <コンテナ名> printenv TERMINAL_SERVER_CONNECTIONS
# docker compose を使っている場合は定義ファイル側も確認する
grep -rn "TERMINAL_SERVER_CONNECTIONS" docker-compose.yml .env 2>/dev/null
環境変数が空・未設定でも、管理者画面から追加した接続は設定ストアに永続化されます。管理者設定のターミナルサーバー欄を開き、登録済みの接続が0件であることを目視で確認してください。0件であれば、以降のバージョン確認は緊急性を持ちません(それでも更新は推奨します)。
手順2:稼働中のバージョンを実測する
インストール方法(Docker / pip / ソース)によらず使えるのは、アプリ自身が返すバージョンエンドポイントです。v0.10.0 のソースで @app.get('/api/version') が認証なしで定義されていることを確認済みです。
# 稼働中のインスタンスに実際に聞く(最も確実)
curl -s http://<あなたのOpen WebUI>/api/version
# → {"version":"0.10.2","deployment_id":"..."} のように返る
返ってきた版が 0.10.0 未満なら CVE-2026-59221 の影響範囲(正確には 0.9.6 以上 0.10.0 未満)、0.9.5 以下なら CVE-2026-54017 の範囲、0.10.0 未満なら CVE-2026-59224 の範囲です。:latest タグを使っている場合はタグ文字列から版を判別できないため、必ずこのエンドポイントで実測してください。ローカルにキャッシュされた古いイメージがそのまま動いていることがあり、「pull した=更新された」とは限りません。
なおこのエンドポイントは認証を要求しないため、外部公開しているインスタンスは第三者からもバージョンを読める点は認識しておいてください。バージョン隠蔽は根本対策にはなりませんが、公開範囲を絞る判断材料にはなります。
手順3:プロキシ経路が外部から叩ける状態か
ターミナルプロキシは /api/v1/terminals 配下にマウントされています。リバースプロキシ側でこの経路をどう扱っているかを確認します。
# 自分のnginx/Caddy等の設定で terminals 経路の扱いを確認
grep -rn "api/v1/terminals" /etc/nginx/ 2>/dev/null
# 認証済みユーザーから見える接続一覧(要ログイン。空配列なら接続なし)
curl -s -H "Authorization: Bearer <あなたのAPIトークン>" \
http://<あなたのOpen WebUI>/api/v1/terminals/
最後のコマンドが [] を返すなら、そのアカウントからアクセスできるターミナルサーバーは存在しません。
何が起きたのか:8回デコードの「あと1回」
問題の関数は backend/open_webui/routers/terminals.py の _sanitize_proxy_path です。役割は、利用者が指定したパス片を上流のターミナルサーバーへ中継する前に、../ による脱出を潰すことです。
v0.9.5 では、デコードは1回だけでした。
decoded = unquote(path)
normalized = posixpath.normpath(decoded)
cleaned = normalized.lstrip('/')
if cleaned.startswith('..') or cleaned == '.':
return None
ここに CVE-2026-54017 の2つ目の経路が刺さります。%252e%252e を渡すと、1回のデコードでは %2e%2e になるだけで .. にはなりません。normpath はこれをただの文字列として扱い、startswith('..') も成立しないのでチェックを通過します。そして中継先の URL に組み込まれた %2e%2e を、上流のサーバーがもう一度デコードして .. に戻す——境界のこちら側とあちら側でデコード回数が食い違うことが、この一連の事案の核心です。
v0.9.6 では「安定するまで繰り返しデコードする」方針に変わり、無限ループを避けるため上限8回が設けられました。
decoded = path
for _ in range(8):
once = unquote(decoded)
if once == decoded:
break
decoded = once
# ↓ 上限に達した場合もここへ来てしまう
normalized = posixpath.normpath(decoded)
見落とされたのは、ループが「安定して抜けた」のか「上限に達して抜けた」のかを区別していないことです。9回エンコードされた値は、8回デコードしてもまだ1段エンコードが残っています。残った %2E%2E%2F... は .. で始まらないためチェックを通過し、上流が最後の1回をデコードして ../ が完成します。
修正コミット(PR #26050)のメッセージは、この構造を開発者自身の言葉でこう説明しています——上限は固定の深さであって、安定性の保証ではない、と。
v0.10.0 で追加されたのは、たった2行です。
# Fail closed: 上限到達後もまだデコードできる=上流がさらに解釈する、として拒否する
if unquote(decoded) != decoded:
return None
「まだデコードできる値が残っているなら、それは上流でさらに解釈される値だから通さない」という判定に変わりました。エンコードの深さが9回でも100回でも結果は同じ拒否になります。上限を引き上げるのではなく、上限に達したこと自体を失敗として扱う——これが決着のつけ方でした。
「9回」なのか「10回」なのか——数え方の注意点
実務で確認するときに混乱しやすいのが、エンコード回数をどこから数えるかです。アドバイザリが「9回」と書いているのは、_sanitize_proxy_path() が受け取る関数の引数の時点での回数です。
一方、外部からHTTPリクエストを投げる場合、URLのパス部分は ASGI サーバー(Uvicorn など)が経路パラメータへ流し込む前に1回デコードします。つまり関数に9回エンコードされた値を届けたければ、リクエストのURL上は1段多い状態にしておく必要があります。アドバイザリ本文もこの点に注意を促しており、「外部からのリクエストは追加のエンコード層を1つ使える」と明記しています。
この「層が何枚あるか」の数え方こそが、今回の問題の本質を象徴しています。ASGI が1回、_sanitize_proxy_path が最大8回、上流サーバーがさらに1回——同じ文字列が経路上で3者に別々の回数だけ解釈され、そのどこかに1枚余分を挿し込めば境界が崩れる、という構造です。防御側が自分の担当区間だけを見て「うちは8回デコードしているから安全」と考えると、前後の層が見えません。
同じ設計ミスは2か所にあった
PR #26050 が直したファイルは1つではありません。routers/models.py の _safe_static_redirect_path にも、まったく同じ形のループが上限2回で存在していました。こちらはリダイレクト応答の Location を組み立てる関数です。
for _ in range(2):
decoded = unquote(path)
if decoded == path:
break
path = decoded
上限が2回ということは、3回エンコードすれば同じ理屈で抜けられる構造だったことになります。追加された修正も同一で、if unquote(path) != path: return None の2行です。「デコード上限を設けたら、上限に達したときは必ず拒否する」という一般則として、自分のコードベースにも持ち帰れる部分です。
パス片"] --> B["Open WebUI
_sanitize_proxy_path"] B --> C{"最大8回
デコード"} C -->|"安定して抜けた
(通常のパス)"| D["normpath と
'..' 判定"] C -->|"上限8回に達した
まだエンコードが残っている"| E{"ここの扱いが
版で分かれる"} E -->|"〜v0.9.6:そのまま続行"| D E -->|"v0.10.0:拒否して終了"| Z["400 Invalid path
(ここで止まる)"] D -->|"'..' で始まらない"| F["中継先URLを組み立て
base_url + safe_path"] F --> G["上流ターミナル
サーバー"] G --> H["最後の1回をデコード
→ '../' が成立"] style E fill:#fff4d6,stroke:#c47f00,stroke-width:2px style H fill:#ffe3e3,stroke:#c92a2a,stroke-width:2px style Z fill:#d9f5ec,stroke:#0b7a63,stroke-width:2px
実測:1〜12回エンコードで3世代のガードを叩いてみた
アドバイザリの記述が自分の理解と一致するかを確かめるため、3世代それぞれのタグから実際の _sanitize_proxy_path を取り寄せ、同じロジックを手元で再現して、../admin/system を1回から12回までエンコードした値を通しました。ネットワーク越しの攻撃ではなく、純粋な関数の入出力比較です。
結果は次のとおりです(PASS=ガードを通過してしまう/BLOCK=拒否される)。
| エンコード回数 | 入力バイト数 | v0.9.5(1回デコード) | v0.9.6(最大8回) | v0.10.0(上限到達で拒否) |
|---|---|---|---|---|
| 1回 | 45 | BLOCK | BLOCK | BLOCK |
| 2回 | 75 | PASS | BLOCK | BLOCK |
| 3〜7回 | 105〜225 | PASS | BLOCK | BLOCK |
| 8回 | 255 | PASS | BLOCK | BLOCK |
| 9回 | 285 | PASS | PASS | BLOCK |
| 10〜12回 | 315〜375 | PASS | PASS | BLOCK |
境界が3世代できれいに分かれました。v0.9.5 は2回以上で抜け、v0.9.6 はちょうど9回から抜け、v0.10.0 はどの深さでも拒否します。アドバイザリが「9回エンコード」と名指ししている理由が、この表の1行として見えます。
9回エンコード時に v0.9.6 のガードが返した値と、上流での見え方も確認しました。
| 項目 | 実測値 |
|---|---|
| 入力の長さ | 285 バイト(アドバイザリ記載と一致) |
| v0.9.6 の戻り値 | %2E%2E%2F%61%64%6D%69%6E%2F%73%79%73%74%65%6D |
| 上流が1回デコードした結果 | ../admin/system |
| 実際に組み立てられるURL | base_url + /../admin/system |
注目すべきは285バイトという長さです。多重エンコードの回避と聞くと「非現実的に巨大なURLが要るのでは」と考えがちですが、9回でもこの程度に収まります。長さによる暗黙の防御は効きません。
一方で、v0.10.0 の判定が正常なパスを壊していないことも確認しました。api/health はそのまま通り、files/my%20doc.txt は files/my doc.txt として通ります。1〜2回で安定する普通のパスは影響を受けず、深いエンコードだけが落ちるという、意図どおりの挙動です。
同じ「パスの組み立てを信じてしまう」系統の問題は、他のセルフホスト製品でも繰り返し出ています。認証不要でファイルを読まれた事例としては、Windmill 脆弱性 CVE-2026-29059|認証不要でファイルと秘密情報を読まれる、確認と対策が同じ CWE-22 の系譜にあり、比較して読むと「認証の要否」でリスクの質がどう変わるかが分かりやすくなります。
CVE-2026-59224:更新後も残る「X-User-Id」の前提
同じファイルには、もう1件別の問題が公表されています。CVE-2026-59224(CVSS 8.0・CWE-287/CWE-290)で、こちらはパスではなく利用者の識別子の扱いです。アドバイザリは2人の報告者による2つの経路を統合しています。
WebSocket経路では、ws_terminal が受け取った session_id を検証もエンコードもせずに上流URLへ埋め込み、そのうしろに ?user_id=<呼び出し元> を付けていました。session_id に ? や & をエンコードして忍ばせると、Open WebUI 側の1回のデコードを生き延びて上流で再デコードされ、付与されるはずの user_id より前に、攻撃者が選んだ user_id を割り込ませられる構造です。クエリ解析は最初に現れた値を採るため、上流は別人のスコープを解決します。
v0.10.0 では、ここに1行の対策が入りました。
# session_id を不透明なパス片としてエンコードし、'?'/'#'/'&' の混入を防ぐ
safe_session_id = urllib.parse.quote(session_id, safe='')
HTTP経路の方は事情が異なります。proxy_terminal は上流への転送時に headers = {'X-User-Id': user.id} を付けますが、この値には署名も整合性の束縛もありません。アドバイザリの「推奨修正」は2点あり、1点目が上記の session_id エンコード、2点目が「生の user_id / X-User-Id を渡すのをやめ、短命の署名付きクレーム(例:{uid, iat, aud} に対する HS256)へ置き換える」でした。
本記事の検証時点(2026-07-27)で v0.9.6・v0.10.0・main のいずれでも、この行は headers = {'X-User-Id': user.id} のままです。2点目は実装されていません。
これは「修正漏れで v0.10.0 が依然として脆弱」という意味ではありません。GitHub 上でこのCVEは 0.10.0 で修正済みとされ、影響範囲も 0.10.0 未満です。アドバイザリ自身、ヘッダ側の経路について「攻撃者が別の手段で上流に到達できる場合(直接アクセス・侵害された同居ホスト・SSRF)」と条件を明記しています。つまりこれは塞ぎ残した穴ではなく、「上流のターミナルサーバーは Open WebUI 以外から到達できない」という前提に立った設計です。
したがって、更新後も次の作業は利用者側に残ります。
・ターミナルサーバーを Open WebUI からのみ到達可能なネットワークに置く(外部公開しない・同一ネットワーク内の他コンテナからも絞る)
・上流側が X-User-Id を認可の根拠として使っているなら、その信頼は「Open WebUI 以外は接続できない」ことに全面的に依存していると理解しておく
・SSRF系の脆弱性が同居環境に存在すると、この前提が崩れる。Open WebUI 自身にも過去に別のSSRF(CVE-2026-54008 など)が公表されている点は、前提の脆さとして意識しておく
セルフホストのAI基盤が外部から探索される実態については、公開 MCP サーバが標的に|AI 開発者を狙い撃つ走査を SANS ISC が観測で観測データを扱っています。「内部向けのつもりだった経路が実際には到達可能だった」というパターンは、今回の前提とそのまま地続きです。
対策:優先度別の手順と、更新後にやること
最優先は 0.10.0 以降への更新です。本記事執筆時点の最新は 0.10.2(2026-07-01 リリース)で、3件すべてが修正済みの系列に入ります。
# Docker Compose の場合:イメージを引き直して再作成する
docker compose pull && docker compose up -d
# 反映を必ず実測で確認する(タグ文字列では判断しない)
curl -s http://<あなたのOpen WebUI>/api/version
すぐに更新できない場合の緩和策は、成立条件そのものを外す方向で考えます。
・ターミナルサーバー接続を一時的に外す。使っていない接続が残っているだけなら、削除するのが最も確実です。接続が0件なら3件とも成立しません
・アクセス権を絞る。接続を残す必要があるなら、has_connection_access が通る利用者・グループを最小限にします。この3件はいずれも「接続にアクセスできる一般利用者」が起点です
・上流ターミナルサーバーを隔離する。Open WebUI 以外から到達できない配置にします。これは更新後も続ける恒久的な作業です
・リバースプロキシで経路を絞る。/api/v1/terminals を外部から到達不能にできる構成なら、暫定的な遮断として機能します
更新したあとに、それで終わりにしない方がよい理由もあります。CVE番号の公開は 7月2日ですが、Open WebUI 0.10.0 は 6月29日には出ていました。この間に未修正のまま公開状態で運用していたインスタンスがあるなら、更新は「これ以降」を止めるだけで、既に起きたことは巻き戻せません。ターミナルサーバー接続を設定していて、かつ広いアクセス権を与えていた場合は、上流側のアクセスログで想定外のパスへの中継が無いかを確認しておくと安心です。
自分のコードベースに同じ形が無いかを探す
この事案でいちばん応用が効くのは、「デコード上限を設けたが、上限到達時に拒否していない」というパターンが自分のコードにも無いかという視点です。Open WebUI では2か所ありました。同じ形は、プロキシ・リダイレクト・ファイル配信を書いていれば珍しくありません。
# 回数を固定したデコードループを洗い出す(Python)
grep -rn -B2 -A6 "for _ in range(" --include="*.py" . | grep -i -A6 "unquote\|urldecode"
# JavaScript / TypeScript の同型(decodeURIComponent を回数で回している箇所)
grep -rn -B2 -A6 "for (let i = 0; i <" --include="*.ts" --include="*.js" . \
| grep -i -A6 "decodeURIComponent\|unescape"
ヒットした箇所については、次の3点を確認します。①ループが「安定して抜けた」のか「上限で抜けた」のかを区別しているか、②上限で抜けた場合に拒否しているか、③デコード後の値を渡す先が、もう一度デコードする相手か。3つ目が「はい」なら、境界の両側でデコード回数がズレる余地があります。安全側に倒すなら、正規化した文字列をそのまま中継せず、分割して空・.・.. を弾き、許可された要素だけを再結合する方式に置き換えるのが確実です。
なお Open WebUI パストラバーサルの一連の流れが示すとおり、「回避された対策を強化する」形の修正は、強化の方向を間違えると次の回避を招きます。上限8回を16回にしても、17回のペイロードが残るだけです。深さに依存しない判定に切り替えられないかを先に検討してください。
Open WebUI 脆弱性3件の影響範囲マトリクスと設計の教訓
3件の関係を1枚に整理します。同じファイル・同じプロキシ機能に対する、性質の違う3つの指摘です。
| 項目 | CVE-2026-54017 | CVE-2026-59221 | CVE-2026-59224 |
|---|---|---|---|
| アドバイザリ | GHSA-r2wg-2mcr-66rv | GHSA-frvj-c5qp-xj4w | GHSA-j657-m4c4-24jq |
| 深刻度(CVSS 3.1) | 7.7 High | 7.7 High | 8.0 High |
| ベクトル | AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N | AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N | AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:H |
| CWE | CWE-22 / CWE-918 | CWE-22 / CWE-918 | CWE-287 / CWE-290 |
| 影響バージョン | 0.9.5 以下 | 0.9.6 以上 0.10.0 未満 | 0.10.0 未満 |
| 修正版 | 0.9.6 以上 | 0.10.0 以上 | 0.10.0 以上 |
| 中身 | 生パス転送+二重エンコード回避 | 8回上限を9回エンコードで通過 | 識別子のなりすまし・クエリ注入 |
| 成立条件 | 接続へのアクセス権 | 接続へのアクセス権 | 接続へのアクセス権(+WebSocket経路) |
| 更新後の残作業 | なし | なし | 上流の隔離(X-User-Id は未署名のまま) |
深刻度を読むときは、3件とも PR:L である点を押さえてください。認証不要ではなく、ログイン済みアカウントが前提です。一方で S:C(スコープ変更)が共通して付いているのは、Open WebUI 自身の権限境界を越えて上流側のリソースへ手が届くためです。CVE-2026-59224 だけ AC:H(攻撃条件が複雑)と UI:R(利用者の関与が必要)が付き、代わりに完全性・可用性まで High になっています——共有チャットなどから相手のセッションIDを知る必要がある一方、成立すれば相手の稼働中ターミナルに繋がりうるためです。
なお今回の3件は、Open WebUI が2026年6月〜7月にかけて公開した一連のアドバイザリの一部です。公開日別に数えると 6月11日に16件、6月29日に5件、7月2日に13件が公表されています(リポジトリの公開アドバイザリは累計111件)。個別のCVEを追うより、「0.10.0 以降の系列に乗る」という判断のほうが実務的な規模感です。本記事は、そのうちターミナルプロキシに関わる3件に絞って掘り下げています。
設計側の教訓として、この事案が残したものは明快です。
・デコード上限を設けたら、上限に達したケースは必ず失敗として扱う。上限を8回から16回へ引き上げる対応では、常に「上限+1」が残ります。v0.10.0 の if unquote(x) != x: return None は、深さへの依存を断ち切った点で本質的でした
・同じパターンはコードベース内に複数あると考える。今回も terminals.py(上限8回)と models.py(上限2回)の2か所が同一コミットで直っています。1か所見つけたら、同じ形の検索を全体にかける価値があります
・信頼境界の両側でデコード回数が一致しているかを疑う。今回の根っこは「こちらで N 回、あちらで 1 回」というズレでした。正規化した値をそのまま中継するのではなく、許可する要素から組み立て直す(分割して空・.・.. を弾き、許可分だけ再結合する)方が原理的に安全です。これは CVE-2026-59221 のアドバイザリが推奨修正として挙げている方式でもあります
・未署名の識別子をヘッダで渡す設計は、ネットワーク隔離とセットでしか成立しない。X-User-Id のような主張は、受け取る側が「送り元は1つしかない」と確信できる配置でのみ意味を持ちます
最後に、Open WebUI 自体のライセンスについても触れておきます。GitHub の API 上は NOASSERTION と表示されますが、実際の LICENSE ファイルを読むと BSD 3-Clause をベースにした「Open WebUI License」 で、第4条としてブランディング(名称・ロゴ等)の改変・除去を禁じる条項が加わっています。ただし直近30日間の実利用者が50人を超えない配布・デプロイなどは、この制限の対象外と規定されています。セルフホストで社内利用する分には多くのケースで問題になりませんが、「一般的なOSSライセンスと同じ」と考えて再配布・ホワイトラベル提供を計画すると齟齬が出ます。バージョン更新のついでに、自分の使い方が条件に収まっているかも確認しておくとよいでしょう。
参照ソース
・GHSA-frvj-c5qp-xj4w|CVE-2026-59221 — Open WebUI 公式アドバイザリ(一次ソース)
・GHSA-r2wg-2mcr-66rv|CVE-2026-54017 — 前段のパストラバーサル / SSRF
・GHSA-j657-m4c4-24jq|CVE-2026-59224 — ターミナルプロキシの識別子なりすまし
・PR #26050 — Fail closed when the proxy/redirect path decode cap is exceeded — 修正コミット
・NVD — CVE-2026-59221 — CWE-22 / CWE-918
・Open WebUI v0.10.0 Release — 修正版
・open-webui/open-webui — 公式リポジトリ(スター数・LICENSE の実測元)