plain 0.64.0__py3-none-any.whl → 0.65.0__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,15 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.65.0](https://github.com/dropseed/plain/releases/plain@0.65.0) (2025-09-22)
4
+
5
+ ### What's changed
6
+
7
+ - Added CIDR notation support to `ALLOWED_HOSTS` for IP address range validation ([c485d21](https://github.com/dropseed/plain/commit/c485d21a8b))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - No changes required
12
+
3
13
  ## [0.64.0](https://github.com/dropseed/plain/releases/plain@0.64.0) (2025-09-19)
4
14
 
5
15
  ### 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):
@@ -702,47 +699,5 @@ def bytes_to_text(s, encoding):
702
699
  return s
703
700
 
704
701
 
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
702
  def parse_accept_header(header):
748
703
  return [MediaType(token) for token in header.split(",") if token.strip()]
@@ -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.0
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=VksQqN6aUmaBpAwFEeYBcjbOSWR-LwwWHB8kJmqK3fU,14815
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=ikDCo5WOSKgHvDz8jn_CaFNa8grVxhWrR60lROLjRwI,24672
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
@@ -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.0.dist-info/METADATA,sha256=L7dFhqvEh4ZVoSDn3ow2O751k6Rx4R_BCWv4I7__ZJw,4488
165
+ plain-0.65.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
166
+ plain-0.65.0.dist-info/entry_points.txt,sha256=iGx7EijzXy87htbSv90RhtAcjhSTH_kvE8aeRCn1TRA,129
167
+ plain-0.65.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
168
+ plain-0.65.0.dist-info/RECORD,,
File without changes