protocol-http 0.53.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49ea23b4e99ab120bfd68806628631db474b789d7ca841a28877ba176239816f
4
- data.tar.gz: 2dbe6a91006f94a303babb3b156a55eb82ac60025f111d7f003896e744009394
3
+ metadata.gz: 4c016217fd5d9ddfac87278d8a629d47ec338d02441d0f27ed25102962e927b9
4
+ data.tar.gz: 51d479e7f25046ca76d08ed60676b7bf4afe5ffa3e234bbb4501478d3fe1db2c
5
5
  SHA512:
6
- metadata.gz: 0d05dca30d34d5a66483cf09b3b3927af1bd6cf4679cd2421fa0737ab7f6cd56b61fd681d90c7b3252c6bc79d6843b8460c3c74c595b31ff636fb46920088165
7
- data.tar.gz: 75cb7b599a2ba9d49f859b16f00f94bc2e59118813779e9a4a7e5804ea94d6cc66cc361fc4ef0d7ce957e26bbf7a6a53e1f7b02ec92e438260bfa5a1a33e1299
6
+ metadata.gz: a49eb6993da937df85d263f3b505dc700066c796b102c2e4290b9893700fd9f82852bb17f330ef2bf350e11dbf0782e206ae1d29cc4be3fb422d8f6642f1720b
7
+ data.tar.gz: 2680c120c9d72c6ee7c35929984b67e248d26496145dc08700e4a8a5f60568e6dfe2600b5179042d92793d5784ee46e270a59e76f6d272c094c47ccf1c97c0b3
checksums.yaml.gz.sig CHANGED
Binary file
@@ -0,0 +1,94 @@
1
+ # Headers
2
+
3
+ This guide explains how to work with HTTP headers using `protocol-http`.
4
+
5
+ ## Core Concepts
6
+
7
+ `protocol-http` provides several core concepts for working with HTTP headers:
8
+
9
+ - A {ruby Protocol::HTTP::Headers} class which represents a collection of HTTP headers with built-in security and policy features.
10
+ - Header-specific classes like {ruby Protocol::HTTP::Header::Accept} and {ruby Protocol::HTTP::Header::Authorization} which provide specialized parsing and formatting.
11
+ - Trailer security validation to prevent HTTP request smuggling attacks.
12
+
13
+ ## Usage
14
+
15
+ The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers:
16
+
17
+ ```ruby
18
+ require 'protocol/http'
19
+
20
+ headers = Protocol::HTTP::Headers.new
21
+ headers.add('content-type', 'text/html')
22
+ headers.add('set-cookie', 'session=abc123')
23
+
24
+ # Access headers
25
+ content_type = headers['content-type'] # => "text/html"
26
+
27
+ # Check if header exists
28
+ headers.include?('content-type') # => true
29
+ ```
30
+
31
+ ### Header Policies
32
+
33
+ Different header types have different behaviors for merging, validation, and trailer handling:
34
+
35
+ ```ruby
36
+ # Some headers can be specified multiple times
37
+ headers.add('set-cookie', 'first=value1')
38
+ headers.add('set-cookie', 'second=value2')
39
+
40
+ # Others are singletons and will raise errors if duplicated
41
+ headers.add('content-length', '100')
42
+ # headers.add('content-length', '200') # Would raise DuplicateHeaderError
43
+ ```
44
+
45
+ ### Structured Headers
46
+
47
+ Some headers have specialized classes for parsing and formatting:
48
+
49
+ ```ruby
50
+ # Accept header with media ranges
51
+ accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9')
52
+ media_ranges = accept.media_ranges
53
+
54
+ # Authorization header
55
+ auth = Protocol::HTTP::Header::Authorization.basic('username', 'password')
56
+ # => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
57
+ ```
58
+
59
+ ### Trailer Security
60
+
61
+ HTTP trailers are headers that appear after the message body. For security reasons, only certain headers are allowed in trailers:
62
+
63
+ ```ruby
64
+ # Working with trailers
65
+ headers = Protocol::HTTP::Headers.new([
66
+ ['content-type', 'text/html'],
67
+ ['content-length', '1000']
68
+ ])
69
+
70
+ # Start trailer section
71
+ headers.trailer!
72
+
73
+ # These will be allowed (safe metadata)
74
+ headers.add('etag', '"12345"')
75
+ headers.add('date', Time.now.httpdate)
76
+
77
+ # These will be silently ignored for security
78
+ headers.add('authorization', 'Bearer token') # Ignored - credential leakage risk
79
+ headers.add('connection', 'close') # Ignored - hop-by-hop header
80
+ ```
81
+
82
+ The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers:
83
+
84
+ **Allowed headers** (return `true` for `trailer?`):
85
+ - `date` - Response generation timestamps.
86
+ - `digest` - Content integrity verification.
87
+ - `etag` - Cache validation tags.
88
+ - `server-timing` - Performance metrics.
89
+
90
+ **Forbidden headers** (return `false` for `trailer?`):
91
+ - `authorization` - Prevents credential leakage.
92
+ - `connection`, `te`, `transfer-encoding` - Hop-by-hop headers that control connection behavior.
93
+ - `cookie`, `set-cookie` - State information needed during initial processing.
94
+ - `accept` - Content negotiation must occur before response generation.
data/context/index.yaml CHANGED
@@ -14,17 +14,12 @@ files:
14
14
  title: Message Body
