Skip to content
94 changes: 94 additions & 0 deletions guides/headers/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Headers

This guide explains how to work with HTTP headers using `protocol-http`.

## Core Concepts

`protocol-http` provides several core concepts for working with HTTP headers:

- A {ruby Protocol::HTTP::Headers} class which represents a collection of HTTP headers with built-in security and policy features.
- Header-specific classes like {ruby Protocol::HTTP::Header::Accept} and {ruby Protocol::HTTP::Header::Authorization} which provide specialized parsing and formatting.
- Trailer security validation to prevent HTTP request smuggling attacks.

## Usage

The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers:

```ruby
require 'protocol/http'

headers = Protocol::HTTP::Headers.new
headers.add('content-type', 'text/html')
headers.add('set-cookie', 'session=abc123')

# Access headers
content_type = headers['content-type'] # => "text/html"

# Check if header exists
headers.include?('content-type') # => true
```

### Header Policies

Different header types have different behaviors for merging, validation, and trailer handling:

```ruby
# Some headers can be specified multiple times
headers.add('set-cookie', 'first=value1')
headers.add('set-cookie', 'second=value2')

# Others are singletons and will raise errors if duplicated
headers.add('content-length', '100')
# headers.add('content-length', '200') # Would raise DuplicateHeaderError
```

### Structured Headers

Some headers have specialized classes for parsing and formatting:

```ruby
# Accept header with media ranges
accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9')
media_ranges = accept.media_ranges

# Authorization header
auth = Protocol::HTTP::Header::Authorization.basic('username', 'password')
# => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
```

### Trailer Security

HTTP trailers are headers that appear after the message body. For security reasons, only certain headers are allowed in trailers:

```ruby
# Working with trailers
headers = Protocol::HTTP::Headers.new([
['content-type', 'text/html'],
['content-length', '1000']
])

# Start trailer section
headers.trailer!

# These will be allowed (safe metadata)
headers.add('etag', '"12345"')
headers.add('date', Time.now.httpdate)

# These will be silently ignored for security
headers.add('authorization', 'Bearer token') # Ignored - credential leakage risk
headers.add('connection', 'close') # Ignored - hop-by-hop header
```

The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers:

**Allowed headers** (return `true` for `trailer?`):
- `date` - Response generation timestamps.
- `digest` - Content integrity verification.
- `etag` - Cache validation tags.
- `server-timing` - Performance metrics.

**Forbidden headers** (return `false` for `trailer?`):
- `authorization` - Prevents credential leakage.
- `connection`, `te`, `transfer-encoding` - Hop-by-hop headers that control connection behavior.
- `cookie`, `set-cookie` - State information needed during initial processing.
- `accept` - Content negotiation must occur before response generation.
10 changes: 6 additions & 4 deletions guides/links.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ getting-started:
order: 1
message-body:
order: 2
middleware:
headers:
order: 3
hypertext-references:
middleware:
order: 4
url-parsing:
hypertext-references:
order: 5
streaming:
url-parsing:
order: 6
streaming:
order: 7
design-overview:
order: 10
6 changes: 6 additions & 0 deletions lib/protocol/http/header/accept.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ def to_s
join(",")
end

# Whether this header is acceptable in HTTP trailers.
# @returns [Boolean] `false`, as Accept headers are used for response content negotiation.
def self.trailer?
false
end

# Parse the `accept` header.
#
# @returns [Array(Charset)] the list of content types and their associated parameters.
Expand Down
6 changes: 6 additions & 0 deletions lib/protocol/http/header/authorization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ def self.basic(username, password)
"Basic #{strict_base64_encoded}"
)
end

# Whether this header is acceptable in HTTP trailers.
# @returns [Boolean] `false`, as authorization headers are used for request authentication.
def self.trailer?
false
end
end
end
end
Expand Down
7 changes: 7 additions & 0 deletions lib/protocol/http/header/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ def close?
def upgrade?
self.include?(UPGRADE)
end

# Whether this header is acceptable in HTTP trailers.
# Connection headers control the current connection and must not appear in trailers.
# @returns [Boolean] `false`, as connection headers are hop-by-hop and forbidden in trailers.
def self.trailer?
false
end
end
end
end
Expand Down
7 changes: 7 additions & 0 deletions lib/protocol/http/header/cookie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ def to_h

cookies.map{|cookie| [cookie.name, cookie]}.to_h
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.
def self.trailer?
false
end
end

# The `set-cookie` header sends cookies from the server to the user agent.
Expand Down
7 changes: 7 additions & 0 deletions lib/protocol/http/header/date.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ def << value
def to_time
::Time.parse(self)
end

# Whether this header is acceptable in HTTP trailers.
# Date headers can safely appear in trailers as they provide metadata about response generation.
# @returns [Boolean] `true`, as date headers are metadata that can be computed after response generation.
def self.trailer?
true
end
end
end
end
Expand Down
70 changes: 70 additions & 0 deletions lib/protocol/http/header/digest.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2025, by Samuel Williams.

require_relative "split"
require_relative "quoted_string"
require_relative "../error"

module Protocol
module HTTP
module Header
# The `digest` header provides a digest of the message body for integrity verification.
#
# This header allows servers to send cryptographic hashes of the response body, enabling clients to verify data integrity. Multiple digest algorithms can be specified, and the header is particularly useful as a trailer since the digest can only be computed after the entire message body is available.
#
# ## Examples
#
# ```ruby
# digest = Digest.new("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=")
# digest << "md5=9bb58f26192e4ba00f01e2e7b136bbd8"
# puts digest.to_s
# # => "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8"
# ```
class Digest < Split
ParseError = Class.new(Error)

