diff --git a/guides/headers/readme.md b/guides/headers/readme.md new file mode 100644 index 0000000..518c204 --- /dev/null +++ b/guides/headers/readme.md @@ -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. diff --git a/guides/links.yaml b/guides/links.yaml index 5bf3ae4..221cf40 100644 --- a/guides/links.yaml +++ b/guides/links.yaml @@ -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 diff --git a/lib/protocol/http/header/accept.rb b/lib/protocol/http/header/accept.rb index fa14074..d2a40da 100644 --- a/lib/protocol/http/header/accept.rb +++ b/lib/protocol/http/header/accept.rb @@ -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. diff --git a/lib/protocol/http/header/authorization.rb b/lib/protocol/http/header/authorization.rb index 37c07be..edc3ac5 100644 --- a/lib/protocol/http/header/authorization.rb +++ b/lib/protocol/http/header/authorization.rb @@ -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 diff --git a/lib/protocol/http/header/connection.rb b/lib/protocol/http/header/connection.rb index 0b5c9f5..1c8a65d 100644 --- a/lib/protocol/http/header/connection.rb +++ b/lib/protocol/http/header/connection.rb @@ -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 diff --git a/lib/protocol/http/header/cookie.rb b/lib/protocol/http/header/cookie.rb index 547047a..c23b530 100644 --- a/lib/protocol/http/header/cookie.rb +++ b/lib/protocol/http/header/cookie.rb @@ -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. diff --git a/lib/protocol/http/header/date.rb b/lib/protocol/http/header/date.rb index 86a751f..02f8034 100644 --- a/lib/protocol/http/header/date.rb +++ b/lib/protocol/http/header/date.rb @@ -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 diff --git a/lib/protocol/http/header/digest.rb b/lib/protocol/http/header/digest.rb new file mode 100644 index 0000000..74370bd --- /dev/null +++ b/lib/protocol/http/header/digest.rb @@ -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(?[a-zA-Z0-9][a-zA-Z0-9\-]*)\s*=\s*(?.*)\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 diff --git a/lib/protocol/http/header/etag.rb b/lib/protocol/http/header/etag.rb index 8c72e46..c4f86f9 100644 --- a/lib/protocol/http/header/etag.rb +++ b/lib/protocol/http/header/etag.rb @@ -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 diff --git a/lib/protocol/http/header/multiple.rb b/lib/protocol/http/header/multiple.rb index fe731f8..77140de 100644 --- a/lib/protocol/http/header/multiple.rb +++ b/lib/protocol/http/header/multiple.rb @@ -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 diff --git a/lib/protocol/http/header/server_timing.rb b/lib/protocol/http/header/server_timing.rb new file mode 100644 index 0000000..5ddf80b --- /dev/null +++ b/lib/protocol/http/header/server_timing.rb @@ -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(?[a-zA-Z0-9][a-zA-Z0-9_\-]*)(;(?.*))?\z/ + PARAMETER = /(?dur|desc)=((?#{TOKEN})|(?#{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 diff --git a/lib/protocol/http/header/split.rb b/lib/protocol/http/header/split.rb index 4ecb1f1..2301b92 100644 --- a/lib/protocol/http/header/split.rb +++ b/lib/protocol/http/header/split.rb @@ -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) diff --git a/lib/protocol/http/header/te.rb b/lib/protocol/http/header/te.rb new file mode 100644 index 0000000..9a3e241 --- /dev/null +++ b/lib/protocol/http/header/te.rb @@ -0,0 +1,131 @@ +# 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 `te` header indicates the transfer encodings the client is willing to accept. AKA `accept-transfer-encoding`. How we ended up with `te` instead of `accept-transfer-encoding` is a mystery lost to time. + # + # The `te` header allows a client to indicate which transfer encodings it can handle, and in what order of preference using quality factors. + class TE < Split + ParseError = Class.new(Error) + + # Transfer encoding token pattern + TOKEN = /[!#$%&'*+\-.0-9A-Z^_`a-z|~]+/ + + # Quality value pattern (0.0 to 1.0) + QVALUE = /0(\.[0-9]{0,3})?|1(\.[0]{0,3})?/ + + # Pattern for parsing transfer encoding with optional quality factor + TRANSFER_CODING = /\A(?#{TOKEN})(\s*;\s*q=(?#{QVALUE}))?\z/ + + # The `chunked` transfer encoding + CHUNKED = "chunked" + + # The `gzip` transfer encoding + GZIP = "gzip" + + # The `deflate` transfer encoding + DEFLATE = "deflate" + + # The `compress` transfer encoding + COMPRESS = "compress" + + # The `identity` transfer encoding + IDENTITY = "identity" + + # The `trailers` pseudo-encoding indicates willingness to accept trailer fields + TRAILERS = "trailers" + + # A single transfer coding entry with optional quality factor + TransferCoding = Struct.new(:name, :q) do + def quality_factor + (q || 1.0).to_f + end + + def <=> other + other.quality_factor <=> self.quality_factor + end + + def to_s + if q && q != 1.0 + "#{name};q=#{q}" + else + name.to_s + end + end + end + + # Initializes the TE header with the given value. The value is split into distinct entries and converted to lowercase for normalization. + # + # @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas. + def initialize(value = nil) + super(value&.downcase) + end + + # Adds one or more comma-separated values to the TE header. The values are converted to lowercase for normalization. + # + # @parameter value [String] the value or values to add, separated by commas. + def << value + super(value.downcase) + end + + # Parse the `te` header value into a list of transfer codings with quality factors. + # + # @returns [Array(TransferCoding)] the list of transfer codings and their associated quality factors. + def transfer_codings + self.map do |value| + if match = value.match(TRANSFER_CODING) + TransferCoding.new(match[:name], match[:q]) + else + raise ParseError.new("Could not parse transfer coding: #{value.inspect}") + end + end + end + + # @returns [Boolean] whether the `chunked` encoding is accepted. + def chunked? + self.any? {|value| value.start_with?(CHUNKED)} + end + + # @returns [Boolean] whether the `gzip` encoding is accepted. + def gzip? + self.any? {|value| value.start_with?(GZIP)} + end + + # @returns [Boolean] whether the `deflate` encoding is accepted. + def deflate? + self.any? {|value| value.start_with?(DEFLATE)} + end + + # @returns [Boolean] whether the `compress` encoding is accepted. + def compress? + self.any? {|value| value.start_with?(COMPRESS)} + end + + # @returns [Boolean] whether the `identity` encoding is accepted. + def identity? + self.any? {|value| value.start_with?(IDENTITY)} + end + + # @returns [Boolean] whether trailers are accepted. + def trailers? + self.any? {|value| value.start_with?(TRAILERS)} + end + + # Whether this header is acceptable in HTTP trailers. + # TE headers negotiate transfer encodings and must not appear in trailers. + # @returns [Boolean] `false`, as TE headers are hop-by-hop and control message framing. + def self.trailer? + false + end + end + end + end +end diff --git a/lib/protocol/http/header/trailer.rb b/lib/protocol/http/header/trailer.rb new file mode 100644 index 0000000..555d13b --- /dev/null +++ b/lib/protocol/http/header/trailer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2025, by Samuel Williams. + +require_relative "split" + +module Protocol + module HTTP + module Header + # Represents headers that can contain multiple distinct values separated by commas. + # + # This isn't a specific header class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed. + class Trailer < Split + # Whether this header is acceptable in HTTP trailers. + # @returns [Boolean] `false`, as Trailer headers control trailer processing and must appear before the message body. + def self.trailer? + false + end + end + end + end +end diff --git a/lib/protocol/http/header/transfer_encoding.rb b/lib/protocol/http/header/transfer_encoding.rb new file mode 100644 index 0000000..354dce6 --- /dev/null +++ b/lib/protocol/http/header/transfer_encoding.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require_relative "split" + +module Protocol + module HTTP + module Header + # The `transfer-encoding` header indicates the encoding transformations that have been applied to the message body. + # + # The `transfer-encoding` header is used to specify the form of encoding used to safely transfer the message body between the sender and receiver. + class TransferEncoding < Split + # The `chunked` transfer encoding allows a server to send data of unknown length by breaking it into chunks. + CHUNKED = "chunked" + + # The `gzip` transfer encoding compresses the message body using the gzip algorithm. + GZIP = "gzip" + + # The `deflate` transfer encoding compresses the message body using the deflate algorithm. + DEFLATE = "deflate" + + # The `compress` transfer encoding compresses the message body using the compress algorithm. + COMPRESS = "compress" + + # The `identity` transfer encoding indicates no transformation has been applied. + IDENTITY = "identity" + + # Initializes the transfer encoding header with the given value. The value is split into distinct entries and converted to lowercase for normalization. + # + # @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas. + def initialize(value = nil) + super(value&.downcase) + end + + # Adds one or more comma-separated values to the transfer encoding header. The values are converted to lowercase for normalization. + # + # @parameter value [String] the value or values to add, separated by commas. + def << value + super(value.downcase) + end + + # @returns [Boolean] whether the `chunked` encoding is present. + def chunked? + self.include?(CHUNKED) + end + + # @returns [Boolean] whether the `gzip` encoding is present. + def gzip? + self.include?(GZIP) + end + + # @returns [Boolean] whether the `deflate` encoding is present. + def deflate? + self.include?(DEFLATE) + end + + # @returns [Boolean] whether the `compress` encoding is present. + def compress? + self.include?(COMPRESS) + end + + # @returns [Boolean] whether the `identity` encoding is present. + def identity? + self.include?(IDENTITY) + end + + # Whether this header is acceptable in HTTP trailers. + # Transfer-Encoding headers control message framing and must not appear in trailers. + # @returns [Boolean] `false`, as Transfer-Encoding headers are hop-by-hop and must precede the message body. + def self.trailer? + false + end + end + end + end +end diff --git a/lib/protocol/http/headers.rb b/lib/protocol/http/headers.rb index 0080458..b3498f9 100644 --- a/lib/protocol/http/headers.rb +++ b/lib/protocol/http/headers.rb @@ -17,11 +17,16 @@ require_relative "header/authorization" require_relative "header/date" require_relative "header/priority" +require_relative "header/trailer" +require_relative "header/server_timing" +require_relative "header/digest" require_relative "header/accept" require_relative "header/accept_charset" require_relative "header/accept_encoding" require_relative "header/accept_language" +require_relative "header/transfer_encoding" +require_relative "header/te" module Protocol module HTTP @@ -65,7 +70,7 @@ def self.[] headers # # @parameter fields [Array] An array of `[key, value]` pairs. # @parameter tail [Integer | Nil] The index of the trailer start in the @fields array. - def initialize(fields = [], tail = nil, indexed: nil) + def initialize(fields = [], tail = nil, indexed: nil, policy: POLICY) @fields = fields # Marks where trailer start in the @fields array: @@ -73,6 +78,21 @@ def initialize(fields = [], tail = nil, indexed: nil) # The cached index of headers: @indexed = nil + + @policy = policy + end + + # @attribute [Hash] The policy for the headers. + attr :policy + + # Set the policy for the headers. + # + # The policy is used to determine how headers are merged and normalized. For example, if a header is specified multiple times, the policy will determine how the values are merged. + # + # @parameter policy [Hash] The policy for the headers. + def policy=(policy) + @policy = policy + @indexed = nil end # Initialize a copy of the headers. @@ -250,17 +270,23 @@ def merge(headers) "content-disposition" => false, "content-length" => false, "content-type" => false, + "expect" => false, "from" => false, "host" => false, "location" => false, "max-forwards" => false, + "range" => false, "referer" => false, "retry-after" => false, + "server" => false, + "transfer-encoding" => Header::TransferEncoding, "user-agent" => false, + "trailer" => Header::Trailer, # Custom headers: "connection" => Header::Connection, "cache-control" => Header::CacheControl, + "te" => Header::TE, "vary" => Header::Vary, "priority" => Header::Priority, @@ -299,6 +325,12 @@ def merge(headers) "accept-charset" => Header::AcceptCharset, "accept-encoding" => Header::AcceptEncoding, "accept-language" => Header::AcceptLanguage, + + # Performance headers: + "server-timing" => Header::ServerTiming, + + # Content integrity headers: + "digest" => Header::Digest, }.tap{|hash| hash.default = Split} # Delete all header values for the given key, and return the merged value. @@ -316,7 +348,7 @@ def delete(key) if @indexed return @indexed.delete(key) - elsif policy = POLICY[key] + elsif policy = @policy[key] (key, value), *tail = deleted merged = policy.new(value) @@ -334,14 +366,24 @@ def delete(key) # @parameter hash [Hash] The hash to merge into. # @parameter key [String] The header key. # @parameter value [String] The raw header value. - protected def merge_into(hash, key, value) - if policy = POLICY[key] + protected def merge_into(hash, key, value, trailer = @tail) + if policy = @policy[key] + # Check if we're adding to trailers and this header is allowed: + if trailer && !policy.trailer? + return false + end + if current_value = hash[key] current_value << value else hash[key] = policy.new(value) end else + # By default, headers are not allowed in trailers: + if trailer + return false + end + if hash.key?(key) raise DuplicateHeaderError, key end @@ -362,11 +404,17 @@ def [] key # # @returns [Hash] A hash table of `{key, value}` pairs. def to_h - @indexed ||= @fields.inject({}) do |hash, (key, value)| - merge_into(hash, key.downcase, value) + unless @indexed + @indexed = {} - hash + @fields.each_with_index do |(key, value), index| + trailer = (@tail && index >= @tail) + + merge_into(@indexed, key.downcase, value, trailer) + end end + + return @indexed end alias as_json to_h diff --git a/releases.md b/releases.md index 9e173c0..8702961 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,54 @@ # Releases +## Unreleased + + - Introduce rich support for `Header::Digest`, `Header::ServerTiming`, `Header::TE`, `Header::Trailer` and `Header::TransferEncoding`. + +### Improved HTTP Trailer Security + +This release introduces significant security improvements for HTTP trailer handling, addressing potential HTTP request smuggling vulnerabilities by implementing a restrictive-by-default policy for trailer headers. + + - **Security-by-default**: HTTP trailers are now validated and restricted by default to prevent HTTP request smuggling attacks. + - Only safe headers are permitted in trailers: + - `date` - Response generation timestamps (safe metadata) + - `digest` - Content integrity verification (safe metadata) + - `etag` - Cache validation tags (safe metadata) + - `server-timing` - Performance metrics (safe metadata) + - All other trailers are ignored by default. + +If you are using this library for gRPC, you will need to use a custom policy to allow the `grpc-status` and `grpc-message` trailers: + +```ruby +module GRPCStatus + def self.new(value) + Integer(value) + end + + def self.trailer? + true + end +end + +module GRPCMessage + def self.new(value) + value + end + + def self.trailer? + true + end +end + +GRPC_POLICY = Protocol::HTTP::Headers::POLICY.dup +GRPC_POLICY['grpc-status'] = GRPCStatus +GRPC_POLICY['grpc-message'] = GRPCMessage + +# Reinterpret the headers using the new policy: +response.headers.policy = GRPC_POLICY +response.headers['grpc-status'] # => 0 +response.headers['grpc-message'] # => "OK" +``` + ## v0.53.0 - Improve consistency of Body `#inspect`. diff --git a/test/protocol/http/header/digest.rb b/test/protocol/http/header/digest.rb new file mode 100644 index 0000000..f49d168 --- /dev/null +++ b/test/protocol/http/header/digest.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/header/digest" +require "sus" + +describe Protocol::HTTP::Header::Digest do + let(:header) {subject.new(description)} + + with "empty header" do + let(:header) {subject.new} + + it "should be empty" do + expect(header.to_s).to be == "" + end + + it "should be an array" do + expect(header).to be_a(Array) + end + + it "should return empty entries" do + expect(header.entries).to be == [] + end + end + + with "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" do + it "can parse a single entry" do + entries = header.entries + expect(entries.size).to be == 1 + expect(entries.first.algorithm).to be == "sha-256" + expect(entries.first.value).to be == "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" + end + end + + with "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8" do + it "can parse multiple entries" do + entries = header.entries + expect(entries.size).to be == 2 + expect(entries[0].algorithm).to be == "sha-256" + expect(entries[1].algorithm).to be == "md5" + end + end + + with "SHA-256=abc123" do + it "normalizes algorithm to lowercase" do + entries = header.entries + expect(entries.first.algorithm).to be == "sha-256" + end + end + + with "sha-256 = X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" do + it "handles whitespace around equals sign" do + entries = header.entries + expect(entries.first.algorithm).to be == "sha-256" + expect(entries.first.value).to be == "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" + end + end + + with "invalid-format-no-equals" do + it "raises ParseError for invalid format" do + expect do + header.entries + end.to raise_exception(Protocol::HTTP::Header::Digest::ParseError) + end + end + + with "#<<" do + let(:header) {subject.new} + + it "can add entries from string" do + header << "sha-256=abc123" + header << "md5=def456" + expect(header.size).to be == 2 + + entries = header.entries + expect(entries[0].algorithm).to be == "sha-256" + expect(entries[1].algorithm).to be == "md5" + end + + it "can add multiple entries at once" do + header << "sha-256=abc123, md5=def456" + expect(header.size).to be == 2 + + entries = header.entries + expect(entries[0].algorithm).to be == "sha-256" + expect(entries[1].algorithm).to be == "md5" + end + end + + with "inherited Split behavior" do + let(:header) {subject.new} + + it "behaves as an array" do + header << "sha-256=abc123" + expect(header.size).to be == 1 + expect(header.first).to be == "sha-256=abc123" + end + + it "can be enumerated" do + header << "sha-256=abc123, md5=def456" + values = [] + header.each {|value| values << value} + expect(values).to be == ["sha-256=abc123", "md5=def456"] + end + + it "supports array methods" do + header << "sha-256=abc123, md5=def456" + expect(header.length).to be == 2 + expect(header.empty?).to be == false + end + end + + with "trailer support" do + it "should be allowed as a trailer" do + expect(subject.trailer?).to be == true + end + end + + with "algorithm edge cases" do + it "handles hyphenated algorithms" do + header = subject.new("sha-256=abc123") + entries = header.entries + expect(entries.first.algorithm).to be == "sha-256" + end + + it "handles numeric algorithms" do + header = subject.new("md5=def456") + entries = header.entries + expect(entries.first.algorithm).to be == "md5" + end + end + + with "value edge cases" do + it "handles empty values" do + header = subject.new("sha-256=") + entries = header.entries + expect(entries.first.value).to be == "" + end + + it "handles values with special characters" do + header = subject.new("sha-256=abc+def/123==") + entries = header.entries + expect(entries.first.value).to be == "abc+def/123==" + end + end +end + +describe Protocol::HTTP::Header::Digest::Entry do + it "can create entry directly" do + entry = subject.new("sha-256", "abc123") + expect(entry.algorithm).to be == "sha-256" + expect(entry.value).to be == "abc123" + expect(entry.to_s).to be == "sha-256=abc123" + end + + it "normalizes algorithm to lowercase" do + entry = subject.new("SHA-256", "abc123") + expect(entry.algorithm).to be == "sha-256" + end + + it "handles complex algorithm names" do + entry = subject.new("sha-384", "complex-value") + expect(entry.algorithm).to be == "sha-384" + expect(entry.to_s).to be == "sha-384=complex-value" + end + + it "handles base64 padding in values" do + entry = subject.new("md5", "abc123==") + expect(entry.value).to be == "abc123==" + end +end diff --git a/test/protocol/http/header/multiple.rb b/test/protocol/http/header/multiple.rb index 35a85f7..48479f0 100644 --- a/test/protocol/http/header/multiple.rb +++ b/test/protocol/http/header/multiple.rb @@ -19,4 +19,10 @@ ) end end + + with ".trailer?" do + it "is not allowed in trailers by default" do + expect(subject).not.to be(:trailer?) + end + end end diff --git a/test/protocol/http/header/server_timing.rb b/test/protocol/http/header/server_timing.rb new file mode 100644 index 0000000..bf5145e --- /dev/null +++ b/test/protocol/http/header/server_timing.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/header/server_timing" +require "sus" + +describe Protocol::HTTP::Header::ServerTiming do + let(:header) {subject.new(description)} + + with "empty header" do + let(:header) {subject.new} + + it "should be empty" do + expect(header.to_s).to be == "" + end + + it "should be an array" do + expect(header).to be_a(Array) + end + + it "should return empty metrics" do + expect(header.metrics).to be == [] + end + end + + with "db;dur=53.2" do + it "can parse metric with duration" do + metrics = header.metrics + expect(metrics.size).to be == 1 + expect(metrics.first.name).to be == "db" + expect(metrics.first.duration).to be == 53.2 + expect(metrics.first.description).to be_nil + end + end + + with 'db;dur="53.2"' do + it "can parse metric with quoted duration" do + metrics = header.metrics + expect(metrics.size).to be == 1 + expect(metrics.first.name).to be == "db" + expect(metrics.first.duration).to be == 53.2 + expect(metrics.first.description).to be_nil + end + end + + with 'cache;desc="Redis lookup"' do + it "can parse metric with description" do + metrics = header.metrics + expect(metrics.size).to be == 1 + expect(metrics.first.name).to be == "cache" + expect(metrics.first.duration).to be_nil + expect(metrics.first.description).to be == "Redis lookup" + end + end + + with 'app;dur=12.7;desc="Application logic"' do + it "can parse metric with duration and description" do + metrics = header.metrics + expect(metrics.first.name).to be == "app" + expect(metrics.first.duration).to be == 12.7 + expect(metrics.first.description).to be == "Application logic" + end + end + + with 'app;dur="12.7";desc="Application logic"' do + it "can parse metric with quoted duration and quoted description" do + metrics = header.metrics + expect(metrics.first.name).to be == "app" + expect(metrics.first.duration).to be == 12.7 + expect(metrics.first.description).to be == "Application logic" + end + end + + with "db;dur=45.3, app;dur=12.7;desc=\"Application logic\", cache;desc=\"Cache miss\"" do + it "can parse multiple metrics" do + metrics = header.metrics + expect(metrics.size).to be == 3 + + expect(metrics[0].name).to be == "db" + expect(metrics[0].duration).to be == 45.3 + expect(metrics[0].description).to be_nil + + expect(metrics[1].name).to be == "app" + expect(metrics[1].duration).to be == 12.7 + expect(metrics[1].description).to be == "Application logic" + + expect(metrics[2].name).to be == "cache" + expect(metrics[2].duration).to be_nil + expect(metrics[2].description).to be == "Cache miss" + end + end + + with "cache-hit" do + it "can parse metric with name only" do + metrics = header.metrics + expect(metrics.first.name).to be == "cache-hit" + expect(metrics.first.duration).to be_nil + expect(metrics.first.description).to be_nil + end + end + + with "invalid;unknown=param" do + it "ignores unknown parameters" do + metrics = header.metrics + expect(metrics.first.name).to be == "invalid" + expect(metrics.first.duration).to be_nil + expect(metrics.first.description).to be_nil + end + end + + with "invalid-metric-name!" do + it "raises ParseError for invalid metric name" do + expect do + header.metrics + end.to raise_exception(Protocol::HTTP::Header::ServerTiming::ParseError) + end + end + + with "#<<" do + let(:header) {subject.new} + + it "can add metrics from string" do + header << "db;dur=25.5" + header << "cache;dur=5.2;desc=\"Hit\"" + expect(header.size).to be == 2 + + metrics = header.metrics + expect(metrics[0].name).to be == "db" + expect(metrics[1].name).to be == "cache" + end + + it "can add multiple metrics at once" do + header << "db;dur=25.5, cache;desc=\"Hit\"" + expect(header.size).to be == 2 + + metrics = header.metrics + expect(metrics[0].name).to be == "db" + expect(metrics[1].name).to be == "cache" + end + end + + with "inherited Split behavior" do + let(:header) {subject.new} + + it "behaves as an array" do + header << "db;dur=25.5" + expect(header.size).to be == 1 + expect(header.first).to be == "db;dur=25.5" + end + + it "can be enumerated" do + header << "db;dur=25.5, cache;desc=\"Hit\"" + values = [] + header.each {|value| values << value} + expect(values).to be == ["db;dur=25.5", "cache;desc=\"Hit\""] + end + + it "supports array methods" do + header << "db;dur=25.5, cache;desc=\"Hit\"" + expect(header.length).to be == 2 + expect(header.empty?).to be == false + end + end + + with "trailer support" do + it "should be allowed as a trailer" do + expect(subject.trailer?).to be == true + end + end + + with "cache_hit" do + it "can parse metric with underscore in name" do + metrics = header.metrics + expect(metrics.first.name).to be == "cache_hit" + end + end + + with "test;desc=unquoted-value" do + it "can parse unquoted description" do + metrics = header.metrics + expect(metrics.first.description).to be == "unquoted-value" + end + end + + with 'test;desc=""' do + it "can parse empty quoted description" do + metrics = header.metrics + expect(metrics.first.description).to be == "" + end + end + + with "test;dur=123;desc=mixed;unknown=ignored" do + it "ignores unknown parameters and processes known ones" do + metrics = header.metrics + expect(metrics.first.name).to be == "test" + expect(metrics.first.duration).to be == 123.0 + expect(metrics.first.description).to be == "mixed" + end + end + + with "test;dur=0" do + it "can parse zero duration" do + metrics = header.metrics + expect(metrics.first.duration).to be == 0.0 + end + end + + with "test;dur=123.456789" do + it "preserves decimal precision" do + metrics = header.metrics + expect(metrics.first.duration).to be == 123.456789 + end + end +end + +describe Protocol::HTTP::Header::ServerTiming::Metric do + it "can create metric directly" do + metric = subject.new("test", 123.45, "Test metric") + expect(metric.name).to be == "test" + expect(metric.duration).to be == 123.45 + expect(metric.description).to be == "Test metric" + expect(metric.to_s).to be == "test;dur=123.45;desc=\"Test metric\"" + end + + it "can create metric with name only" do + metric = subject.new("cache") + expect(metric.name).to be == "cache" + expect(metric.duration).to be_nil + expect(metric.description).to be_nil + expect(metric.to_s).to be == "cache" + end + + it "can create metric with duration only" do + metric = subject.new("test", 123.45, nil) + expect(metric.to_s).to be == "test;dur=123.45" + end + + it "can create metric with description only" do + metric = subject.new("test", nil, "description") + expect(metric.to_s).to be == "test;desc=\"description\"" + end + + it "handles nil values correctly" do + metric = subject.new("test", nil, nil) + expect(metric.to_s).to be == "test" + end +end diff --git a/test/protocol/http/header/te.rb b/test/protocol/http/header/te.rb new file mode 100644 index 0000000..d2e749b --- /dev/null +++ b/test/protocol/http/header/te.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/header/te" + +describe Protocol::HTTP::Header::TE do + let(:header) {subject.new(description)} + + with "chunked" do + it "detects chunked encoding" do + expect(header).to be(:chunked?) + end + end + + with "gzip" do + it "detects gzip encoding" do + expect(header).to be(:gzip?) + end + end + + with "deflate" do + it "detects deflate encoding" do + expect(header).to be(:deflate?) + end + end + + with "trailers" do + it "detects trailers acceptance" do + expect(header).to be(:trailers?) + end + end + + with "compress" do + it "detects compress encoding" do + expect(header).to be(:compress?) + end + end + + with "identity" do + it "detects identity encoding" do + expect(header).to be(:identity?) + end + end + + with "gzip;q=0.8, chunked;q=1.0" do + it "parses quality factors" do + codings = header.transfer_codings + expect(codings.length).to be == 2 + expect(codings[0].name).to be == "gzip" + expect(codings[0].quality_factor).to be == 0.8 + expect(codings[1].name).to be == "chunked" + expect(codings[1].quality_factor).to be == 1.0 + end + + it "contains expected encodings" do + expect(header).to be(:gzip?) + expect(header).to be(:chunked?) + end + end + + with "gzip;q=0.5, deflate;q=0.8" do + it "handles multiple quality factors" do + codings = header.transfer_codings.sort + expect(codings[0].name).to be == "deflate" # higher quality first + expect(codings[1].name).to be == "gzip" + end + end + + with "empty header value" do + let(:header) {subject.new} + + it "handles empty TE header" do + expect(header).to be(:empty?) + expect(header).not.to be(:chunked?) + end + end + + with "#<<" do + let(:header) {subject.new} + + it "can add encodings" do + header << "gzip" + expect(header).to be(:gzip?) + + header << "chunked;q=0.9" + expect(header).to be(:chunked?) + end + end + + with "error handling" do + it "raises ParseError for invalid transfer coding" do + header = subject.new("invalid@encoding") + expect do + header.transfer_codings + end.to raise_exception(Protocol::HTTP::Header::TE::ParseError) + end + end + + with ".trailer?" do + it "should be forbidden in trailers" do + expect(subject).not.to be(:trailer?) + end + end +end + +describe Protocol::HTTP::Header::TE::TransferCoding do + it "handles quality factor conversion" do + coding = subject.new("gzip", "0.8") + expect(coding.quality_factor).to be == 0.8 + end + + it "defaults quality factor to 1.0" do + coding = subject.new("gzip", nil) + expect(coding.quality_factor).to be == 1.0 + end + + it "serializes with quality factor" do + coding = subject.new("gzip", "0.8") + expect(coding.to_s).to be == "gzip;q=0.8" + end + + it "serializes without quality factor when 1.0" do + coding = subject.new("gzip", nil) + expect(coding.to_s).to be == "gzip" + end + + it "compares by quality factor" do + high = subject.new("gzip", "0.9") + low = subject.new("deflate", "0.5") + expect(high <=> low).to be == -1 # high quality first + end +end diff --git a/test/protocol/http/header/trailer.rb b/test/protocol/http/header/trailer.rb new file mode 100644 index 0000000..9250223 --- /dev/null +++ b/test/protocol/http/header/trailer.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/header/trailer" + +describe Protocol::HTTP::Header::Trailer do + let(:header) {subject.new(description)} + + with "etag" do + it "contains etag header" do + expect(header).to be(:include?, "etag") + end + + it "has one header" do + expect(header.length).to be == 1 + end + end + + with "etag, content-md5" do + it "contains multiple headers" do + expect(header).to be(:include?, "etag") + expect(header).to be(:include?, "content-md5") + end + + it "has correct count" do + expect(header.length).to be == 2 + end + end + + with "etag, content-md5, expires" do + it "handles three headers" do + expect(header).to be(:include?, "etag") + expect(header).to be(:include?, "content-md5") + expect(header).to be(:include?, "expires") + end + + it "serializes correctly" do + expect(header.to_s).to be == "etag,content-md5,expires" + end + end + + with "etag , content-md5 , expires" do + it "strips whitespace" do + expect(header.length).to be == 3 + expect(header).to be(:include?, "etag") + expect(header).to be(:include?, "content-md5") + end + end + + with "empty header value" do + let(:header) {subject.new} + + it "handles empty trailer" do + expect(header).to be(:empty?) + expect(header.to_s).to be == "" + end + end + + with "#<<" do + let(:header) {subject.new("etag")} + + it "can add headers" do + header << "content-md5, expires" + expect(header.length).to be == 3 + expect(header).to be(:include?, "expires") + end + end + + with ".trailer?" do + it "should be forbidden in trailers" do + expect(subject).not.to be(:trailer?) + end + end +end diff --git a/test/protocol/http/header/transfer_encoding.rb b/test/protocol/http/header/transfer_encoding.rb new file mode 100644 index 0000000..2cfd7e3 --- /dev/null +++ b/test/protocol/http/header/transfer_encoding.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/header/transfer_encoding" + +describe Protocol::HTTP::Header::TransferEncoding do + let(:header) {subject.new(description)} + + with "chunked" do + it "detects chunked encoding" do + expect(header).to be(:chunked?) + end + end + + with "gzip" do + it "detects gzip encoding" do + expect(header).to be(:gzip?) + end + end + + with "deflate" do + it "detects deflate encoding" do + expect(header).to be(:deflate?) + end + end + + with "compress" do + it "detects compress encoding" do + expect(header).to be(:compress?) + end + end + + with "identity" do + it "detects identity encoding" do + expect(header).to be(:identity?) + end + end + + with "gzip, chunked" do + it "handles multiple encodings" do + expect(header.length).to be == 2 + expect(header).to be(:include?, "gzip") + expect(header).to be(:include?, "chunked") + expect(header).to be(:gzip?) + expect(header).to be(:chunked?) + end + end + + with "empty header value" do + let(:header) {subject.new} + + it "handles empty transfer encoding" do + expect(header).to be(:empty?) + expect(header).not.to be(:chunked?) + end + end + + with "#<<" do + let(:header) {subject.new} + + it "can add encodings" do + header << "gzip" + expect(header).to be(:gzip?) + + header << "chunked" + expect(header).to be(:chunked?) + end + end + + with ".trailer?" do + it "should be forbidden in trailers" do + expect(subject).not.to be(:trailer?) + end + end +end diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index 9db3fd7..bfaca51 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -297,6 +297,69 @@ expect(trailer.to_h).to be == {"etag" => "abcd"} end + + with "forbidden trailers" do + let(:headers) {subject.new} + + forbidden_trailers = %w[ + accept + accept-charset + accept-encoding + accept-language + + authorization + proxy-authorization + www-authenticate + proxy-authenticate + + connection + content-length + transfer-encoding + te + upgrade + trailer + + host + expect + range + + content-type + content-encoding + content-range + + cookie + set-cookie + + x-foo-bar + ] + + forbidden_trailers.each do |key| + it "can't add a #{key.inspect} header in the trailer", unique: key do + trailer = headers.trailer! + headers.add(key, "example") + expect(headers).not.to be(:include?, key) + end + end + end + + with "permitted trailers" do + let(:headers) {subject.new} + + permitted_trailers = [ + "date", + "digest", + "etag", + "server-timing", + ] + + permitted_trailers.each do |key| + it "can add a #{key.inspect} header in the trailer", unique: key do + trailer = headers.trailer! + headers.add(key, "example") + expect(headers).to be(:include?, key) + end + end + end end with "#trailer" do @@ -309,6 +372,78 @@ end end + with "custom policy" do + let(:headers) {subject.new} + + # Create a custom header class that allows trailers + let(:grpc_status_class) do + Class.new(String) do + def self.trailer? + true + end + end + end + + it "can set custom policy to allow additional trailer headers" do + # Create custom policy that allows grpc-status as trailer + custom_policy = Protocol::HTTP::Headers::POLICY.dup + custom_policy["grpc-status"] = grpc_status_class + + # Set the custom policy + headers.policy = custom_policy + + # Enable trailers + headers.trailer! + + # Add grpc-status header (should be allowed with custom policy) + headers.add("grpc-status", "0") + + # Verify it appears in trailers + expect(headers).to be(:include?, "grpc-status") + + trailer_headers = {} + headers.trailer do |key, value| + trailer_headers[key] = value + end + + expect(trailer_headers["grpc-status"]).to be == "0" + end + + it "policy= clears indexed cache" do + # Add some headers first + headers.add("content-type", "text/html") + + # Force indexing + hash1 = headers.to_h + expect(hash1).to be(:include?, "content-type") + + # Change policy + new_policy = {} + headers.policy = new_policy + + # Add another header + headers.add("x-custom", "value") + + # Verify cache was cleared and rebuilt + hash2 = headers.to_h + expect(hash2).to be(:include?, "content-type") + expect(hash2).to be(:include?, "x-custom") + end + + it "can read policy attribute" do + original_policy = headers.policy + expect(original_policy).to be == Protocol::HTTP::Headers::POLICY + + # Set new policy + custom_policy = {"custom" => String} + headers.policy = custom_policy + + # Verify policy was changed + expect(headers.policy).to be == custom_policy + expect(headers.policy).not.to be == original_policy + end + end + with "#flatten!" do it "can flatten trailer" do headers.add("trailer", "etag")