Repository: rack/rack (Ruby)

Issue:
`Rack::Lint` `SERVER_NAME` and `HTTP_HOST` are insufficiently validated?
When we use `URI.parse("http://#{http_host}/")`, we assume that `http_host` holds a valid, sanitized hostname. However, `URI.parse` is fairly permissive in what it accepts. This can lead to unexpected (and potentially unsafe) behavior if `http_host` is user-controlled or not rigorously validated before parsing. Attackers can craft unusual or malicious values to manipulate the resulting URI in ways that are not obviously valid domain names. The same applies to `SERVER_NAME`.

Below is a condensed list of potentially unusual or invalid values that `URI.parse("http://#{string}/")` would still parse:

1. **Userinfo embedded**  
   - `user:pass@evil.com`  
   (Parses as `http://user:pass@evil.com/`)

2. **Port numbers and paths**  
   - `example.com:8080/path/to/endpoint`  
   (Parses as `http://example.com:8080/path/to/endpoint/`)

3. **Query or fragment**  
   - `example.com?foo=bar#frag`  
   (Parses as `http://example.com?foo=bar#frag/`)


Discussion: We can change the check to use `URI#host=`:

```
uri = URI("http://")
uri.host = "user:pass@evil.com" # URI::InvalidComponentError
# same for example.com:8080/path/to/endpoint
# same for example.com?foo=bar#frag

```
What about going direct to the source:

```
> URI::RFC3986_Parser::HOST
=> 
/
      (?<IP-literal>\[(?:
          (?<IPv6address>
            (?:\h{1,4}:){6}
            (?<ls32>\h{1,4}:\h{1,4}
            | (?<IPv4address>(?<dec-octet>[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]|\d)
                \.\g<dec-octet>\.\g<dec-octet>\.\g<dec-octet>)
            )
          | ::(?:\h{1,4}:){5}\g<ls32>
          | \h{1,4}?::(?:\h{1,4}:){4}\g<ls32>
          | (?:(?:\h{1,4}:)?\h{1,4})?::(?:\h{1,4}:){3}\g<ls32>
          | (?:(?:\h{1,4}:){,2}\h{1,4})?::(?:\h{1,4}:){2}\g<ls32>
          | (?:(?:\h{1,4}:){,3}\h{1,4})?::\h{1,4}:\g<ls32>
          | (?:(?:\h{1,4}:){,4}\h{1,4})?::\g<ls32>
          | (?:(?:\h{1,4}:){,5}\h{1,4})?::\h{1,4}
          | (?:(?:\h{1,4}:){,6}\h{1,4})?::
          )
        | (?<IPvFuture>v\h++\.[!$&-.0-9:;=A-Z_a-z~]++)
        )\])
    | \g<IPv4address>
    | (?<reg-name>(?:%\h\h|[!$&-.0-9;=A-Z_a-z~])*+)
    /x
```

from <https://github.com/ruby/uri/blob/master/lib/uri/rfc3986_parser.rb#L2-L26>.

e.g.

```
unless http_host =~ URI::RFC3986_Parser::HOST
  ...
```

Might need to add some `\A` and `\z`.
That approach is better (once `\A` and `\z` are used), though I would use `match?` instead of `=~`.
I did a bit more research on this.

In principle, the authority (HTTP/1 `host` header, and HTTP/2+ `:authority` pseudo header) includes user info, host name, and port: https://datatracker.ietf.org/doc/html/rfc3986#section-3.2

For HTTP, using RFC 9110 as the canonical source for semantics:

https://www.rfc-editor.org/rfc/rfc9110#section-7.2

> The "Host" header field in a request provides the host and port information from the target URI, enabling the origin server to distinguish among resources while servicing requests for multiple host names.

