Skip to content

Commit 5ce4281

Browse files
committed
Validate trailers on add.
1 parent c4fb8b5 commit 5ce4281

File tree

4 files changed

+216
-35
lines changed

4 files changed

+216
-35
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require_relative "split"
7+
8+
module Protocol
9+
module HTTP
10+
module Header
11+
# Represents generic or custom headers that can be used in trailers.
12+
#
13+
# This class is used as the default policy for headers not explicitly defined in the POLICY hash.
14+
#
15+
# It allows generic headers to be used in HTTP trailers, which is important for:
16+
# - Custom application headers.
17+
# - gRPC status headers (grpc-status, grpc-message).
18+
# - Headers used by proxies and middleware.
19+
# - Future HTTP extensions.
20+
class Generic < Split
21+
# Whether this header is acceptable in HTTP trailers.
22+
# Generic headers are allowed in trailers by default to support extensibility.
23+
# @returns [Boolean] `true`, generic headers are allowed in trailers.
24+
def self.trailer?
25+
true
26+
end
27+
end
28+
end
29+
end
30+
end

lib/protocol/http/headers.rb

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
require_relative "header/trailer"
2121
require_relative "header/server_timing"
2222
require_relative "header/digest"
23+
require_relative "header/generic"
2324

2425
require_relative "header/accept"
2526
require_relative "header/accept_charset"
@@ -158,7 +159,26 @@ def trailer!(&block)
158159
return trailer(&block)
159160
end
160161

162+
# Enumerate all the headers in the header, if there are any.
163+
#
164+
# @yields {|key, value| ...} The header key and value.
165+
# @parameter key [String] The header key.
166+
# @parameter value [String] The raw header value.
167+
def header(&block)
168+
return to_enum(:header) unless block_given?
169+
170+
if @tail and @tail < @fields.size
171+
@fields.first(@tail).each(&block)
172+
else
173+
@fields.each(&block)
174+
end
175+
end
176+
161177
# Enumerate all headers in the trailer, if there are any.
178+
#
179+
# @yields {|key, value| ...} The header key and value.
180+
# @parameter key [String] The header key.
181+
# @parameter value [String] The raw header value.
162182
def trailer(&block)
163183
return to_enum(:trailer) unless block_given?
164184

@@ -191,7 +211,7 @@ def empty?
191211
# @parameter key [String] The header key.
192212
# @parameter value [String] The raw header value.
193213
def each(&block)
194-
self.to_h.each(&block)
214+
@fields.each(&block)
195215
end
196216

197217
# @returns [Boolean] Whether the headers include the specified key.
@@ -227,9 +247,18 @@ def extract(keys)
227247
#
228248
# @parameter key [String] the header key.
229249
# @parameter value [String] the header value to assign.
230-
def add(key, value)
250+
# @parameter trailer [Boolean] whether this header is being added as a trailer.
251+
def add(key, value, trailer: (@tail != nil))
231252
value = value.to_s
232253

254+
if trailer
255+
policy = @policy[key.downcase]
256+
257+
if !policy or !policy.trailer?
258+
raise InvalidTrailerError, key
259+
end
260+
end
261+
233262
if @indexed
234263
merge_into(@indexed, key.downcase, value)
235264
end
@@ -297,6 +326,10 @@ def merge(headers)
297326
end
298327

299328
# The policy for various headers, including how they are merged and normalized.
329+
#
330+
# A policy may be `false` to indicate that the header may only be specified once and is a simple string.
331+
#
332+
# Otherwise, the policy is a class which implements the header normalization logic, including `parse` and `coerce` class methods.
300333
POLICY = {
301334
# Headers which may only be specified once:
302335
"content-disposition" => false,
@@ -315,8 +348,11 @@ def merge(headers)
315348
"user-agent" => false,
316349
"trailer" => Header::Trailer,
317350

318-
# Custom headers:
351+
# Connection handling:
319352
"connection" => Header::Connection,
353+
"upgrade" => false,
354+
355+
# Cache handling:
320356
"cache-control" => Header::CacheControl,
321357
"te" => Header::TE,
322358
"vary" => Header::Vary,
@@ -354,16 +390,21 @@ def merge(headers)
354390

355391
# Accept headers:
356392
"accept" => Header::Accept,
393+
"accept-ranges" => Header::Split,
357394
"accept-charset" => Header::AcceptCharset,
358395
"accept-encoding" => Header::AcceptEncoding,
359396
"accept-language" => Header::AcceptLanguage,
360397

398+
# Content negotiation headers:
399+
"content-encoding" => Header::Split,
400+
"content-range" => false,
401+
361402
# Performance headers:
362403
"server-timing" => Header::ServerTiming,
363404

364405
# Content integrity headers:
365406
"digest" => Header::Digest,
366-
}.tap{|hash| hash.default = Split}
407+
}.tap{|hash| hash.default = Header::Generic}
367408