15
15
  description: This guide explains how to work with HTTP request and response message
16
16
  bodies using `Protocol::HTTP::Body` classes.
17
+ - path: headers.md
18
+ title: Headers
19
+ description: This guide explains how to work with HTTP headers using `protocol-http`.
17
20
  - path: middleware.md
18
21
  title: Middleware
19
22
  description: This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`.
20
- - path: hypertext-references.md
21
- title: Hypertext References
22
- description: This guide explains how to use `Protocol::HTTP::Reference` for constructing
23
- and manipulating hypertext references (URLs with parameters).
24
- - path: url-parsing.md
25
- title: URL Parsing
26
- description: This guide explains how to use `Protocol::HTTP::URL` for parsing and
27
- manipulating URL components, particularly query strings and parameters.
28
23
  - path: streaming.md
29
24
  title: Streaming
30
25
  description: This guide gives an overview of how to implement streaming requests
@@ -3,6 +3,7 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2019-2025, by Samuel Williams.
5
5
  # Copyright, 2023, by Genki Takiuchi.
6
+ # Copyright, 2025, by William T. Nelson.
6
7
 
7
8
  require_relative "buffered"
8
9
 
@@ -315,7 +316,7 @@ module Protocol
315
316
  end
316
317
 
317
318
  # Write data to the stream using {write}.
318
- def <<(buffer)
319
+ def << buffer
319
320
  write(buffer)
320
321
  end
321
322
 
@@ -4,59 +4,66 @@
4
4
  # Copyright, 2019-2025, by Samuel Williams.
5
5
  # Copyright, 2022, by Herrick Fang.
6
6
 
7
- require_relative "url"
7
+ require_relative "quoted_string"
8
8
 
9
9
  module Protocol
10
10
  module HTTP
11
11
  # Represents an individual cookie key-value pair.
12
12
  class Cookie
13
+ # Valid cookie name characters according to RFC 6265.
14
+ # cookie-name = token (RFC 2616 defines token)
15
+ VALID_COOKIE_KEY = /\A#{TOKEN}\z/.freeze
16
+
17
+ # Valid cookie value characters according to RFC 6265.
18
+ # cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
19
+ # cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
20
+ # Excludes control chars, whitespace, DQUOTE, comma, semicolon, and backslash
21
+ VALID_COOKIE_VALUE = /\A[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*\z/.freeze
22
+
13
23
  # Initialize the cookie with the given name, value, and directives.
14
24
  #
15
- # @parameter name [String] The name of the cookiel, e.g. "session_id".
25
+ # @parameter name [String] The name of the cookie, e.g. "session_id".
16
26
  # @parameter value [String] The value of the cookie, e.g. "1234".
17
27
  # @parameter directives [Hash] The directives of the cookie, e.g. `{"path" => "/"}`.
18
- def initialize(name, value, directives)
28
+ # @raises [ArgumentError] If the name or value contains invalid characters.
29
+ def initialize(name, value, directives = nil)
30
+ unless VALID_COOKIE_KEY.match?(name)
31
+ raise ArgumentError, "Invalid cookie name: #{name.inspect}"
32
+ end
33
+
34
+ if value && !VALID_COOKIE_VALUE.match?(value)
35
+ raise ArgumentError, "Invalid cookie value: #{value.inspect}"
36
+ end
37
+
19
38
  @name = name
20
39
  @value = value
21
40
  @directives = directives
22
41
  end
23
42
 
24
43
  # @attribute [String] The name of the cookie.
25
- attr :name
44
+ attr_accessor :name
26
45
 
27
46
  # @attribute [String] The value of the cookie.
28
- attr :value
47
+ attr_accessor :value
29
48
 
30
49
  # @attribute [Hash] The directives of the cookie.
31
- attr :directives
32
-
33
- # Encode the name of the cookie.
34
- def encoded_name
35
- URL.escape(@name)
36
- end
37
-
38
- # Encode the value of the cookie.
39
- def encoded_value
40
- URL.escape(@value)
41
- end
50
+ attr_accessor :directives
42
51
 
43
52
  # Convert the cookie to a string.
44
53
  #
45
54
  # @returns [String] The string representation of the cookie.
46
55
  def to_s
47
- buffer = String.new.b
56
+ buffer = String.new
48
57
 
49
- buffer << encoded_name << "=" << encoded_value
58
+ buffer << @name << "=" << @value
50
59
 
51
60
  if @directives
52
- @directives.collect do |key, value|
61
+ @directives.each do |key, value|
53
62
  buffer << ";"
63
+ buffer << key
54
64
 
55
- case value
56
- when String
57
- buffer << key << "=" << value
58
- when TrueClass
59
- buffer << key
65
+ if value != true
66
+ buffer << "=" << value.to_s
60
67
  end
61
68
  end
62
69
  end
@@ -74,11 +81,7 @@ module Protocol
74
81
  key, value = head.split("=", 2)
75
82
  directives = self.parse_directives(directives)
76
83
 
77
- self.new(
78
- URL.unescape(key),
79
- URL.unescape(value),
80
- directives,
81
- )
84
+ self.new(key, value, directives)
82
85
  end
83
86
 
84
87
  # Parse a list of strings into a hash of directives.
@@ -2,9 +2,10 @@
2
2
 
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2025, by Samuel Williams.
5
+ # Copyright, 2025, by William T. Nelson.
5
6
 
6
7
  require_relative "split"
7
- require_relative "quoted_string"
8
+ require_relative "../quoted_string"
8
9
  require_relative "../error"
9
10
 
10
11
  module Protocol
@@ -81,7 +82,7 @@ module Protocol
81
82
  # The input string is split into distinct entries and appended to the array.
82
83
  #
83
84
  # @parameter value [String] the value or values to add, separated by commas.
84
- def << (value)
85
+ def << value
85
86
  self.concat(value.scan(SEPARATOR).map(&:strip))
86
87
  end
87
88
 
@@ -92,6 +93,12 @@ module Protocol
92
93
  join(",")
93
94
  end
94
95
 
96
+ # Whether this header is acceptable in HTTP trailers.
97
+ # @returns [Boolean] `false`, as Accept headers are used for response content negotiation.
98
+ def self.trailer?
99
+ false
100
+ end
101
+
95
102
  # Parse the `accept` header.
96
103
  #
97
104
  # @returns [Array(Charset)] the list of content types and their associated parameters.
@@ -4,7 +4,7 @@
4
4
  # Copyright, 2025, by Samuel Williams.
5
5
 
6
6
  require_relative "split"
7
- require_relative "quoted_string"
7
+ require_relative "../quoted_string"
8
8
  require_relative "../error"
9
9
 
10
10
  module Protocol
@@ -4,7 +4,7 @@
4
4
  # Copyright, 2025, by Samuel Williams.
5
5
 
6
6
  require_relative "split"
7
- require_relative "quoted_string"
7
+ require_relative "../quoted_string"
8
8
  require_relative "../error"
9
9
 
10
10
  module Protocol
@@ -4,7 +4,7 @@
4
4
  # Copyright, 2025, by Samuel Williams.
5
5
 
6
6
  require_relative "split"
7
- require_relative "quoted_string"
7
+ require_relative "../quoted_string"
8
8
  require_relative "../error"
9
9
 
10
10
  module Protocol
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2024, by Samuel Williams.
4
+ # Copyright, 2019-2025, by Samuel Williams.
5
5
  # Copyright, 2024, by Earlopain.
6
6
 
7
7
  module Protocol
@@ -34,6 +34,12 @@ module Protocol
34
34
  "Basic #{strict_base64_encoded}"
35
35
  )
36
36
  end
37
+
38
+ # Whether this header is acceptable in HTTP trailers.
39
+ # @returns [Boolean] `false`, as authorization headers are used for request authentication.
40
+ def self.trailer?
41
+ false
42
+ end
37
43
  end
38
44
  end
39
45
  end
@@ -50,6 +50,13 @@ module Protocol
50
50
  def upgrade?
51
51
  self.include?(UPGRADE)
52
52
  end
53
+
54
+ # Whether this header is acceptable in HTTP trailers.
55
+ # Connection headers control the current connection and must not appear in trailers.
56
+ # @returns [Boolean] `false`, as connection headers are hop-by-hop and forbidden in trailers.
57
+ def self.trailer?
58
+ false
59
+ end
53
60
  end
54
61
  end
55
62
  end
@@ -23,6 +23,13 @@ module Protocol
23
23
 
24
24
  cookies.map{|cookie| [cookie.name, cookie]}.to_h
25
25
  end
26
+
27
+ # Whether this header is acceptable in HTTP trailers.
28
+ # Cookie headers should not appear in trailers as they contain state information needed early in processing.
29
+ # @returns [Boolean] `false`, as cookie headers are needed during initial request processing.
30
+ def self.trailer?
31
+ false
32
+ end
26
33
  end
27
34
 
28
35
  # The `set-cookie` header sends cookies from the server to the user agent.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2024, by Samuel Williams.
4
+ # Copyright, 2023-2025, by Samuel Williams.
5
5
 
6
6
  require "time"
7
7
 
@@ -25,6 +25,13 @@ module Protocol
25
25
  def to_time
26
26
  ::Time.parse(self)
27
27
  end
28
+
29
+ # Whether this header is acceptable in HTTP trailers.
30
+ # Date headers can safely appear in trailers as they provide metadata about response generation.
31
+ # @returns [Boolean] `true`, as date headers are metadata that can be computed after response generation.
32
+ def self.trailer?
33
+ true
34
+ end
28
35
  end
29
36
  end
30
37
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "split"
7
+ require_relative "../quoted_string"
8
+ require_relative "../error"
9
+
10
+ module Protocol
11
+ module HTTP
12
+ module Header
13
+ # The `digest` header provides a digest of the message body for integrity verification.
14
+ #
15
+ # 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.
16
+ #
17
+ # ## Examples
18
+ #
19
+ # ```ruby
20
+ # digest = Digest.new("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=")
21
+ # digest << "md5=9bb58f26192e4ba00f01e2e7b136bbd8"
22
+ # puts digest.to_s
23
+ # # => "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8"
24
+ # ```
25
+ class Digest < Split
26
+ ParseError = Class.new(Error)
27
+
28
+ # https://tools.ietf.org/html/rfc3230#section-4.3.2
29
+ ENTRY = /\A(?<algorithm>[a-zA-Z0-9][a-zA-Z0-9\-]*)\s*=\s*(?<value>.*)\z/
30
+
31
+ # A single digest entry in the Digest header.
32
+ Entry = Struct.new(:algorithm, :value) do
33
+ # Create a new digest entry.
34
+ #
35
+ # @parameter algorithm [String] the digest algorithm (e.g., "sha-256", "md5").
36
+ # @parameter value [String] the base64-encoded or hex-encoded digest value.
37
+ def initialize(algorithm, value)
38
+ super(algorithm.downcase, value)
39
+ end
40
+
41
+ # Convert the entry to its string representation.
42
+ #
43
+ # @returns [String] the formatted digest string.
44
+ def to_s
45
+ "#{algorithm}=#{value}"
46
+ end
47
+ end
48
+
49
+ # Parse the `digest` header value into a list of digest entries.
50
+ #
51
+ # @returns [Array(Entry)] the list of digest entries with their algorithms and values.
52
+ def entries
53
+ self.map do |value|
54
+ if match = value.match(ENTRY)
55
+ Entry.new(match[:algorithm], match[:value])
56
+ else
57
+ raise ParseError.new("Could not parse digest value: #{value.inspect}")
58
+ end
59
+ end
60
+ end
61
+
62
+ # Whether this header is acceptable in HTTP trailers.
63
+ # @returns [Boolean] `true`, as digest headers contain integrity hashes that can only be calculated after the entire message body is available.
64
+ def self.trailer?
65
+ true
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -25,6 +25,13 @@ module Protocol
25
25
  def weak?
