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 +10 -0
- plain/http/hosts.py +147 -0
- plain/http/request.py +2 -47
- plain/runtime/global_settings.py +1 -0
- plain/utils/http.py +0 -20
- {plain-0.64.0.dist-info → plain-0.65.0.dist-info}/METADATA +1 -1
- {plain-0.64.0.dist-info → plain-0.65.0.dist-info}/RECORD +10 -9
- {plain-0.64.0.dist-info → plain-0.65.0.dist-info}/WHEEL +0 -0
- {plain-0.64.0.dist-info → plain-0.65.0.dist-info}/entry_points.txt +0 -0
- {plain-0.64.0.dist-info → plain-0.65.0.dist-info}/licenses/LICENSE +0 -0
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
|
31
|
-
from plain.utils.regex_helper import _lazy_re_compile
|
30
|
+
from plain.utils.http import parse_header_parameters
|
32
31
|
|
33
|
-
|
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()]
|
plain/runtime/global_settings.py
CHANGED
@@ -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,5 +1,5 @@
|
|
1
1
|
plain/AGENTS.md,sha256=5XMGBpJgbCNIpp60DPXB7bpAtFk8FAzqiZke95T965o,1038
|
2
|
-
plain/CHANGELOG.md,sha256=
|
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=
|
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=
|
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=
|
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.
|
164
|
-
plain-0.
|
165
|
-
plain-0.
|
166
|
-
plain-0.
|
167
|
-
plain-0.
|
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
|
File without changes
|
File without changes
|