> In HTTP/2 [[HTTP/2](https://www.rfc-editor.org/rfc/rfc9110#HTTP2)] and HTTP/3 [[HTTP/3](https://www.rfc-editor.org/rfc/rfc9110#HTTP3)], the Host header field is, in some cases, supplanted by the ":authority" pseudo-header field of a request's control data.

```
  Host = uri-host [ ":" port ] ; Section 4
```

Clarified later:

```
uri-host = <host, see [URI], Section 3.2.2>
```

Going back to RFC 3986 <https://datatracker.ietf.org/doc/html/rfc3986#section-3.2>:

```
authority   = [ userinfo "@" ] host [ ":" port ]
```

But we must refer to section 3.2.2 <https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2> - which aligns with `URI::RFC3986_Parser::HOST`.

In other words, I think we need to accept host and port, but not userinfo.

The current spec allows userinfo in the HTTP_HOST and SERVER_NAME (and of course all the other unexpected variants).

I think that including user info in HTTP_HOST is not valid, from a server point of view, but it might be nice to confirm that, what we intend for the specification is:

- `SERVER_NAME` is supposed to be only a host - the port should be in `SERVER_PORT`
- `HTTP_HOST` is the `host[:port]` part of the authority.
- neither of them contain user info.

Does that makes sense?

Ah, found some more clarifications from RFC 9113 <https://datatracker.ietf.org/doc/html/rfc9113#section-8.3.1>

> The ":authority" pseudo-header field conveys the authority portion ([Section 3.2](https://www.rfc-editor.org/rfc/rfc3986#section-3.2) of [[RFC3986](https://datatracker.ietf.org/doc/html/rfc3986)]) of the target URI ([Section 7.1](https://www.rfc-editor.org/rfc/rfc9110#section-7.1) of [[HTTP](https://datatracker.ietf.org/doc/html/rfc9110)]). The recipient of an HTTP/2 request MUST NOT use the Host header field to determine the target URI if ":authority" is present.

AND

> ":authority" MUST NOT include the deprecated userinfo subcomponent for "http" or "https" schemed URIs.

So, I think we can assume the user info cannot be part of `HTTP_HOST`.



Fix (patch):
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3d13440d..2cd91f869 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@ All notable changes to this project will be documented in this file. For info on
 - `Rack::RewindableInput::Middleware` no longer wraps a nil input. ([#2259](https://github.com/rack/rack/pull/2259), [@tt](https://github.com/tt))
 - Fix `NoMethodError` in `Rack::Request#wrap_ipv6` when `x-forwarded-host` is empty. ([#2270](https://github.com/rack/rack/pull/2270), [@oieioi](https://github.com/oieioi))
 - Fix the specification for `SERVER_PORT` which was incorrectly documented as required to be an `Integer` if present - it must be a `String` containing digits only. ([#2296](https://github.com/rack/rack/pull/2296), [@ioquatix])
+- `SERVER_NAME` and `HTTP_HOST` are now more strictly validated according to the relevant specifications. ([#2298](https://github.com/rack/rack/pull/2298), [@ioquatix])
 
 ## [3.1.10] - 2025-02-12
 
diff --git a/SPEC.rdoc b/SPEC.rdoc
index e8d67b0bb..491cfae25 100644
--- a/SPEC.rdoc
+++ b/SPEC.rdoc
@@ -116,8 +116,8 @@ is reserved for use with the Rack core distribution and other
 accepted specifications and must not be used otherwise.
 
 The <tt>SERVER_PORT</tt>, if given, must consist of digits only.
-The <tt>SERVER_NAME</tt> must be a valid authority as defined by RFC7540.
-The <tt>HTTP_HOST</tt> must be a valid authority as defined by RFC7540.
+The <tt>SERVER_NAME</tt> must be a valid host as defined by RFC3986.
+The <tt>HTTP_HOST</tt> must be a valid authority as defined by RFC9110.
 The <tt>SERVER_PROTOCOL</tt> must match the regexp <tt>HTTP/\d(\.\d)?</tt>.
 The environment must not contain the keys
 <tt>HTTP_CONTENT_TYPE</tt> or <tt>HTTP_CONTENT_LENGTH</tt>
diff --git a/lib/rack/lint.rb b/lib/rack/lint.rb
index 81fc0c7e3..e9fd2c3d7 100644
--- a/lib/rack/lint.rb
+++ b/lib/rack/lint.rb
@@ -1,7 +1,6 @@
 # frozen_string_literal: true
 
 require 'forwardable'
-require 'uri'
 
 require_relative 'constants'
 require_relative 'utils'
@@ -18,6 +17,35 @@ class Lint
     REQUEST_PATH_AUTHORITY_FORM = /\A[^\/:]+:\d+\z/
     REQUEST_PATH_ASTERISK_FORM = '*'
 
+    # Match a host name, according to RFC3986.
+    # Copied from `URI::RFC3986_Parser::HOST` because older Ruby versions (< 3.3) don't expose it.
+    HOST_PATTERN = /
+      (?<IP-literal>\[(?:
+          (?<IPv6address>
+            (?:\h{1,4}:){6}
+            (?<ls32>\h{1,4}:\h{1,4}
+            | (?<IPv4address>(?<dec-octet>[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]|\d)
+                \.\g<dec-octet>\.\g<dec-octet>\.\g<dec-octet>)
+            )
+          | ::(?:\h{1,4}:){5}\g<ls32>
+          | \h{1,4}?::(?:\h{1,4}:){4}\g<ls32>
+          | (?:(?:\h{1,4}:)?\h{1,4})?::(?:\h{1,4}:){3}\g<ls32>
+          | (?:(?:\h{1,4}:){,2}\h{1,4})?::(?:\h{1,4}:){2}\g<ls32>
+          | (?:(?:\h{1,4}:){,3}\h{1,4})?::\h{1,4}:\g<ls32>
+          | (?:(?:\h{1,4}:){,4}\h{1,4})?::\g<ls32>
+          | (?:(?:\h{1,4}:){,5}\h{1,4})?::\h{1,4}
+          | (?:(?:\h{1,4}:){,6}\h{1,4})?::
+          )
+        | (?<IPvFuture>v\h++\.[!$&-.0-9:;=A-Z_a-z~]++)
+        )\])
+    | \g<IPv4address>
+    | (?<reg-name>(?:%\h\h|[!$&-.0-9;=A-Z_a-z~])*+)
+    /x.freeze
+    SERVER_NAME_PATTERN = /\A#{HOST_PATTERN}\z/.freeze
+    HTTP_HOST_PATTERN = /\A#{HOST_PATTERN}(:\d*+)?\z/.freeze
+
+    private_constant :HOST_PATTERN, :SERVER_NAME_PATTERN, :HTTP_HOST_PATTERN
+
     # :stopdoc:
 
     class LintError < RuntimeError; end
@@ -320,16 +348,16 @@ def check_environment(env)
           end
         end
 
-        ## The <tt>SERVER_NAME</tt> must be a valid authority as defined by RFC7540.
+        ## The <tt>SERVER_NAME</tt> must be a valid host as defined by RFC3986.
         server_name = assert_required(SERVER_NAME)
-        unless (URI.parse("http://#{server_name}/") rescue false)
-          raise LintError, "#{server_name} must be a valid authority"
+        unless server_name.match?(SERVER_NAME_PATTERN)
+          raise LintError, "env[SERVER_NAME] must be a valid host"
         end
 
-        ## The <tt>HTTP_HOST</tt> must be a valid authority as defined by RFC7540.
+        ## The <tt>HTTP_HOST</tt> must be a valid authority as defined by RFC9110.
         if http_host = env[HTTP_HOST]
-          unless (URI.parse("http://#{http_host}/") rescue false)
-            raise LintError, "#{http_host} must be a valid authority"
+          unless http_host.match?(HTTP_HOST_PATTERN)
+            raise LintError, "env[HTTP_HOST] must be a valid authority"
           end
         end