Module: EndPointBlank::BaseUrl
- Defined in:
- lib/end_point_blank/base_url.rb
Overview
Resolves the base URL the caller used -- scheme, host and port -- from a Rack env.
This deliberately does not go through Rack::Request#host / #scheme / #port. Rack, Express, the servlet spec and Plug each resolve "host" differently (Rack takes the last X-Forwarded-Host hop, Express the first, WSGI and Plug neither), which is precisely why the same request produced five different answers across the five clients. The env is read directly so that this algorithm is the same one implemented in the JS, Python, Java and Elixir libraries.
Forwarded headers are honored when trust_proxy_headers is on, which it is
by default: host was already caller-controlled in every client (all five
read the Host header), so this opens no new hole for the field that matters
most, and taking the LAST hop means that behind a proxy that appends, the
value is the proxy's own observation rather than anything the caller
planted. A directly-exposed deployment -- where nothing sits in front to
overwrite the headers -- can set the flag to false, and then only the
connection and the Host header are consulted.
The flag arrives as an argument rather than being read from Configuration here, so that this module stays framework- and configuration-free and both states are directly testable.
Constant Summary collapse
- HOSTNAME =
/\A[a-z0-9._-]+\z/- IPV6 =
/\A\[[0-9a-f:.]+\]\z/- SCHEME =
/\A[a-z][a-z0-9+.-]{0,31}\z/- DEFAULT_PORTS =
{ "http" => 80, "https" => 443 }.freeze
Class Method Summary collapse
-
.clean_host(value) ⇒ Object
DNS caps a hostname at 253 characters, and the receiving column is varchar(255).
-
.clean_scheme(value) ⇒ Object
Normalize, then validate.
-
.from_rack_env(env, trust_proxy_headers: true) ⇒ Object
Returns a Hash carrying only the fields that resolved to a usable value.
-
.host_authority(env) ⇒ Object
The authority the caller named, for both resolution paths.
-
.hostname_from_rack_env(env) ⇒ Object
The hostname alone, for the authorize path.
-
.last_hop(value) ⇒ Object
A proxy that appends writes its own observation last.
-
.parse_port(value) ⇒ Object
Numeric validation only -- 1..65535, nothing scheme-aware.
-
.split_authority(value) ⇒ Object
"api.example.com:8443" -> ["api.example.com", "8443"] "[2001:db8::1]:8443" -> ["[2001:db8::1]", "8443"].
-
.usable_port(candidate, scheme) ⇒ Object
A port is reported only when it can be classified against a resolved scheme.
Class Method Details
.clean_host(value) ⇒ Object
DNS caps a hostname at 253 characters, and the receiving column is
varchar(255). No web-server adapter validates the length of
X-Forwarded-Host, so without this a caller could make the SDK report an
arbitrarily long value; dropped, not truncated, because a truncated
hostname is a plausible-looking WRONG one and the portal reads host
verbatim to assemble a base URL.
184 185 186 187 188 189 190 191 |
# File 'lib/end_point_blank/base_url.rb', line 184 def clean_host(value) return nil unless value.is_a?(String) host = value.strip.downcase return nil if host.empty? || host.bytesize > 253 host.match?(HOSTNAME) || host.match?(IPV6) ? host : nil end |
.clean_scheme(value) ⇒ Object
Normalize, then validate. "HTTPS" and "https:" both have to reach intake as "https": JS's location.protocol and Node's URL#protocol keep the colon, nothing pins the case, and intake never rewrites a stored row -- two spellings of the same scheme would split the dominant-triple grouping forever. delete_suffix removes one colon, not all of them, so "https::" still fails the shape check rather than sneaking through.
171 172 173 174 175 176 |
# File 'lib/end_point_blank/base_url.rb', line 171 def clean_scheme(value) return nil unless value.is_a?(String) scheme = value.strip.downcase.delete_suffix(":") scheme.match?(SCHEME) ? scheme : nil end |
.from_rack_env(env, trust_proxy_headers: true) ⇒ Object
Returns a Hash carrying only the fields that resolved to a usable value. A field that could not be resolved is absent, never nil: the receiver has to be able to tell "this SDK did not report a port" from "the port is null".
With trust_proxy_headers: false the three X-Forwarded-* headers are not read at all, so the request is never treated as proxied and the connection's scheme and port stay evidence.
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/end_point_blank/base_url.rb', line 43 def from_rack_env(env, trust_proxy_headers: true) return {} unless env.is_a?(Hash) forwarded_scheme = trust_proxy_headers ? clean_scheme(last_hop(env["HTTP_X_FORWARDED_PROTO"])) : nil forwarded_host_part, = trust_proxy_headers ? (last_hop(env["HTTP_X_FORWARDED_HOST"])) : [nil, nil] forwarded_host = clean_host(forwarded_host_part) forwarded_port = trust_proxy_headers ? parse_port(last_hop(env["HTTP_X_FORWARDED_PORT"])) : nil # Evidence is judged AFTER validation, not on raw header presence: a # malformed header (e.g. "X-Forwarded-Port: not-a-port") parses to # nothing and so must never count as proxy evidence, or an # unauthenticated caller could blank an otherwise-valid field just by # sending garbage. # # Only a forwarded scheme or port counts as evidence strong enough to # distrust the raw connection. A forwarded Host alone does not: some # proxies rewrite only the Host header and pass scheme/port through # unchanged, so a valid X-Forwarded-Host by itself says nothing about # whether the connection's own scheme/port belong to the proxy or the # caller. proxied = !forwarded_scheme.nil? || !forwarded_port.nil? # A malformed X-Forwarded-Host (wrong shape, or too long -- see # clean_host) gets the same treatment as a malformed proto or port: it # is ignored entirely and falls back to the direct Host header, exactly # as if the header were absent, rather than leaving host unresolved. host_part, = if forwarded_host [forwarded_host_part, ] else ((env)) end scheme = forwarded_scheme || (proxied ? nil : clean_scheme(env["rack.url_scheme"])) host = forwarded_host || clean_host(host_part) port_candidate = forwarded_port || parse_port() || (proxied ? nil : parse_port(env["SERVER_PORT"])) port = usable_port(port_candidate, scheme) resolved = {} resolved[:scheme] = scheme if scheme resolved[:host] = host if host resolved[:port] = port if port resolved end |
.host_authority(env) ⇒ Object
The authority the caller named, for both resolution paths.
An empty Host header is treated as ABSENT, not as a present-but-unusable
value. A caller that sends Host: with nothing after it has said nothing
about which host it meant, so there is nothing there to prefer over
SERVER_NAME. Falling through concedes no control the caller did not
already have either: SERVER_NAME is a server-side value, not one a
request can steer.
On the authorize path the alternative is worse than cosmetic. Resolving the host to nil there drops the request to Basic auth and skips the token mint entirely, where falling through yields a usable application-environment lookup key.
This CHANGES Ruby's behavior: an empty Host header used to resolve the
host to nil. env["HTTP_HOST"] || env["SERVER_NAME"] stops at "" because
"" is truthy in Ruby. Python, Java and JS already fell through, because
"" is falsy there; Ruby and Elixir stopped, because "" is truthy in both.
One expression written five times, diverging only on the empty case. It
now lives at one site per SDK, and this comment is why.
.to_s.empty? so a nil Host header takes the same branch as an empty one.
141 142 143 144 |
# File 'lib/end_point_blank/base_url.rb', line 141 def (env) host = env["HTTP_HOST"] host.to_s.empty? ? env["SERVER_NAME"] : host end |
.hostname_from_rack_env(env) ⇒ Object
The hostname alone, for the authorize path.
Deliberately NOT from_rack_env(env)[:host]: reads the Host header only,
never the forwarded chain, however trust_proxy_headers is set. The
value feeds target_hostname and the access-token cache key, and the
portal resolves an application environment from it -- a value matching
no registered row is a hard 422 with no fallback, not a cache miss.
Composed from the same split_authority/clean_host pair from_rack_env uses, so IPv6 bracketing, lowercasing, and shape and length validation are identical between the two; only the authority's source differs.
102 103 104 105 106 107 |
# File 'lib/end_point_blank/base_url.rb', line 102 def hostname_from_rack_env(env) return nil unless env.is_a?(Hash) host_part, = ((env)) clean_host(host_part) end |
.last_hop(value) ⇒ Object
A proxy that appends writes its own observation last. A proxy that overwrites (nginx, Caddy, ALB) emits one value, where first and last are the same thing.
112 113 114 115 116 117 |
# File 'lib/end_point_blank/base_url.rb', line 112 def last_hop(value) return nil unless value.is_a?(String) hops = value.split(",").map(&:strip).reject(&:empty?) hops.last end |
.parse_port(value) ⇒ Object
Numeric validation only -- 1..65535, nothing scheme-aware. Used both to decide whether a forwarded/authority port counts as a usable value and as a port candidate; default-port omission happens exactly once, in usable_port, against the FINAL resolved scheme, so it can never be skipped just because the scheme happened to resolve from a different source than the port did.
199 200 201 202 203 204 |
# File 'lib/end_point_blank/base_url.rb', line 199 def parse_port(value) port = Integer(value.to_s.strip, 10, exception: false) return nil if port.nil? || port < 1 || port > 65_535 port end |
.split_authority(value) ⇒ Object
"api.example.com:8443" -> ["api.example.com", "8443"] "[2001:db8::1]:8443" -> ["[2001:db8::1]", "8443"]
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/end_point_blank/base_url.rb', line 148 def (value) return [nil, nil] unless value.is_a?(String) = value.strip if .start_with?("[") head, bracket, tail = .partition("]") return [nil, nil] if bracket.empty? ["#{head}]", tail.start_with?(":") ? tail[1..] : nil] elsif .count(":") == 1 host, _, port = .partition(":") [host, port] else [, nil] end end |
.usable_port(candidate, scheme) ⇒ Object
A port is reported only when it can be classified against a resolved scheme. With no scheme, "default" is meaningless, so an unclassifiable port is withheld entirely rather than guessed at -- the same origin must never be reportable two ways depending on which headers happened to arrive.
211 212 213 214 215 216 |
# File 'lib/end_point_blank/base_url.rb', line 211 def usable_port(candidate, scheme) return nil if candidate.nil? || scheme.nil? return nil if DEFAULT_PORTS[scheme] == candidate candidate end |