plain 0.64.0__py3-none-any.whl → 0.65.1__py3-none-any.whl

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.
plain/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.65.1](https://github.com/dropseed/plain/releases/plain@0.65.1) (2025-09-22)
4
+
5
+ ### What's changed
6
+
7
+ - Fixed DisallowedHost exception handling in request span attributes to prevent telemetry errors ([bcc0005](https://github.com/dropseed/plain/commit/bcc000575b))
8
+ - Removed cached property optimization for scheme/host to improve request processing reliability ([3a52690](https://github.com/dropseed/plain/commit/3a52690d47))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
14
+ ## [0.65.0](https://github.com/dropseed/plain/releases/plain@0.65.0) (2025-09-22)
15
+
16
+ ### What's changed
17
+
18
+ - Added CIDR notation support to `ALLOWED_HOSTS` for IP address range validation ([c485d21](https://github.com/dropseed/plain/commit/c485d21a8b))
19
+
20
+ ### Upgrade instructions
21
+
22
+ - No changes required
23
+
3
24
  ## [0.64.0](https://github.com/dropseed/plain/releases/plain@0.64.0) (2025-09-19)
4
25
 
5
26
  ### What's changed
plain/http/hosts.py ADDED
@@ -0,0 +1,147 @@
1
+ """
2
+ Host validation utilities for ALLOWED_HOSTS functionality.
3
+
4
+ This module provides functions for validating hosts against allowed patterns,
5
+ including domain patterns, wildcards, and CIDR notation for IP ranges.
6
+ """
7
+
8
+ import ipaddress
9
+
10
+ from plain.utils.regex_helper import _lazy_re_compile
11
+
12
+ host_validation_re = _lazy_re_compile(
13
+ r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:[0-9]+)?$"
14
+ )
15
+
16
+
17
+ def split_domain_port(host: str) -> tuple[str, str]:
18
+ """
19
+ Return a (domain, port) tuple from a given host.
20
+
21
+ Returned domain is lowercased. If the host is invalid, the domain will be
22
+ empty.
23
+ """
24
+ host = host.lower()
25
+
26
+ if not host_validation_re.match(host):
27
+ return "", ""
28
+
29
+ if host[-1] == "]":
30
+ # It's an IPv6 address without a port.
31
+ return host, ""
32
+ bits = host.rsplit(":", 1)
33
+ domain, port = bits if len(bits) == 2 else (bits[0], "")
34
+ # Remove a trailing dot (if present) from the domain.
35
+ domain = domain.removesuffix(".")
36
+ return domain, port
37
+
38
+
39
+ def _is_same_domain(host: str, pattern: str) -> bool:
40
+ """
41
+ Return ``True`` if the host is either an exact match or a match
42
+ to the wildcard pattern.
43
+
44
+ Any pattern beginning with a period matches a domain and all of its
45
+ subdomains. (e.g. ``.example.com`` matches ``example.com`` and
46
+ ``foo.example.com``). Anything else is an exact string match.
47
+ """
48
+ if not pattern:
49
+ return False
50
+
51
+ pattern = pattern.lower()
52
+ return (
53
+ pattern[0] == "."
54
+ and (host.endswith(pattern) or host == pattern[1:])
55
+ or pattern == host
56
+ )
57
+
58
+
59
+ def _parse_ip_address(
60
+ host: str,
61
+ ) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
62
+ """
63
+ Parse a host string as an IP address (IPv4 or IPv6).
64
+
65
+ Returns the ipaddress.ip_address object if valid, None otherwise.
66
+ Handles both bracketed and non-bracketed IPv6 addresses.
67
+ """
68
+ # Remove brackets from IPv6 addresses
69
+ if host.startswith("[") and host.endswith("]"):
70
+ host = host[1:-1]
71
+
72
+ try:
73
+ return ipaddress.ip_address(host)
74
+ except ValueError:
75
+ return None
76
+
77
+
78
+ def _parse_cidr_pattern(
79
+ pattern: str,
80
+ ) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None:
81
+ """
82
+ Parse a CIDR pattern and return the network object if valid.
83
+
84
+ Returns the ipaddress.ip_network object if valid CIDR notation, None otherwise.
85
+ """
86
+ # Check if it contains a slash (required for CIDR)
87
+ if "/" not in pattern:
88
+ return None
89
+
90
+ # Remove brackets from IPv6 CIDR patterns
91
+ test_pattern = pattern
92
+ if pattern.startswith("[") and "]/" in pattern:
93
+ # Handle format like [2001:db8::]/32
94
+ bracket_end = pattern.find("]/")
95
+ if bracket_end != -1:
96
+ ip_part = pattern[1:bracket_end]
97
+ cidr_part = pattern[bracket_end + 2 :]
98
+ test_pattern = f"{ip_part}/{cidr_part}"
99
+ elif pattern.startswith("[") and pattern.endswith("]") and "/" in pattern:
100
+ # Handle format like [2001:db8::/32] (slash inside brackets)
101
+ test_pattern = pattern[1:-1]
102
+
103
+ try:
104
+ return ipaddress.ip_network(test_pattern, strict=False)
105
+ except ValueError:
106
+ return None
107
+
108
+
109
+ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
110
+ """
111
+ Validate the given host for this site.
112
+
113
+ Check that the host looks valid and matches a host or host pattern in the
114
+ given list of ``allowed_hosts``. Supported patterns:
115
+
116
+ - ``*`` matches anything
117
+ - ``.example.com`` matches a domain and all its subdomains
118
+ (e.g. ``example.com`` and ``sub.example.com``)
119
+ - ``example.com`` matches exactly that domain
120
+ - ``192.168.1.0/24`` matches IP addresses in that CIDR range
121
+ - ``[2001:db8::]/32`` matches IPv6 addresses in that CIDR range
122
+ - ``192.168.1.1`` matches that exact IP address
123
+
124
+ Note: This function assumes that the given host is lowercased and has
125
+ already had the port, if any, stripped off.
126
+
127
+ Return ``True`` for a valid host, ``False`` otherwise.
128
+ """
129
+ # Parse the host as an IP address if possible
130
+ host_ip = _parse_ip_address(host)
131
+
132
+ for pattern in allowed_hosts:
133
+ # Wildcard matches everything
134
+ if pattern == "*":
135
+ return True
136
+
137
+ # Check CIDR notation patterns using walrus operator
138
+ if network := _parse_cidr_pattern(pattern):
139
+ if host_ip and host_ip in network:
140
+ return True
141
+ continue
142
+
143
+ # For non-CIDR patterns, use existing domain matching logic
144
+ if _is_same_domain(host, pattern):
145
+ return True
146
+
147
+ return False
plain/http/request.py CHANGED
@@ -27,12 +27,9 @@ from plain.utils.datastructures import (
27
27
  MultiValueDict,
28
28
  )
29
29
  from plain.utils.encoding import iri_to_uri
30
- from plain.utils.http import is_same_domain, parse_header_parameters
31
- from plain.utils.regex_helper import _lazy_re_compile
30
+ from plain.utils.http import parse_header_parameters
32
31
 
33
- host_validation_re = _lazy_re_compile(
34
- r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:[0-9]+)?$"
35
- )
32
+ from .hosts import split_domain_port, validate_host
36
33
 
37
34
 
38
35
  class UnreadablePostError(OSError):
@@ -223,6 +220,8 @@ class HttpRequest:
223
220
  location = str(location)
224
221
  bits = urlsplit(location)
225
222
  if not (bits.scheme and bits.netloc):
223
+ current_scheme_host = f"{self.scheme}://{self.get_host()}"
224
+
226
225
  # Handle the simple, most common case. If the location is absolute
227
226
  # and a scheme or host (netloc) isn't provided, skip an expensive
228
227
  # urljoin() as long as no path segments are '.' or '..'.
@@ -236,17 +235,14 @@ class HttpRequest:
236
235
  # If location starts with '//' but has no netloc, reuse the
237
236
  # schema and netloc from the current request. Strip the double
238
237
  # slashes and continue as if it wasn't specified.
239
- location = self._current_scheme_host + location.removeprefix("//")
238
+ location = current_scheme_host + location.removeprefix("//")
240
239
  else:
241
240
  # Join the constructed URL with the provided location, which
242
241
  # allows the provided location to apply query strings to the
243
242
  # base path.
244
- location = urljoin(self._current_scheme_host + self.path, location)
245
- return iri_to_uri(location)
243
+ location = urljoin(current_scheme_host + self.path, location)
246
244
 
247
- @cached_property
248
- def _current_scheme_host(self):
249
- return f"{self.scheme}://{self.get_host()}"
245
+ return iri_to_uri(location)
250
246
 
251
247
  def _get_scheme(self):
252
248
  """
@@ -702,47 +698,5 @@ def bytes_to_text(s, encoding):
702
698
  return s
703
699
 
704
700
 
705
- def split_domain_port(host):
706
- """
707
- Return a (domain, port) tuple from a given host.
708
-
709
- Returned domain is lowercased. If the host is invalid, the domain will be
710
- empty.
711
- """
712
- host = host.lower()
713
-
714
- if not host_validation_re.match(host):
715
- return "", ""
716
-
717
- if host[-1] == "]":
718
- # It's an IPv6 address without a port.
719
- return host, ""
720
- bits = host.rsplit(":", 1)
721
- domain, port = bits if len(bits) == 2 else (bits[0], "")
722
- # Remove a trailing dot (if present) from the domain.
723
- domain = domain.removesuffix(".")
724
- return domain, port
725
-
726
-
727
- def validate_host(host, allowed_hosts):
728
- """
729
- Validate the given host for this site.
730
-
731
- Check that the host looks valid and matches a host or host pattern in the
732
- given list of ``allowed_hosts``. Any pattern beginning with a period
733
- matches a domain and all its subdomains (e.g. ``.example.com`` matches
734
- ``example.com`` and any subdomain), ``*`` matches anything, and anything
735
- else must match exactly.
736
-
737
- Note: This function assumes that the given host is lowercased and has
738
- already had the port, if any, stripped off.
739
-
740
- Return ``True`` for a valid host, ``False`` otherwise.
741
- """
742
- return any(
743
- pattern == "*" or is_same_domain(host, pattern) for pattern in allowed_hosts
744
- )
745
-
746
-
747
701
  def parse_accept_header(header):
748
702
  return [MediaType(token) for token in header.split(",") if token.strip()]
@@ -4,7 +4,7 @@ import types
4
4
  from opentelemetry import baggage, trace
5
5
  from opentelemetry.semconv.attributes import http_attributes, url_attributes
6
6
 
7
- from plain.exceptions import ImproperlyConfigured
7
+ from plain.exceptions import DisallowedHost, ImproperlyConfigured
8
8
  from plain.logs.utils import log_response
9
9
  from plain.runtime import settings
10
10
  from plain.urls import get_resolver
@@ -78,6 +78,10 @@ class BaseHandler:
78
78
  except KeyError:
79
79
  # Missing required WSGI environment variables (e.g. in tests)
80
80
  pass
81
+ except DisallowedHost:
82
+ # Invalid host header - skip URL_FULL for telemetry but let the
83
+ # exception be handled normally by middleware for proper 400 response
84
+ pass
81
85
 
82
86
  # Add query string if present
83
87
  if query_string := request.meta.get("QUERY_STRING"):
@@ -24,6 +24,7 @@ URLS_ROUTER: str
24
24
 
25
25
  # Hosts/domain names that are valid for this site.
26
26
  # "*" matches anything, ".example.com" matches example.com and all subdomains
27
+ # "192.168.1.0/24" matches IP addresses in that CIDR range
27
28
  ALLOWED_HOSTS: list[str] = []
28
29
 
29
30
  # Default headers for all responses.
plain/utils/http.py CHANGED
@@ -92,26 +92,6 @@ def int_to_base36(i):
92
92
  return b36
93
93
 
94
94
 
95
- def is_same_domain(host, pattern):
96
- """
97
- Return ``True`` if the host is either an exact match or a match
98
- to the wildcard pattern.
99
-
100
- Any pattern beginning with a period matches a domain and all of its
101
- subdomains. (e.g. ``.example.com`` matches ``example.com`` and
102
- ``foo.example.com``). Anything else is an exact string match.
103
- """
104
- if not pattern:
105
- return False
106
-
107
- pattern = pattern.lower()
108
- return (
109
- pattern[0] == "."
110
- and (host.endswith(pattern) or host == pattern[1:])
111
- or pattern == host
112
- )
113
-
114
-
115
95
  def escape_leading_slashes(url):
116
96
  """
117
97
  If redirecting to an absolute path (two leading slashes), a slash must be
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.64.0
3
+ Version: 0.65.1
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,5 +1,5 @@
1
1
  plain/AGENTS.md,sha256=5XMGBpJgbCNIpp60DPXB7bpAtFk8FAzqiZke95T965o,1038
2
- plain/CHANGELOG.md,sha256=8EVqRYBSknRtM-becwiafkwoLHz3z2erO8dyULfVKfY,14516
2
+ plain/CHANGELOG.md,sha256=NlQC7IqcrH9tiUMqkCgyG6Pn7xQiaDUE3Xtfn6g4nIc,15292
3
3
  plain/README.md,sha256=5BJyKhf0TDanWVbOQyZ3zsi5Lov9xk-LlJYCDWofM6Y,4078
4
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
5
5
  plain/debug.py,sha256=XdjnXcbPGsi0J2SpHGaLthhYU5AjhBlkHdemaP4sbYY,758
@@ -57,8 +57,9 @@ plain/forms/forms.py,sha256=hF7Dl8rEaiBTZhFQyfbh1Zf54BSEka8RYpBiGqkTa8I,10441
57
57
  plain/http/README.md,sha256=hkjTJJ2_WEGm7vaIxjjNHrzD6EN5VI6pjDJPIR9l1jo,760
58
58
  plain/http/__init__.py,sha256=PfmXBIq7onLO_bbOrVdj5rJeHxqJMYqrIobVKupUzUA,1003
59
59
  plain/http/cookie.py,sha256=THd7nOl-2ugeBPKgOhbD87aM2oxUbNH8HWrarUn0fpM,1955
60
+ plain/http/hosts.py,sha256=GbvFzle2VgPC2ehtXW8MM_1XD9gKuv7h-8HTOX3RiJA,4567
60
61
  plain/http/multipartparser.py,sha256=Z1dFJNAd8N5RHUuF67jh1jBfZOFepORsre_3ee6CgOQ,27266
61
- plain/http/request.py,sha256=93b2gqkfEsBczUyP_9vlueVoxyzzfbnJ423PDAk8aHc,26103
62
+ plain/http/request.py,sha256=6kimxbIZMK2ckx366fQHDzrNTf6H8ih6YJ4y9yx2aAI,24623
62
63
  plain/http/response.py,sha256=FM3otFkKEEkAaV_pVB3JUSMhMk7x_zf5JcDWKhOKviM,23223
63
64
  plain/internal/__init__.py,sha256=fVBaYLCXEQc-7riHMSlw3vMTTuF7-0Bj2I8aGzv0o0w,171
64
65
  plain/internal/files/__init__.py,sha256=VctFgox4Q1AWF3klPaoCC5GIw5KeLafYjY5JmN8mAVw,63
@@ -70,7 +71,7 @@ plain/internal/files/uploadedfile.py,sha256=JRB7T3quQjg-1y3l1ASPxywtSQZhaeMc45uF
70
71
  plain/internal/files/uploadhandler.py,sha256=63_QUwAwfq3bevw79i0S7zt2EB2UBoO7MaauvezaVMY,7198
71
72
  plain/internal/files/utils.py,sha256=xN4HTJXDRdcoNyrL1dFd528MBwodRlHZM8DGTD_oBIg,2646
72
73
  plain/internal/handlers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
- plain/internal/handlers/base.py,sha256=ur-nYmpvXjXhu03aPP1KV5GSNaLL_QZoT8x0v8l6_wg,6006
74
+ plain/internal/handlers/base.py,sha256=rrPq-LqMOsDUP2IQ-d_FUNq8fZfYqbLVMYOeqrftoXQ,6228
74
75
  plain/internal/handlers/exception.py,sha256=TbPYtgZ7ITJahUKhQWkptHK28Lb4zh_nOviNctC2EYs,4815
75
76
  plain/internal/handlers/wsgi.py,sha256=dgPT29t_F9llB-c5RYU3SHxGuZNaZ83xRjOfuOmtOl8,8209
76
77
  plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -97,7 +98,7 @@ plain/preflight/security.py,sha256=oxUZBp2M0bpBfUoLYepIxoex2Y90nyjlrL8XU8UTHYY,2
97
98
  plain/preflight/urls.py,sha256=cQ-WnFa_5oztpKdtwhuIGb7pXEml__bHsjs1SWO2YNI,1468
98
99
  plain/runtime/README.md,sha256=sTqXXJkckwqkk9O06XMMSNRokAYjrZBnB50JD36BsYI,4873
99
100
  plain/runtime/__init__.py,sha256=byFYnHrpUCwkpkHtdNhxr9iUdLDCWJjy92HPj30Ilck,2478
100
- plain/runtime/global_settings.py,sha256=PjgrsTQc3aQ0YxbZ43Lj2eNrOcP6hf4jBjjQ2lT0MfE,5767
101
+ plain/runtime/global_settings.py,sha256=GxcXFjXui5GLkiLlWe8P8X91ndShnmuJK0Ql6xjn24s,5826
101
102
  plain/runtime/user_settings.py,sha256=OzMiEkE6ZQ50nxd1WIqirXPiNuMAQULklYHEzgzLWgA,11027
102
103
  plain/runtime/utils.py,sha256=p5IuNTzc7Kq-9Ym7etYnt_xqHw5TioxfSkFeq1bKdgk,832
103
104
  plain/signals/README.md,sha256=XefXqROlDhzw7Z5l_nx6Mhq6n9jjQ-ECGbH0vvhKWYg,272
@@ -140,7 +141,7 @@ plain/utils/encoding.py,sha256=T0Shb2xRAR3NPwwoqhpUOB55gDprWzqu72aRiiulv9Y,4251
140
141
  plain/utils/functional.py,sha256=eJksrhVdkC8HKF56qtVyTOsOnkZB2jMUnXSTGzjJMF4,13331
141
142
  plain/utils/hashable.py,sha256=uLWobCCh7VcEPJ7xzVGPgigNVuTazYJbyzRzHTCI_wo,739
142
143
  plain/utils/html.py,sha256=SR8oNrungB5gxJaHbvAaCw_bAiqLQOk09fj-iIXY0i0,3679
143
- plain/utils/http.py,sha256=VOOnwRXnDp5PL_qEmkInLTm10fF58vlhVjeSTdzV2cQ,6031
144
+ plain/utils/http.py,sha256=_YrXfauKOiEDr2beFK4UY2A2Am1Xz1BpZCho1A6I3W4,5471
144
145
  plain/utils/inspect.py,sha256=O3VMH5f4aGOrVpXJBKtQOxx01XrKnjjz6VO_MCV0xkE,1140
145
146
  plain/utils/ipv6.py,sha256=pISQ2AIlG8xXlxpphn388q03fq-fOrlu4GZR0YYjQXw,1267
146
147
  plain/utils/itercompat.py,sha256=lacIDjczhxbwG4ON_KfG1H6VNPOGOpbRhnVhbedo2CY,184
@@ -160,8 +161,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
160
161
  plain/views/objects.py,sha256=v3Vgvdoc1s0QW6JNWWrO5XXy9zF7vgwndgxX1eOSQoE,4999
161
162
  plain/views/redirect.py,sha256=Xpb3cB7nZYvKgkNqcAxf9Jwm2SWcQ0u2xz4oO5M3vP8,1909
162
163
  plain/views/templates.py,sha256=oAlebEyfES0rzBhfyEJzFmgLkpkbleA6Eip-8zDp-yk,1863
163
- plain-0.64.0.dist-info/METADATA,sha256=rXEqF5LoYBy2GzUEq2vrrhCDIG_Xn3VmyXAHgd4OdY4,4488
164
- plain-0.64.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
165
- plain-0.64.0.dist-info/entry_points.txt,sha256=iGx7EijzXy87htbSv90RhtAcjhSTH_kvE8aeRCn1TRA,129
166
- plain-0.64.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
167
- plain-0.64.0.dist-info/RECORD,,
164
+ plain-0.65.1.dist-info/METADATA,sha256=MnXWc7Ncpu8r1geVUL6uiAorVD_1aJ0CJ5KcUs4jkQI,4488
165
+ plain-0.65.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
166
+ plain-0.65.1.dist-info/entry_points.txt,sha256=iGx7EijzXy87htbSv90RhtAcjhSTH_kvE8aeRCn1TRA,129
167
+ plain-0.65.1.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
168
+ plain-0.65.1.dist-info/RECORD,,
File without changes