diff --git a/lib/protocol/http/error.rb b/lib/protocol/http/error.rb index c044b4f..1ad7ab5 100644 --- a/lib/protocol/http/error.rb +++ b/lib/protocol/http/error.rb @@ -8,5 +8,23 @@ module HTTP # A generic, HTTP protocol error. class Error < StandardError end + + # Represents a bad request error (as opposed to a server error). + # This is used to indicate that the request was malformed or invalid. + module BadRequest + end + + # Raised when a singleton (e.g. `content-length`) header is duplicated in a request or response. + class DuplicateHeaderError < Error + include BadRequest + + # @parameter key [String] The header key that was duplicated. + def initialize(key) + super("Duplicate singleton header key: #{key.inspect}") + end + + # @attribute [String] key The header key that was duplicated. + attr :key + end end end diff --git a/lib/protocol/http/headers.rb b/lib/protocol/http/headers.rb index 3eae025..4bce8b9 100644 --- a/lib/protocol/http/headers.rb +++ b/lib/protocol/http/headers.rb @@ -3,6 +3,8 @@ # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. +require_relative "error" + require_relative "header/split" require_relative "header/multiple" @@ -238,16 +240,16 @@ def []= key, value # The policy for various headers, including how they are merged and normalized. POLICY = { # Headers which may only be specified once: - "content-type" => false, "content-disposition" => false, "content-length" => false, - "user-agent" => false, - "referer" => false, - "host" => false, + "content-type" => false, "from" => false, + "host" => false, "location" => false, "max-forwards" => false, + "referer" => false, "retry-after" => false, + "user-agent" => false, # Custom headers: "connection" => Header::Connection, @@ -267,6 +269,7 @@ def []= key, value "etag" => Header::ETag, "if-match" => Header::ETags, "if-none-match" => Header::ETags, + "if-range" => false, # Headers which may be specified multiple times, but which can't be concatenated: "www-authenticate" => Multiple, @@ -332,7 +335,10 @@ def delete(key) hash[key] = policy.new(value) end else - # We can't merge these, we only expose the last one set. + if hash.key?(key) + raise DuplicateHeaderError, key + end + hash[key] = value end end diff --git a/releases.md b/releases.md index ca24dec..4114ea3 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - `Protocol::HTTP::Headers` now raise a `DuplicateHeaderError` when a duplicate singleton header (e.g. `content-length`) is added. + ## v0.50.0 - Drop support for Ruby v3.1. diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index 20b6b0b..2b2809b 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -42,9 +42,6 @@ with "#merge" do it "should merge headers" do other = subject[[ - # This will replace the original one: - ["Content-Type", "text/plain"], - # This will be appended: ["Set-Cookie", "goodbye=world"], ]] @@ -52,12 +49,26 @@ merged = headers.merge(other) expect(merged.to_h).to be == { - "content-type" => "text/plain", + "content-type" => "text/html", "set-cookie" => ["hello=world", "foo=bar", "goodbye=world"], "accept" => ["*/*"], "connection" => ["keep-alive"] } end + + it "can't merge singleton headers" do + other = subject[[ + ["content-type", "text/plain"], + ]] + + # This doesn't fail as we haven't built an internal index yet: + merged = headers.merge(other) + + expect do + # Once we build the index, it will fail: + merged.to_h + end.to raise_exception(Protocol::HTTP::DuplicateHeaderError) + end end with "#extract" do