# https://tools.ietf.org/html/rfc3230#section-4.3.2
ENTRY = /\A(?<algorithm>[a-zA-Z0-9][a-zA-Z0-9\-]*)\s*=\s*(?<value>.*)\z/

# A single digest entry in the Digest header.
Entry = Struct.new(:algorithm, :value) do
# Create a new digest entry.
#
# @parameter algorithm [String] the digest algorithm (e.g., "sha-256", "md5").
# @parameter value [String] the base64-encoded or hex-encoded digest value.
def initialize(algorithm, value)
super(algorithm.downcase, value)
end

# Convert the entry to its string representation.
#
# @returns [String] the formatted digest string.
def to_s
"#{algorithm}=#{value}"
end
end

# Parse the `digest` header value into a list of digest entries.
#
# @returns [Array(Entry)] the list of digest entries with their algorithms and values.
def entries
self.map do |value|
if match = value.match(ENTRY)
Entry.new(match[:algorithm], match[:value])
else
raise ParseError.new("Could not parse digest value: #{value.inspect}")
end
end
end

# Whether this header is acceptable in HTTP trailers.
# @returns [Boolean] `true`, as digest headers contain integrity hashes that can only be calculated after the entire message body is available.
def self.trailer?
true
end
end
end
end
end
7 changes: 7 additions & 0 deletions lib/protocol/http/header/etag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ def << value
def weak?
self.start_with?("W/")
end

# Whether this header is acceptable in HTTP trailers.
# ETag headers can safely appear in trailers as they provide cache validation metadata.
# @returns [Boolean] `true`, as ETag headers are metadata that can be computed after response generation.
def self.trailer?
true
end
end
end
end
Expand Down
7 changes: 7 additions & 0 deletions lib/protocol/http/header/multiple.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ def initialize(value)
def to_s
join("\n")
end

# Whether this header is acceptable in HTTP trailers.
# This is a base class for headers with multiple values, default is to disallow in trailers.
# @returns [Boolean] `false`, as most multiple-value headers should not appear in trailers by default.
def self.trailer?
false
end
end
end
end
Expand Down
92 changes: 92 additions & 0 deletions lib/protocol/http/header/server_timing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2025, by Samuel Williams.

require_relative "split"
require_relative "quoted_string"
require_relative "../error"

module Protocol
module HTTP
module Header
# The `server-timing` header communicates performance metrics about the request-response cycle to the client.
#
# This header allows servers to send timing information about various server-side operations, which can be useful for performance monitoring and debugging. Each metric can include a name, optional duration, and optional description.
#
# ## Examples
#
# ```ruby
# server_timing = ServerTiming.new("db;dur=53.2")
# server_timing << "cache;dur=12.1;desc=\"Redis lookup\""
# puts server_timing.to_s
# # => "db;dur=53.2, cache;dur=12.1;desc=\"Redis lookup\""
# ```
class ServerTiming < Split
ParseError = Class.new(Error)

# https://www.w3.org/TR/server-timing/
METRIC = /\A(?<name>[a-zA-Z0-9][a-zA-Z0-9_\-]*)(;(?<parameters>.*))?\z/
PARAMETER = /(?<key>dur|desc)=((?<value>#{TOKEN})|(?<quoted_value>#{QUOTED_STRING}))/

# A single metric in the Server-Timing header.
Metric = Struct.new(:name, :duration, :description) do
# Create a new server timing metric.
#
# @parameter name [String] the name of the metric.
# @parameter duration [Float | Nil] the duration in milliseconds.
# @parameter description [String | Nil] the description of the metric.
def initialize(name, duration = nil, description = nil)
super(name, duration, description)
end

# Convert the metric to its string representation.
#
# @returns [String] the formatted metric string.
def to_s
result = name.dup
result << ";dur=#{duration}" if duration
result << ";desc=\"#{description}\"" if description
result
end
end

# Parse the `server-timing` header value into a list of metrics.
#
# @returns [Array(Metric)] the list of metrics with their names, durations, and descriptions.
def metrics
self.map do |value|
if match = value.match(METRIC)
name = match[:name]
parameters = match[:parameters] || ""

duration = nil
description = nil

parameters.scan(PARAMETER) do |key, value, quoted_value|
value = QuotedString.unquote(quoted_value) if quoted_value

case key
when "dur"
duration = value.to_f
when "desc"
description = value
end
end

Metric.new(name, duration, description)
else
raise ParseError.new("Could not parse server timing metric: #{value.inspect}")
end
end
end

# Whether this header is acceptable in HTTP trailers.
# @returns [Boolean] `true`, as server-timing headers contain performance metrics that are typically calculated during response generation.
def self.trailer?
true
end
end
end
end
end
7 changes: 7 additions & 0 deletions lib/protocol/http/header/split.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ def to_s
join(",")
end

# Whether this header is acceptable in HTTP trailers.
# This is a base class for comma-separated headers, default is to disallow in trailers.
# @returns [Boolean] `false`, as most comma-separated headers should not appear in trailers by default.
def self.trailer?
false
end

protected

def reverse_find(&block)
Expand Down
Loading
Loading