26
26
  self.start_with?("W/")
27
27
  end
28
+
29
+ # Whether this header is acceptable in HTTP trailers.
30
+ # ETag headers can safely appear in trailers as they provide cache validation metadata.
31
+ # @returns [Boolean] `true`, as ETag headers are metadata that can be computed after response generation.
32
+ def self.trailer?
33
+ true
34
+ end
28
35
  end
29
36
  end
30
37
  end
@@ -25,6 +25,13 @@ module Protocol
25
25
  def to_s
26
26
  join("\n")
27
27
  end
28
+
29
+ # Whether this header is acceptable in HTTP trailers.
30
+ # This is a base class for headers with multiple values, default is to disallow in trailers.
31
+ # @returns [Boolean] `false`, as most multiple-value headers should not appear in trailers by default.
32
+ def self.trailer?
33
+ false
34
+ end
28
35
  end
29
36
  end
30
37
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "split"
7
+ require_relative "../quoted_string"
8
+ require_relative "../error"
9
+
10
+ module Protocol
11
+ module HTTP
12
+ module Header
13
+ # The `server-timing` header communicates performance metrics about the request-response cycle to the client.
14
+ #
15
+ # 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.
16
+ #
17
+ # ## Examples
18
+ #
19
+ # ```ruby
20
+ # server_timing = ServerTiming.new("db;dur=53.2")
21
+ # server_timing << "cache;dur=12.1;desc=\"Redis lookup\""
22
+ # puts server_timing.to_s
23
+ # # => "db;dur=53.2, cache;dur=12.1;desc=\"Redis lookup\""
24
+ # ```
25
+ class ServerTiming < Split
26
+ ParseError = Class.new(Error)
27
+
28
+ # https://www.w3.org/TR/server-timing/
29
+ METRIC = /\A(?<name>[a-zA-Z0-9][a-zA-Z0-9_\-]*)(;(?<parameters>.*))?\z/
30
+ PARAMETER = /(?<key>dur|desc)=((?<value>#{TOKEN})|(?<quoted_value>#{QUOTED_STRING}))/
31
+
32
+ # A single metric in the Server-Timing header.
33
+ Metric = Struct.new(:name, :duration, :description) do
34
+ # Create a new server timing metric.
35
+ #
36
+ # @parameter name [String] the name of the metric.
37
+ # @parameter duration [Float | Nil] the duration in milliseconds.
38
+ # @parameter description [String | Nil] the description of the metric.
39
+ def initialize(name, duration = nil, description = nil)
40
+ super(name, duration, description)
41
+ end
42
+
43
+ # Convert the metric to its string representation.
44
+ #
45
+ # @returns [String] the formatted metric string.
46
+ def to_s
47
+ result = name.dup
48
+ result << ";dur=#{duration}" if duration
49
+ result << ";desc=\"#{description}\"" if description
50
+ result
51
+ end
52
+ end
53
+
54
+ # Parse the `server-timing` header value into a list of metrics.
55
+ #
56
+ # @returns [Array(Metric)] the list of metrics with their names, durations, and descriptions.
57
+ def metrics
58
+ self.map do |value|
59
+ if match = value.match(METRIC)
60
+ name = match[:name]
61
+ parameters = match[:parameters] || ""
62
+
63
+ duration = nil
64
+ description = nil
65
+
66
+ parameters.scan(PARAMETER) do |key, value, quoted_value|
67
+ value = QuotedString.unquote(quoted_value) if quoted_value
68
+
69
+ case key
70
+ when "dur"
71
+ duration = value.to_f
72
+ when "desc"
73
+ description = value
74
+ end
75
+ end
76
+
77
+ Metric.new(name, duration, description)
78
+ else
79
+ raise ParseError.new("Could not parse server timing metric: #{value.inspect}")
80
+ end
81
+ end
82
+ end
83
+
84
+ # Whether this header is acceptable in HTTP trailers.
85
+ # @returns [Boolean] `true`, as server-timing headers contain performance metrics that are typically calculated during response generation.
86
+ def self.trailer?
87
+ true
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -40,6 +40,13 @@ module Protocol
40
40
  join(",")
41
41
  end
42
42
 
43
+ # Whether this header is acceptable in HTTP trailers.
44
+ # This is a base class for comma-separated headers, default is to disallow in trailers.
45
+ # @returns [Boolean] `false`, as most comma-separated headers should not appear in trailers by default.
46
+ def self.trailer?
47
+ false
48
+ end
49
+
43
50
  protected
44
51
 
45
52
  def reverse_find(&block)