Home Assistant Unofficial Reference 2024.12.1
forwarded.py
Go to the documentation of this file.
1 """Middleware to handle forwarded data by a reverse proxy."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Awaitable, Callable
6 from ipaddress import IPv4Network, IPv6Network, ip_address
7 import logging
8 
9 from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO
10 from aiohttp.web import Application, HTTPBadRequest, Request, StreamResponse, middleware
11 from hass_nabucasa import remote
12 
13 from homeassistant.core import callback
14 
15 _LOGGER = logging.getLogger(__name__)
16 
17 
18 @callback
20  app: Application,
21  use_x_forwarded_for: bool | None,
22  trusted_proxies: list[IPv4Network | IPv6Network],
23 ) -> None:
24  """Create forwarded middleware for the app.
25 
26  Process IP addresses, proto and host information in the forwarded for headers.
27 
28  `X-Forwarded-For: <client>, <proxy1>, <proxy2>`
29  e.g., `X-Forwarded-For: 203.0.113.195, 70.41.3.18, 150.172.238.178`
30 
31  We go through the list from the right side, and skip all entries that are in our
32  trusted proxies list. The first non-trusted IP is used as the client IP. If all
33  items in the X-Forwarded-For are trusted, including the most left item (client),
34  the most left item is used. In the latter case, the client connection originated
35  from an IP that is also listed as a trusted proxy IP or network.
36 
37  `X-Forwarded-Proto: <client>, <proxy1>, <proxy2>`
38  e.g., `X-Forwarded-Proto: https, http, http`
39  OR `X-Forwarded-Proto: https` (one entry, even with multiple proxies)
40 
41  The X-Forwarded-Proto is determined based on the corresponding entry of the
42  X-Forwarded-For header that is used/chosen as the client IP. However,
43  some proxies, for example, Kubernetes NGINX ingress, only retain one element
44  in the X-Forwarded-Proto header. In that case, we'll just use what we have.
45 
46  `X-Forwarded-Host: <host>`
47  e.g., `X-Forwarded-Host: example.com`
48 
49  If the previous headers are processed successfully, and the X-Forwarded-Host is
50  present, it will be used.
51 
52  Additionally:
53  - If no X-Forwarded-For header is found, the processing of all headers is skipped.
54  - Throw HTTP 400 status when untrusted connected peer provides
55  X-Forwarded-For headers.
56  - If multiple instances of X-Forwarded-For, X-Forwarded-Proto or
57  X-Forwarded-Host are found, an HTTP 400 status code is thrown.
58  - If malformed or invalid (IP) data in X-Forwarded-For header is found,
59  an HTTP 400 status code is thrown.
60  - The connected client peer on the socket of the incoming connection,
61  must be trusted for any processing to take place.
62  - If the number of elements in X-Forwarded-Proto does not equal 1 or
63  is equal to the number of elements in X-Forwarded-For, an HTTP 400
64  status code is thrown.
65  - If an empty X-Forwarded-Host is provided, an HTTP 400 status code is thrown.
66  - If an empty X-Forwarded-Proto is provided, or an empty element in the list,
67  an HTTP 400 status code is thrown.
68  """
69 
70  @middleware
71  async def forwarded_middleware(
72  request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
73  ) -> StreamResponse:
74  """Process forwarded data by a reverse proxy."""
75  # Skip requests from Remote UI
76  if remote.is_cloud_request.get():
77  return await handler(request)
78 
79  # Handle X-Forwarded-For
80  forwarded_for_headers: list[str] = request.headers.getall(X_FORWARDED_FOR, [])
81  if not forwarded_for_headers:
82  # No forwarding headers, continue as normal
83  return await handler(request)
84 
85  # Get connected IP
86  if (
87  request.transport is None
88  or request.transport.get_extra_info("peername") is None
89  ):
90  # Connected IP isn't retrieveable from the request transport, continue
91  return await handler(request)
92 
93  connected_ip = ip_address(request.transport.get_extra_info("peername")[0])
94 
95  # We have X-Forwarded-For, but config does not agree
96  if not use_x_forwarded_for:
97  _LOGGER.error(
98  (
99  "A request from a reverse proxy was received from %s, but your "
100  "HTTP integration is not set-up for reverse proxies"
101  ),
102  connected_ip,
103  )
104  raise HTTPBadRequest
105 
106  # Ensure the IP of the connected peer is trusted
107  if not any(connected_ip in trusted_proxy for trusted_proxy in trusted_proxies):
108  _LOGGER.error(
109  "Received X-Forwarded-For header from an untrusted proxy %s",
110  connected_ip,
111  )
112  raise HTTPBadRequest
113 
114  # Multiple X-Forwarded-For headers
115  if len(forwarded_for_headers) > 1:
116  _LOGGER.error(
117  "Too many headers for X-Forwarded-For: %s", forwarded_for_headers
118  )
119  raise HTTPBadRequest
120 
121  # Process X-Forwarded-For from the right side (by reversing the list)
122  forwarded_for_split = list(reversed(forwarded_for_headers[0].split(",")))
123  try:
124  forwarded_for = [ip_address(addr.strip()) for addr in forwarded_for_split]
125  except ValueError as err:
126  _LOGGER.error(
127  "Invalid IP address in X-Forwarded-For: %s", forwarded_for_headers[0]
128  )
129  raise HTTPBadRequest from err
130 
131  overrides: dict[str, str] = {}
132 
133  # Find the last trusted index in the X-Forwarded-For list
134  forwarded_for_index = 0
135  for forwarded_ip in forwarded_for:
136  if any(forwarded_ip in trusted_proxy for trusted_proxy in trusted_proxies):
137  forwarded_for_index += 1
138  continue
139  overrides["remote"] = str(forwarded_ip)
140  break
141  else:
142  # If all the IP addresses are from trusted networks, take the left-most.
143  forwarded_for_index = -1
144  overrides["remote"] = str(forwarded_for[-1])
145 
146  # Handle X-Forwarded-Proto
147  forwarded_proto_headers: list[str] = request.headers.getall(
148  X_FORWARDED_PROTO, []
149  )
150  if forwarded_proto_headers:
151  if len(forwarded_proto_headers) > 1:
152  _LOGGER.error(
153  "Too many headers for X-Forward-Proto: %s", forwarded_proto_headers
154  )
155  raise HTTPBadRequest
156 
157  forwarded_proto_split = list(
158  reversed(forwarded_proto_headers[0].split(","))
159  )
160  forwarded_proto = [proto.strip() for proto in forwarded_proto_split]
161 
162  # Catch empty values
163  if "" in forwarded_proto:
164  _LOGGER.error(
165  "Empty item received in X-Forward-Proto header: %s",
166  forwarded_proto_headers[0],
167  )
168  raise HTTPBadRequest
169 
170  # The X-Forwarded-Proto contains either one element, or the equals number
171  # of elements as X-Forwarded-For
172  if len(forwarded_proto) not in (1, len(forwarded_for)):
173  _LOGGER.error(
174  (
175  "Incorrect number of elements in X-Forward-Proto. Expected 1 or"
176  " %d, got %d: %s"
177  ),
178  len(forwarded_for),
179  len(forwarded_proto),
180  forwarded_proto_headers[0],
181  )
182  raise HTTPBadRequest
183 
184  # Ideally this should take the scheme corresponding to the entry
185  # in X-Forwarded-For that was chosen, but some proxies only retain
186  # one element. In that case, use what we have.
187  overrides["scheme"] = forwarded_proto[-1]
188  if len(forwarded_proto) != 1:
189  overrides["scheme"] = forwarded_proto[forwarded_for_index]
190 
191  # Handle X-Forwarded-Host
192  forwarded_host_headers: list[str] = request.headers.getall(X_FORWARDED_HOST, [])
193  if forwarded_host_headers:
194  # Multiple X-Forwarded-Host headers
195  if len(forwarded_host_headers) > 1:
196  _LOGGER.error(
197  "Too many headers for X-Forwarded-Host: %s", forwarded_host_headers
198  )
199  raise HTTPBadRequest
200 
201  forwarded_host = forwarded_host_headers[0].strip()
202  if not forwarded_host:
203  _LOGGER.error("Empty value received in X-Forward-Host header")
204  raise HTTPBadRequest
205 
206  overrides["host"] = forwarded_host
207 
208  # Done, create a new request based on gathered data.
209  request = request.clone(**overrides) # type: ignore[arg-type]
210  return await handler(request)
211 
212  app.middlewares.append(forwarded_middleware)
None async_setup_forwarded(Application app, bool|None use_x_forwarded_for, list[IPv4Network|IPv6Network] trusted_proxies)
Definition: forwarded.py:23