From ea8e9b8acdc5852a4bbc2af2eb6b2905060b07e7 Mon Sep 17 00:00:00 2001 From: Robin Schroer Date: Thu, 23 Apr 2026 15:03:08 +0900 Subject: [PATCH 1/5] Set multiple cookies in multiple `set-cookie` headers This fixes a bug we've hit with Falcon & Rails, where multiple cookies set on a single response would return a single `set-cookie` header with the values from several cookies joined by `;`, which browsers do not accept, and is not compliant with the spec in RFC 6265. This required decoupling the `Cookie` and `SetCookie` implementations, so we can splat `Multiple` into `@fields`. This also introduces spaces after semicolons into the output of `cookie#to_s`, which is also required by the spec. --- lib/protocol/http/header/cookie.rb | 69 +++++++++++++++++++++++++---- lib/protocol/http/headers.rb | 10 ++++- test/protocol/http/header/cookie.rb | 4 +- test/protocol/http/headers.rb | 2 +- 4 files changed, 71 insertions(+), 14 deletions(-) diff --git a/lib/protocol/http/header/cookie.rb b/lib/protocol/http/header/cookie.rb index f86c97f..427f701 100644 --- a/lib/protocol/http/header/cookie.rb +++ b/lib/protocol/http/header/cookie.rb @@ -12,7 +12,40 @@ module Header # The `cookie` header contains stored HTTP cookies previously sent by the server with the `set-cookie` header. # # It is used by clients to send key-value pairs representing stored cookies back to the server. - class Cookie < Multiple + # Multiple cookies within a single `Cookie` header are joined with `"; "` per RFC 6265. + class Cookie < Array + # Parses a raw header value. + # + # @parameter value [String] a single raw header value. + # @returns [Cookie] a new instance containing the parsed value. + def self.parse(value) + self.new([value]) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String | Array] the value to coerce. + # @returns [Cookie] a parsed header object. + def self.coerce(value) + case value + when Array + self.new(value.map(&:to_s)) + else + self.parse(value.to_s) + end + end + + # Initializes the cookie header with the given values. + # + # @parameter value [Array | Nil] an array of cookie strings, or `nil` for an empty header. + def initialize(value = nil) + super() + + if value + self.concat(value) + end + end + # Parses the `cookie` header into a hash of cookie names and their corresponding cookie objects. # # @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects. @@ -20,15 +53,15 @@ def to_h cookies = self.collect do |string| HTTP::Cookie.parse(string) end - + cookies.map{|cookie| [cookie.name, cookie]}.to_h end - - # Serializes the `cookie` header by joining individual cookie strings with semicolons. + + # Serializes the `cookie` header by joining individual cookie strings with `"; "` per RFC 6265. def to_s - join(";") + join("; ") end - + # Whether this header is acceptable in HTTP trailers. # Cookie headers should not appear in trailers as they contain state information needed early in processing. # @returns [Boolean] `false`, as cookie headers are needed during initial request processing. @@ -36,11 +69,29 @@ def self.trailer? false end end - + # The `set-cookie` header sends cookies from the server to the user agent. # - # It is used to store cookies on the client side, which are then sent back to the server in subsequent requests using the `cookie` header. - class SetCookie < Cookie + # Each `Set-Cookie` header must be a separate header field — they cannot be combined. + # It is used to store cookies on the client side, which are then sent back to the server + # in subsequent requests using the `cookie` header. + class SetCookie < Multiple + # Parses the `set-cookie` headers into a hash of cookie names and their corresponding cookie objects. + # + # @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects. + def to_h + cookies = self.collect do |string| + HTTP::Cookie.parse(string) + end + + cookies.map{|cookie| [cookie.name, cookie]}.to_h + end + + # Whether this header is acceptable in HTTP trailers. + # @returns [Boolean] `false`, as set-cookie headers are needed during initial response processing. + def self.trailer? + false + end end end end diff --git a/lib/protocol/http/headers.rb b/lib/protocol/http/headers.rb index 76dcb5a..bc00ac9 100644 --- a/lib/protocol/http/headers.rb +++ b/lib/protocol/http/headers.rb @@ -299,8 +299,14 @@ def []=(key, value) if @indexed @indexed[key] = value end - - @fields << [key, value.to_s] + + if value.is_a?(Multiple) + value.each do |v| + @fields << [key, v.to_s] + end + else + @fields << [key, value.to_s] + end end # Get the value of the specified header key. diff --git a/test/protocol/http/header/cookie.rb b/test/protocol/http/header/cookie.rb index 7b108dc..a866ffa 100644 --- a/test/protocol/http/header/cookie.rb +++ b/test/protocol/http/header/cookie.rb @@ -56,8 +56,8 @@ cookie end - it "joins cookies with semicolons without spaces" do - expect(header.to_s).to be == "session=abc123;user_id=42;token=xyz789" + it "joins cookies with semicolons and spaces per RFC 6265" do + expect(header.to_s).to be == "session=abc123; user_id=42; token=xyz789" end end end diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index e5bb9b2..69de97b 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -600,7 +600,7 @@ def self.trailer? with "set-cookie" do it "can extract parsed cookies" do - expect(headers["set-cookie"]).to be_a(Protocol::HTTP::Header::Cookie) + expect(headers["set-cookie"]).to be_a(Protocol::HTTP::Header::SetCookie) end end From 62ac12372ceb532ef6143ce5f748b2d536d425b7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 28 Jul 2026 17:11:39 +1200 Subject: [PATCH 2/5] Add regression tests for multiple Set-Cookie fields Assisted-By: devx/d2b0744c-2a13-4062-bbb7-b404acb1c0f5 --- test/protocol/http/headers.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index 69de97b..38cf21e 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -212,6 +212,27 @@ expect(headers["accept-encoding"]).to be(:include?, "deflate") end + it "adds multiple set-cookie values as separate fields" do + headers["set-cookie"] = ["session=abc123; Path=/", "theme=dark; HttpOnly"] + + expect(headers.fields.select{|key, value| key == "set-cookie"}).to be == [ + ["set-cookie", "session=abc123; Path=/"], + ["set-cookie", "theme=dark; HttpOnly"], + ] + expect(headers["set-cookie"]).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"] + end + + it "adds multiple set-cookie values after indexing headers" do + headers.to_h + headers["set-cookie"] = ["session=abc123; Path=/", "theme=dark; HttpOnly"] + + expect(headers.fields.select{|key, value| key == "set-cookie"}).to be == [ + ["set-cookie", "session=abc123; Path=/"], + ["set-cookie", "theme=dark; HttpOnly"], + ] + expect(headers["set-cookie"]).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"] + end + it "can add field with indexed hash" do expect(headers.to_h).not.to be(:empty?) From 5e18de3f1c3ab204bac3c5e2fd1f3869c776dd9d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 28 Jul 2026 17:13:25 +1200 Subject: [PATCH 3/5] Add release note for multiple Set-Cookie fields Assisted-By: devx/d2b0744c-2a13-4062-bbb7-b404acb1c0f5 --- releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/releases.md b/releases.md index d982394..3180bbe 100644 --- a/releases.md +++ b/releases.md @@ -3,6 +3,7 @@ ## Unreleased - Improve `Accept` header parsing for quoted pairs, malformed parameters, invalid wildcards, and invalid quality factors. + - Emit multiple `Set-Cookie` values as separate header fields and delimit combined `Cookie` values with a space after each semicolon. ## v0.64.0 From 50fedbf76ea3eef1f3d3237583b90d26e6478420 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 28 Jul 2026 17:19:42 +1200 Subject: [PATCH 4/5] Separate SetCookie header policy Assisted-By: devx/d2b0744c-2a13-4062-bbb7-b404acb1c0f5 --- lib/protocol/http/header/cookie.rb | 42 ++++++------------------- lib/protocol/http/header/set_cookie.rb | 37 ++++++++++++++++++++++ lib/protocol/http/headers.rb | 3 +- test/protocol/http/header/set_cookie.rb | 24 ++++++++++++++ test/protocol/http/headers.rb | 8 +++++ 5 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 lib/protocol/http/header/set_cookie.rb create mode 100644 test/protocol/http/header/set_cookie.rb diff --git a/lib/protocol/http/header/cookie.rb b/lib/protocol/http/header/cookie.rb index 427f701..adc441a 100644 --- a/lib/protocol/http/header/cookie.rb +++ b/lib/protocol/http/header/cookie.rb @@ -3,7 +3,6 @@ # Released under the MIT License. # Copyright, 2019-2026, by Samuel Williams. -require_relative "multiple" require_relative "../cookie" module Protocol @@ -21,7 +20,7 @@ class Cookie < Array def self.parse(value) self.new([value]) end - + # Coerces a value into a parsed header object. # # @parameter value [String | Array] the value to coerce. @@ -34,18 +33,18 @@ def self.coerce(value) self.parse(value.to_s) end end - + # Initializes the cookie header with the given values. # # @parameter value [Array | Nil] an array of cookie strings, or `nil` for an empty header. def initialize(value = nil) super() - + if value self.concat(value) end end - + # Parses the `cookie` header into a hash of cookie names and their corresponding cookie objects. # # @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects. @@ -53,15 +52,15 @@ def to_h cookies = self.collect do |string| HTTP::Cookie.parse(string) end - + cookies.map{|cookie| [cookie.name, cookie]}.to_h end - + # Serializes the `cookie` header by joining individual cookie strings with `"; "` per RFC 6265. def to_s join("; ") end - + # Whether this header is acceptable in HTTP trailers. # Cookie headers should not appear in trailers as they contain state information needed early in processing. # @returns [Boolean] `false`, as cookie headers are needed during initial request processing. @@ -69,30 +68,9 @@ def self.trailer? false end end - - # The `set-cookie` header sends cookies from the server to the user agent. - # - # Each `Set-Cookie` header must be a separate header field — they cannot be combined. - # It is used to store cookies on the client side, which are then sent back to the server - # in subsequent requests using the `cookie` header. - class SetCookie < Multiple - # Parses the `set-cookie` headers into a hash of cookie names and their corresponding cookie objects. - # - # @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects. - def to_h - cookies = self.collect do |string| - HTTP::Cookie.parse(string) - end - - cookies.map{|cookie| [cookie.name, cookie]}.to_h - end - - # Whether this header is acceptable in HTTP trailers. - # @returns [Boolean] `false`, as set-cookie headers are needed during initial response processing. - def self.trailer? - false - end - end end end end + +# Preserve the existing behavior where requiring this file also defines SetCookie. +require_relative "set_cookie" diff --git a/lib/protocol/http/header/set_cookie.rb b/lib/protocol/http/header/set_cookie.rb new file mode 100644 index 0000000..dd29292 --- /dev/null +++ b/lib/protocol/http/header/set_cookie.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2026, by Samuel Williams. + +require_relative "multiple" +require_relative "../cookie" + +module Protocol + module HTTP + module Header + # The `set-cookie` header sends cookies from the server to the user agent. + # + # Each `Set-Cookie` header must be a separate header field — they cannot be combined. + # It is used to store cookies on the client side, which are then sent back to the server + # in subsequent requests using the `cookie` header. + class SetCookie < Multiple + # Parses the `set-cookie` headers into a hash of cookie names and their corresponding cookie objects. + # + # @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects. + def to_h + cookies = self.collect do |string| + HTTP::Cookie.parse(string) + end + + cookies.map{|cookie| [cookie.name, cookie]}.to_h + end + + # Whether this header is acceptable in HTTP trailers. + # @returns [Boolean] `false`, as set-cookie headers are needed during initial response processing. + def self.trailer? + false + end + end + end + end +end diff --git a/lib/protocol/http/headers.rb b/lib/protocol/http/headers.rb index bc00ac9..b3d634a 100644 --- a/lib/protocol/http/headers.rb +++ b/lib/protocol/http/headers.rb @@ -9,6 +9,7 @@ require_relative "header/multiple" require_relative "header/cookie" +require_relative "header/set_cookie" require_relative "header/connection" require_relative "header/cache_control" require_relative "header/etag" @@ -299,7 +300,7 @@ def []=(key, value) if @indexed @indexed[key] = value end - + if value.is_a?(Multiple) value.each do |v| @fields << [key, v.to_s] diff --git a/test/protocol/http/header/set_cookie.rb b/test/protocol/http/header/set_cookie.rb new file mode 100644 index 0000000..103b596 --- /dev/null +++ b/test/protocol/http/header/set_cookie.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/header/set_cookie" + +describe Protocol::HTTP::Header::SetCookie do + let(:header) do + subject.coerce([ + "session=abc123; Path=/", + "theme=dark; HttpOnly", + ]) + end + + it "represents values which must be emitted as separate fields" do + expect(header).to be_a(Protocol::HTTP::Header::Multiple) + expect(header).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"] + end + + it "can extract parsed cookies" do + expect(header.to_h).to have_keys("session", "theme") + end +end diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index 38cf21e..7a35956 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -212,6 +212,14 @@ expect(headers["accept-encoding"]).to be(:include?, "deflate") end + it "joins multiple cookie values into one field" do + headers["cookie"] = ["session=abc123", "theme=dark"] + + expect(headers.fields.select{|key, value| key == "cookie"}).to be == [ + ["cookie", "session=abc123; theme=dark"], + ] + end + it "adds multiple set-cookie values as separate fields" do headers["set-cookie"] = ["session=abc123; Path=/", "theme=dark; HttpOnly"] From 87f94547521d6c0eef7d849b2313e18f5628c4b2 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 28 Jul 2026 19:47:05 +1200 Subject: [PATCH 5/5] Test scalar Cookie coercion Assisted-By: devx/d2b0744c-2a13-4062-bbb7-b404acb1c0f5 --- test/protocol/http/header/cookie.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/protocol/http/header/cookie.rb b/test/protocol/http/header/cookie.rb index a866ffa..d27a3ed 100644 --- a/test/protocol/http/header/cookie.rb +++ b/test/protocol/http/header/cookie.rb @@ -10,6 +10,12 @@ let(:header) {subject.parse(description)} let(:cookies) {header.to_h} + it "can coerce a single value" do + header = subject.coerce("session=abc123") + + expect(header).to be == ["session=abc123"] + end + with "session=123; secure" do it "can parse cookies" do expect(cookies).to have_keys("session")