From d50c4d3dab62fa80b2a276271d0d4fb338cfa7df Mon Sep 17 00:00:00 2001
From: Jeremy Evans <code@jeremyevans.net>
Date: Sat, 7 Feb 2026 12:58:00 -0800
Subject: [PATCH] Implement OBS unfolding for multipart requests per RFC 5322
 2.2.3

Do this for both the Content-Disposition and Content-Type lines.

Co-authored-by: "William T. Nelson" <35801+wtn@users.noreply.github.com>
---
 CHANGELOG.md                 |  1 +
 lib/rack/multipart/parser.rb |  8 ++++++++
 test/spec_multipart.rb       | 23 +++++++++++++++++++++++
 3 files changed, 32 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index be1cd347a..b911d9197 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file. For info on
 - [CVE-2026-34785](https://github.com/advisories/GHSA-h2jq-g4cq-5ppq) `Rack::Static` prefix matching can expose unintended files under the static root.
 - [CVE-2026-34829](https://github.com/advisories/GHSA-8vqr-qjwx-82mw) Multipart parsing without `Content-Length` header allows unbounded chunked file uploads.
 - [CVE-2026-34827](https://github.com/advisories/GHSA-v6x5-cg8r-vv6x) Quadratic-time multipart header parsing allows denial of service via escape-heavy quoted parameters.
+- [CVE-2026-26962](https://github.com/advisories/GHSA-rx22-g9mx-qrhv) Improper unfolding of folded multipart headers preserves CRLF in parsed parameter values.
 
 ## [3.2.5] - 2026-02-16
 
diff --git a/lib/rack/multipart/parser.rb b/lib/rack/multipart/parser.rb
index d370f2ab4..fed78b850 100644
--- a/lib/rack/multipart/parser.rb
+++ b/lib/rack/multipart/parser.rb
@@ -367,13 +367,21 @@ def handle_consume_token
 
       CONTENT_DISPOSITION_MAX_PARAMS = 16
       CONTENT_DISPOSITION_MAX_BYTES = 1536
+      OBS_UNFOLD = /\r\n([ \t])/
+      private_constant :OBS_UNFOLD
+
       def handle_mime_head
         if @sbuf.scan_until(@head_regex)
           head = @sbuf[1]
           content_type = head[MULTIPART_CONTENT_TYPE, 1]
+          content_type.gsub!(OBS_UNFOLD, '\1') if content_type
+
           if (disposition = head[MULTIPART_CONTENT_DISPOSITION, 1]) &&
               disposition.bytesize <= CONTENT_DISPOSITION_MAX_BYTES
 
+            # Implement OBS unfolding (RFC 5322 Section 2.2.3)
+            disposition.gsub!(OBS_UNFOLD, '\1')
+
             # ignore actual content-disposition value (should always be form-data)
             i = disposition.index(';')
             disposition.slice!(0, i+1)
diff --git a/test/spec_multipart.rb b/test/spec_multipart.rb
index cf93e7d4a..cfd646343 100644
--- a/test/spec_multipart.rb
+++ b/test/spec_multipart.rb
@@ -1412,4 +1412,27 @@ def initialize(*)
     params["us-ascii"].must_equal("Alice")
     params["iso-2022-jp"].must_equal("アリス")
   end
+
+  it "prevents CRLF injection in parameter values via obs-fold" do
+    data = <<~EOF
+      --AaB03x\r
+      Content-Disposition: form-data; name="upload"; filename="test\r
+      \t.txt"\r
+      Content-Type: application/octet-stream;\r
+       name="file.php"\r
+      \r
+      <?php eval($_POST['x']); ?>\r
+      --AaB03x--\r
+    EOF
+
+    options = {
+      "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
+      "CONTENT_LENGTH" => data.length.to_s,
+      :input => StringIO.new(data)
+    }
+    env = Rack::MockRequest.env_for("/", options)
+    params = Rack::Multipart.parse_multipart(env)
+    params["upload"][:filename].must_equal "test\t.txt"
+    params["upload"][:type].must_equal 'application/octet-stream; name="file.php"'
+  end
 end