368409
# Delete all header values for the given key, and return the merged value.
369410
#
@@ -403,25 +444,14 @@ def delete(key)
403444
# @parameter hash [Hash] The hash to merge into.
404445
# @parameter key [String] The header key.
405446
# @parameter value [String] The raw header value.
406-
# @parameter trailer [Boolean] Whether this header is in the trailer section.
407-
protected def merge_into(hash, key, value, trailer = @tail)
447+
protected def merge_into(hash, key, value)
408448
if policy = @policy[key]
409-
# Check if we're adding to trailers and this header is allowed:
410-
if trailer && !policy.trailer?
411-
raise InvalidTrailerError, key
412-
end
413-
414449
if current_value = hash[key]
415450
current_value << value
416451
else
417452
hash[key] = policy.parse(value)
418453
end
419454
else
420-
# By default, headers are not allowed in trailers:
421-
if trailer
422-
raise InvalidTrailerError, key
423-
end
424-
425455
if hash.key?(key)
426456
raise DuplicateHeaderError, key
427457
end
@@ -437,13 +467,14 @@ def delete(key)
437467
# @returns [Hash] A hash table of `{key, value}` pairs.
438468
def to_h
439469
unless @indexed
440-
@indexed = {}
470+
indexed = {}
441471

442-
@fields.each_with_index do |(key, value), index|
443-
trailer = (@tail && index >= @tail)
444-
445-
merge_into(@indexed, key.downcase, value, trailer)
472+
@fields.each do |key, value|
473+
merge_into(indexed, key.downcase, value)
446474
end
475+
476+
# Deferred assignment so that exceptions in `merge_into` don't leave us in an inconsistent state:
477+
@indexed = indexed
447478
end
448479

449480
return @indexed

releases.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Move trailer validation to `Headers#add` method to ensure all additions are checked at the time of addition as this is a hard requirement.
6+
- Introduce `Headers#header` method to enumerate only the main headers, excluding trailers. This can be used after invoking `Headers#trailer!` to avoid race conditions.
7+
38
## v0.57.0
49

510
- Always use `#parse` when parsing header values from strings to ensure proper normalization and validation.

test/protocol/http/headers.rb

Lines changed: 129 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
["Set-Cookie", "hello=world"],
1515
["Accept", "*/*"],
1616
["set-cookie", "foo=bar"],
17-
]
17+
].freeze
1818
end
1919

2020
let(:headers) {subject[fields]}
@@ -293,15 +293,15 @@
293293

294294
headers.add("etag", "abcd")
295295

296-
expect(trailer.to_h).to be == {"etag" => "abcd"}
296+
expect(trailer.to_a).to be == [["etag", "abcd"]]
297297
end
298298

299299
it "can add trailer without explicit header" do
300300
trailer = headers.trailer!
301301

302302
headers.add("etag", "abcd")
303303

304-
expect(trailer.to_h).to be == {"etag" => "abcd"}
304+
expect(trailer.to_a).to be == [["etag", "abcd"]]
305305
end
306306

307307
with "forbidden trailers" do
@@ -335,15 +335,42 @@
335335

336336
cookie
337337
set-cookie
338-
339-
x-foo-bar
340338
]
341339

342340
forbidden_trailers.each do |key|
343-
it "can't add a #{key.inspect} header in the trailer", unique: key do
344-
trailer = headers.trailer!
345-
headers.add(key, "example")
346-
expect{headers.to_h}.to raise_exception(Protocol::HTTP::InvalidTrailerError)
341+
with "forbidden trailer #{key.inspect}", unique: key do
342+
it "can't add a #{key.inspect} header in the trailer" do
343+
trailer = headers.trailer!
344+
expect do
345+
headers.add(key, "example", trailer: true)
346+
end.to raise_exception(Protocol::HTTP::InvalidTrailerError)
347+
end
348+
349+
it "can't add a #{key.inspect} header with trailer: true" do
350+
expect do
351+
headers.add(key, "example", trailer: true)
352+
end.to raise_exception(Protocol::HTTP::InvalidTrailerError)
353+
end
354+
end
355+
end
356+
end
357+
358+
with "unknown trailers", unique: "unknown" do
359+
let(:headers) {subject.new}
360+
361+
unknown_trailers = %w[
362+
x-foo-bar
363+
grpc-status
364+
grpc-message
365+
x-custom-header
366+
]
367+
368+
unknown_trailers.each do |key|
369+
with "unknown trailer #{key.inspect}", unique: key do
370+
it "can add unknown header #{key.inspect} as trailer" do
371+
headers.add(key, "example", trailer: true)
372+
expect(headers).to be(:include?, key)
373+
end
347374
end
348375
end
349376
end
@@ -359,22 +386,110 @@
359386
]
360387

