1from __future__ import annotations
2
3import re
4import typing as t
5from urllib.parse import quote
6
7from .._internal import _plain_int
8from ..exceptions import SecurityError
9from ..http import parse_set_header
10from ..urls import uri_to_iri
11
12_host_re = re.compile(
13 r"""
14 (
15 [a-z0-9.-]+ # domain or ipv4
16 |
17 \[[a-f0-9]*:[a-f0-9.:]+] # ipv6
18 )
19 (?::([1-9][0-9]{,4}))? # optional port
20 """,
21 flags=re.ASCII | re.IGNORECASE | re.VERBOSE,
22)
23
24
25def host_is_trusted(
26 hostname: str | None, trusted_list: t.Collection[str] | None = None
27) -> bool:
28 """Perform some checks on a ``Host`` header ``host:port``. The host must be
29 made up of valid characters, but this does not check validity beyond that.
30 If a list of trusted domains is given, the domain must match one.
31
32 :param hostname: The ``Host`` header ``host:port`` to check.
33 :param trusted_list: A list of trusted domains to match. These should
34 already be IDNA encoded, but will be encoded if needed. The port is
35 ignored for this check. If a name starts with a dot it will match as a
36 suffix, accepting all subdomains. If empty or ``None``, all domains are
37 allowed.
38
39 .. versionchanged:: 3.2
40 The value's characters are validated.
41
42 .. versionchanged:: 3.2
43 ``trusted_list`` defaults to ``None``.
44
45 .. versionadded:: 0.9
46 """
47 if not hostname:
48 return False
49
50 if (m := _host_re.fullmatch(hostname)) is None:
51 return False
52
53 hostname, port_str = m.groups()
54
55 if port_str and not (1 <= int(port_str) <= 65535):
56 return False
57
58 if not trusted_list:
59 return True
60
61 if isinstance(trusted_list, str):
62 trusted_list = [trusted_list]
63
64 for ref in trusted_list:
65 if ref.startswith("."):
66 ref = ref[1:]
67 suffix_match = True
68 else:
69 suffix_match = False
70
71 try:
72 ref = ref.partition(":")[0].encode("idna").decode("ascii")
73 except UnicodeEncodeError:
74 return False
75
76 if ref == hostname or (suffix_match and hostname.endswith(f".{ref}")):
77 return True
78
79 return False
80
81
82def get_host(
83 scheme: str,
84 host_header: str | None,
85 server: tuple[str, int | None] | None = None,
86 trusted_hosts: t.Collection[str] | None = None,
87) -> str:
88 """Get and validate a request's ``host:port`` based on the given values.
89
90 The ``Host`` header sent by the client is preferred. Otherwise, the server's
91 configured address is used. The port is omitted if it matches the standard
92 HTTP or HTTPS ports.
93
94 The value is passed through :func:`host_is_trusted`. The host must be made
95 up of valid characters, but this does not check validity beyond that. If a
96 list of trusted domains is given, the domain must match one.
97
98 If the host header is not available, such as for HTTP/0.9 and 1.0, or it has
99 invalid characters, the empty string is returned. Subdomain and host
100 routing, and external URL building, will not work in these cases.
101
102 :param scheme: The protocol of the request. Used to omit the standard ports
103 80 and 443.
104 :param host_header: The ``Host`` header value.
105 :param server: The server's configured address ``(host, port)``. The server
106 may be using a Unix socket and give ``(path, None)``; this is ignored as
107 it would not produce a useful host value.
108 :param trusted_hosts: A list of trusted domains to match. These should
109 already be IDNA encoded, but will be encoded if needed. The port is
110 ignored for this check. If a name starts with a dot it will match as a
111 suffix, accepting all subdomains. If empty or ``None``, all domains are
112 allowed.
113
114 :return: Host, with port if necessary.
115 :raise .SecurityError: If the host is not trusted.
116
117 .. versionchanged:: 3.1.8
118 The empty string is again returned if no host header value is available,
119 or if the characters are invalid.
120
121 .. versionchanged:: 3.1.7
122 The characters of the host value are validated. The empty string is no
123 longer allowed if no header value is available.
124
125 .. versionchanged:: 3.2
126 When using the server address, Unix sockets are ignored.
127
128 .. versionchanged:: 3.1.3
129 If ``SERVER_NAME`` is IPv6, it is wrapped in ``[]``.
130 """
131 if host_header is not None:
132 host = host_header
133 # The port server[1] will be None for a Unix socket. Ignore in that case.
134 elif server is not None and server[1] is not None:
135 host = server[0]
136
137 # If SERVER_NAME is IPv6, wrap it in [] to match Host header.
138 # Check for : because domain or IPv4 can't have that.
139 if ":" in host and host[0] != "[":
140 host = f"[{host}]"
141
142 host = f"{host}:{server[1]}"
143 else:
144 # Pass through empty host from HTTP/0.9 and 1.0.
145 return ""
146
147 if scheme in {"http", "ws"}:
148 host = host.removesuffix(":80")
149 elif scheme in {"https", "wss"}:
150 host = host.removesuffix(":443")
151
152 if not host_is_trusted(host, trusted_hosts):
153 if trusted_hosts:
154 raise SecurityError(f"Host {host!r} is not trusted.")
155
156 # Invalid characters, treat as empty.
157 return ""
158
159 return host
160
161
162def get_current_url(
163 scheme: str,
164 host: str,
165 root_path: str | None = None,
166 path: str | None = None,
167 query_string: bytes | None = None,
168) -> str:
169 """Recreate the URL for a request. If an optional part isn't
170 provided, it and subsequent parts are not included in the URL.
171
172 The URL is an IRI, not a URI, so it may contain Unicode characters.
173 Use :func:`~werkzeug.urls.iri_to_uri` to convert it to ASCII.
174
175 :param scheme: The protocol the request used, like ``"https"``.
176 :param host: The host the request was made to. See :func:`get_host`.
177 :param root_path: Prefix that the application is mounted under. This
178 is prepended to ``path``.
179 :param path: The path part of the URL after ``root_path``.
180 :param query_string: The portion of the URL after the "?".
181 """
182 url = [scheme, "://", host]
183
184 if root_path is None:
185 url.append("/")
186 return uri_to_iri("".join(url))
187
188 # safe = https://url.spec.whatwg.org/#url-path-segment-string
189 # as well as percent for things that are already quoted
190 url.append(quote(root_path.rstrip("/"), safe="!$&'()*+,/:;=@%"))
191 url.append("/")
192
193 if path is None:
194 return uri_to_iri("".join(url))
195
196 url.append(quote(path.lstrip("/"), safe="!$&'()*+,/:;=@%"))
197
198 if query_string:
199 url.append("?")
200 url.append(quote(query_string, safe="!$&'()*+,/:;=?@%"))
201
202 return uri_to_iri("".join(url))
203
204
205def get_content_length(
206 http_content_length: str | None = None,
207 http_transfer_encoding: str | None = None,
208) -> int | None:
209 """Return the ``Content-Length`` header value as an int. If the header is not given
210 or the ``Transfer-Encoding`` header is ``chunked``, ``None`` is returned to indicate
211 a streaming request. If the value is not an integer, or negative, 0 is returned.
212
213 :param http_content_length: The Content-Length HTTP header.
214 :param http_transfer_encoding: The Transfer-Encoding HTTP header.
215
216 .. versionadded:: 2.2
217 """
218 if (
219 http_transfer_encoding is not None
220 and "chunked" in parse_set_header(http_transfer_encoding)
221 ) or http_content_length is None:
222 return None
223
224 try:
225 return max(0, _plain_int(http_content_length))
226 except ValueError:
227 return 0