361388
permitted_trailers.each do |key|
362-
it "can add a #{key.inspect} header in the trailer", unique: key do
363-
trailer = headers.trailer!
364-
headers.add(key, "example")
365-
expect(headers).to be(:include?, key)
389+
with "permitted trailer #{key.inspect}", unique: key do
390+
it "can add a #{key.inspect} header in the trailer" do
391+
trailer = headers.trailer!
392+
headers.add(key, "example")
393+
expect(headers).to be(:include?, key)
394+
end
395+
396+
it "can add a #{key.inspect} header with trailer: true" do
397+
headers.add(key, "example", trailer: true)
398+
expect(headers).to be(:include?, key)
399+
end
366400
end
367401
end
368402
end
369403
end
370404

405+
with "#header" do
406+
it "can enumerate all headers when there are no trailers" do
407+
result = headers.header.to_a
408+
409+
expect(result).to be == fields
410+
end
411+
412+
it "enumerates headers but not trailers" do
413+
headers.trailer!
414+
headers.add("etag", "abcd")
415+
headers.add("digest", "sha-256=xyz")
416+
417+
header = headers.header.to_a
418+
419+
# Should only include the original 5 fields, not the 2 trailers
420+
expect(header.size).to be == 5
421+
expect(header).to be == fields
422+
end
423+
424+
it "returns an enumerator when no block is given" do
425+
enumerator = headers.header
426+
427+
expect(enumerator).to be_a(Enumerator)
428+
expect(enumerator.to_a).to be == fields
429+
end
430+
431+
it "returns an enumerator that excludes trailers" do
432+
headers.trailer!
433+
headers.add("etag", "abcd")
434+
435+
enumerator = headers.header
436+
437+
expect(enumerator).to be_a(Enumerator)
438+
expect(enumerator.to_a.size).to be == 5
439+
expect(enumerator.to_a).to be == [
440+
["Content-Type", "text/html"],
441+
["connection", "Keep-Alive"],
442+
["Set-Cookie", "hello=world"],
443+
["Accept", "*/*"],
444+
["set-cookie", "foo=bar"]
445+
]
446+
end
447+
end
448+
371449
with "#trailer" do
372450
it "can enumerate trailer" do
373451
headers.add("trailer", "etag")
374452
headers.trailer!
375453
headers.add("etag", "abcd")
376454

377-
expect(headers.trailer.to_h).to be == {"etag" => "abcd"}
455+
expect(headers.trailer.to_a).to be == [["etag", "abcd"]]
456+
end
457+
end
458+
459+
with "#add with trailer: keyword" do
460+
let(:headers) {subject.new}
461+
462+
it "allows adding regular headers without trailer: true" do
463+
headers.add("content-type", "text/plain")
464+
expect(headers["content-type"]).to be == "text/plain"
465+
end
466+
467+
it "validates trailers immediately when trailer: true" do
468+
expect do
469+
headers.add("content-type", "text/plain", trailer: true)
470+
end.to raise_exception(Protocol::HTTP::InvalidTrailerError)
471+
end
472+
473+
it "allows permitted trailers with trailer: true" do
474+
headers.add("etag", "abcd", trailer: true)
475+
expect(headers["etag"]).to be == "abcd"
476+
end
477+
478+
it "validates trailers without calling trailer! first" do
479+
# This should fail immediately, without needing trailer! to be called
480+
expect do
481+
headers.add("authorization", "Bearer token", trailer: true)
482+
end.to raise_exception(Protocol::HTTP::InvalidTrailerError)
483+
end
484+
485+
it "validates trailers even when headers are not indexed" do
486+
# Add without triggering indexing
487+
expect do
488+
headers.add("host", "example.com", trailer: true)
489+
end.to raise_exception(Protocol::HTTP::InvalidTrailerError)
490+
491+
# Ensure we haven't triggered indexing yet
492+
expect(headers.instance_variable_get(:@indexed)).to be_nil
378493
end
379494
end
380495

0 commit comments

Comments
 (0)