plain 0.64.0__tar.gz → 0.65.1__tar.gz
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-0.64.0 → plain-0.65.1}/PKG-INFO +1 -1
- {plain-0.64.0 → plain-0.65.1}/plain/CHANGELOG.md +21 -0
- plain-0.65.1/plain/http/hosts.py +147 -0
- {plain-0.64.0 → plain-0.65.1}/plain/http/request.py +7 -53
- {plain-0.64.0 → plain-0.65.1}/plain/internal/handlers/base.py +5 -1
- {plain-0.64.0 → plain-0.65.1}/plain/runtime/global_settings.py +1 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/http.py +0 -20
- {plain-0.64.0 → plain-0.65.1}/pyproject.toml +1 -1
- plain-0.65.1/tests/test_http_hosts.py +214 -0
- {plain-0.64.0 → plain-0.65.1}/.gitignore +0 -0
- {plain-0.64.0 → plain-0.65.1}/LICENSE +0 -0
- {plain-0.64.0 → plain-0.65.1}/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/AGENTS.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/__main__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/assets/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/assets/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/assets/compile.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/assets/finders.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/assets/fingerprints.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/assets/urls.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/assets/views.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/chores/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/chores/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/chores/registry.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/docs.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/llmdocs.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/md.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/prompt.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/request.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/build.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/changelog.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/chores.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/core.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/docs.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/formatting.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/install.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/output.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/preflight.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/print.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/registry.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/scaffold.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/settings.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/shell.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/startup.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/upgrade.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/urls.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/cli/utils.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/csrf/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/csrf/middleware.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/csrf/views.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/debug.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/exceptions.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/forms/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/forms/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/forms/boundfield.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/forms/exceptions.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/forms/fields.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/forms/forms.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/http/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/http/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/http/cookie.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/http/multipartparser.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/http/response.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/files/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/files/base.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/files/locks.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/files/move.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/files/temp.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/files/utils.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/handlers/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/handlers/exception.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/middleware/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/middleware/headers.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/middleware/https.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/internal/middleware/slash.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/json.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/logs/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/logs/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/logs/configure.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/logs/debug.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/logs/formatters.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/logs/loggers.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/logs/utils.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/packages/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/packages/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/packages/config.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/packages/registry.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/paginator.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/preflight/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/preflight/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/preflight/files.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/preflight/messages.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/preflight/registry.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/preflight/security.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/preflight/urls.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/runtime/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/runtime/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/runtime/user_settings.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/runtime/utils.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/signals/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/signals/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/signing.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/templates/AGENTS.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/templates/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/templates/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/templates/core.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/templates/jinja/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/templates/jinja/environments.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/templates/jinja/filters.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/templates/jinja/globals.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/test/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/test/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/test/client.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/test/encoding.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/test/exceptions.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/urls/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/urls/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/urls/converters.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/urls/exceptions.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/urls/patterns.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/urls/resolvers.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/urls/routers.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/urls/utils.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/cache.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/crypto.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/datastructures.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/dateparse.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/deconstruct.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/decorators.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/duration.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/encoding.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/functional.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/hashable.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/html.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/inspect.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/ipv6.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/itercompat.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/module_loading.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/regex_helper.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/safestring.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/text.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/timesince.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/timezone.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/utils/tree.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/validators.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/views/README.md +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/views/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/views/base.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/views/errors.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/views/exceptions.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/views/forms.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/views/objects.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/views/redirect.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/views/templates.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/plain/wsgi.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/.gitignore +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/app/.gitignore +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/app/settings.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/app/test/__init__.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/app/test/default_settings.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/app/urls.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/conftest.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/test_cli.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/test_csrf.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/test_logs.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/test_runtime.py +0 -0
- {plain-0.64.0 → plain-0.65.1}/tests/test_wsgi.py +0 -0
@@ -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
|
@@ -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
|
@@ -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):
|
@@ -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 =
|
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(
|
245
|
-
return iri_to_uri(location)
|
243
|
+
location = urljoin(current_scheme_host + self.path, location)
|
246
244
|
|
247
|
-
|
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.
|
@@ -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
|
@@ -0,0 +1,214 @@
|
|
1
|
+
import pytest
|
2
|
+
|
3
|
+
from plain.http.hosts import split_domain_port, validate_host
|
4
|
+
|
5
|
+
|
6
|
+
@pytest.mark.parametrize(
|
7
|
+
("host", "expected_domain", "expected_port"),
|
8
|
+
[
|
9
|
+
# IPv4 addresses
|
10
|
+
("127.0.0.1", "127.0.0.1", ""),
|
11
|
+
("192.168.1.1", "192.168.1.1", ""),
|
12
|
+
("127.0.0.1:8000", "127.0.0.1", "8000"),
|
13
|
+
("192.168.1.1:443", "192.168.1.1", "443"),
|
14
|
+
# IPv6 addresses
|
15
|
+
("[::1]", "[::1]", ""),
|
16
|
+
("[2001:db8::1]", "[2001:db8::1]", ""),
|
17
|
+
(
|
18
|
+
"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]",
|
19
|
+
"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]",
|
20
|
+
"",
|
21
|
+
),
|
22
|
+
("[::1]:8000", "[::1]", "8000"),
|
23
|
+
("[2001:db8::1]:443", "[2001:db8::1]", "443"),
|
24
|
+
# Domain names
|
25
|
+
("example.com", "example.com", ""),
|
26
|
+
("sub.example.com", "sub.example.com", ""),
|
27
|
+
("deep.sub.example.com", "deep.sub.example.com", ""),
|
28
|
+
("example.com:8080", "example.com", "8080"),
|
29
|
+
("sub.example.com:443", "sub.example.com", "443"),
|
30
|
+
("api.example.com:3000", "api.example.com", "3000"),
|
31
|
+
# Trailing dot removal
|
32
|
+
("example.com.", "example.com", ""),
|
33
|
+
("sub.example.com.:8080", "sub.example.com", "8080"),
|
34
|
+
# Case normalization
|
35
|
+
("EXAMPLE.COM", "example.com", ""),
|
36
|
+
("Example.Com:8080", "example.com", "8080"),
|
37
|
+
("SUB.EXAMPLE.COM:443", "sub.example.com", "443"),
|
38
|
+
# Borderline valid cases
|
39
|
+
("invalid..domain", "invalid..domain", ""),
|
40
|
+
# Invalid hosts
|
41
|
+
("", "", ""),
|
42
|
+
("::1", "", ""), # IPv6 without brackets
|
43
|
+
("not a valid host", "", ""),
|
44
|
+
("example$.com", "", ""),
|
45
|
+
("example.com:", "", ""), # Trailing colon
|
46
|
+
("[::1", "", ""), # Incomplete brackets
|
47
|
+
("example.com:8080:9000", "", ""), # Multiple colons
|
48
|
+
],
|
49
|
+
)
|
50
|
+
def test_split_domain_port(host, expected_domain, expected_port):
|
51
|
+
"""Test split_domain_port function with various inputs."""
|
52
|
+
domain, port = split_domain_port(host)
|
53
|
+
assert domain == expected_domain
|
54
|
+
assert port == expected_port
|
55
|
+
|
56
|
+
|
57
|
+
@pytest.mark.parametrize(
|
58
|
+
("host", "allowed_hosts", "expected"),
|
59
|
+
[
|
60
|
+
# Wildcard matching
|
61
|
+
("example.com", ["*"], True),
|
62
|
+
("sub.example.com", ["*"], True),
|
63
|
+
("127.0.0.1", ["*"], True),
|
64
|
+
("[::1]", ["*"], True),
|
65
|
+
("anything.at.all", ["*"], True),
|
66
|
+
# Subdomain pattern matching
|
67
|
+
("example.com", [".example.com"], True),
|
68
|
+
("sub.example.com", [".example.com"], True),
|
69
|
+
("api.example.com", [".example.com"], True),
|
70
|
+
("deep.sub.example.com", [".example.com"], True),
|
71
|
+
("notexample.com", [".example.com"], False),
|
72
|
+
("example.org", [".example.com"], False),
|
73
|
+
("fakeexample.com", [".example.com"], False),
|
74
|
+
# Exact domain matching
|
75
|
+
("example.com", ["example.com"], True),
|
76
|
+
("sub.example.com", ["example.com"], False),
|
77
|
+
("api.example.com", ["example.com"], False),
|
78
|
+
("example.org", ["example.com"], False),
|
79
|
+
("notexample.com", ["example.com"], False),
|
80
|
+
# Multiple patterns
|
81
|
+
("example.com", ["example.com", ".api.example.com", "127.0.0.1"], True),
|
82
|
+
("api.example.com", ["example.com", ".api.example.com", "127.0.0.1"], True),
|
83
|
+
("v1.api.example.com", ["example.com", ".api.example.com", "127.0.0.1"], True),
|
84
|
+
("127.0.0.1", ["example.com", ".api.example.com", "127.0.0.1"], True),
|
85
|
+
("sub.example.com", ["example.com", ".api.example.com", "127.0.0.1"], False),
|
86
|
+
("other.com", ["example.com", ".api.example.com", "127.0.0.1"], False),
|
87
|
+
# Literal asterisk pattern (not treated as wildcard)
|
88
|
+
("*.test.com", ["*.test.com"], True),
|
89
|
+
("anything.test.com", ["*.test.com"], False),
|
90
|
+
("api.test.com", ["*.test.com"], False),
|
91
|
+
# IPv4 address matching
|
92
|
+
("127.0.0.1", ["127.0.0.1", "192.168.1.1"], True),
|
93
|
+
("192.168.1.1", ["127.0.0.1", "192.168.1.1"], True),
|
94
|
+
("127.0.0.2", ["127.0.0.1", "192.168.1.1"], False),
|
95
|
+
("10.0.0.1", ["127.0.0.1", "192.168.1.1"], False),
|
96
|
+
# IPv6 address matching
|
97
|
+
("[::1]", ["[::1]", "[2001:db8::1]"], True),
|
98
|
+
("[2001:db8::1]", ["[::1]", "[2001:db8::1]"], True),
|
99
|
+
("[::2]", ["[::1]", "[2001:db8::1]"], False),
|
100
|
+
("[2001:db8::2]", ["[::1]", "[2001:db8::1]"], False),
|
101
|
+
# Pattern case insensitive matching (host should be lowercase already)
|
102
|
+
("example.com", [".Example.Com"], True),
|
103
|
+
("sub.example.com", [".Example.Com"], True),
|
104
|
+
("api.test.com", ["API.TEST.COM"], True),
|
105
|
+
# Empty allowed_hosts
|
106
|
+
("example.com", [], False),
|
107
|
+
("127.0.0.1", [], False),
|
108
|
+
("[::1]", [], False),
|
109
|
+
# Empty pattern in allowed_hosts
|
110
|
+
("", ["", "example.com"], False),
|
111
|
+
("anything.com", ["", "example.com"], False),
|
112
|
+
("example.com", ["", "example.com"], True),
|
113
|
+
# Complex subdomain patterns
|
114
|
+
("api.example.com", [".api.example.com", ".staging.example.com"], True),
|
115
|
+
("staging.example.com", [".api.example.com", ".staging.example.com"], True),
|
116
|
+
("v1.api.example.com", [".api.example.com", ".staging.example.com"], True),
|
117
|
+
(
|
118
|
+
"beta.staging.example.com",
|
119
|
+
[".api.example.com", ".staging.example.com"],
|
120
|
+
True,
|
121
|
+
),
|
122
|
+
("example.com", [".api.example.com", ".staging.example.com"], False),
|
123
|
+
("www.example.com", [".api.example.com", ".staging.example.com"], False),
|
124
|
+
# Wildcard overrides other patterns
|
125
|
+
(
|
126
|
+
"anything.com",
|
127
|
+
["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
|
128
|
+
True,
|
129
|
+
),
|
130
|
+
(
|
131
|
+
"random.domain.org",
|
132
|
+
["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
|
133
|
+
True,
|
134
|
+
),
|
135
|
+
(
|
136
|
+
"192.168.1.100",
|
137
|
+
["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
|
138
|
+
True,
|
139
|
+
),
|
140
|
+
# Edge cases
|
141
|
+
("", ["example.com"], False),
|
142
|
+
("example .com", ["example.com"], False),
|
143
|
+
("a" * 50 + ".example.com", [".example.com"], True), # Very long subdomain
|
144
|
+
],
|
145
|
+
)
|
146
|
+
def test_validate_host(host, allowed_hosts, expected):
|
147
|
+
"""Test validate_host function with various inputs."""
|
148
|
+
assert validate_host(host, allowed_hosts) is expected
|
149
|
+
|
150
|
+
|
151
|
+
@pytest.mark.parametrize(
|
152
|
+
("host", "allowed_hosts", "expected"),
|
153
|
+
[
|
154
|
+
# IPv4 CIDR tests
|
155
|
+
("192.168.1.100", ["192.168.1.0/24"], True),
|
156
|
+
("192.168.1.1", ["192.168.1.0/24"], True),
|
157
|
+
("192.168.1.254", ["192.168.1.0/24"], True),
|
158
|
+
("192.168.2.100", ["192.168.1.0/24"], False),
|
159
|
+
("10.0.5.1", ["10.0.0.0/8"], True),
|
160
|
+
("172.16.0.1", ["10.0.0.0/8"], False),
|
161
|
+
# IPv4 single IP as CIDR
|
162
|
+
("192.168.1.1", ["192.168.1.1/32"], True),
|
163
|
+
("192.168.1.2", ["192.168.1.1/32"], False),
|
164
|
+
# IPv4 larger networks
|
165
|
+
("172.16.5.10", ["172.16.0.0/12"], True),
|
166
|
+
("172.32.5.10", ["172.16.0.0/12"], False),
|
167
|
+
("127.0.0.1", ["127.0.0.0/8"], True),
|
168
|
+
# IPv6 CIDR tests
|
169
|
+
("[2001:db8::1]", ["[2001:db8::]/32"], True),
|
170
|
+
("[2001:db8:1::1]", ["[2001:db8::]/32"], True),
|
171
|
+
("[2001:db9::1]", ["[2001:db8::]/32"], False),
|
172
|
+
("[::1]", ["[::]/0"], True), # Match everything IPv6
|
173
|
+
("[2001:db8::1]", ["[fe80::]/10"], False),
|
174
|
+
# IPv6 without brackets in pattern (should still work)
|
175
|
+
("[2001:db8::1]", ["2001:db8::/32"], True),
|
176
|
+
("[2001:db9::1]", ["2001:db8::/32"], False),
|
177
|
+
# IPv6 single address as CIDR
|
178
|
+
("[::1]", ["[::1]/128"], True),
|
179
|
+
("[::2]", ["[::1]/128"], False),
|
180
|
+
# Mixed CIDR and domain patterns
|
181
|
+
("192.168.1.50", ["192.168.1.0/24", ".example.com"], True),
|
182
|
+
("sub.example.com", ["192.168.1.0/24", ".example.com"], True),
|
183
|
+
("192.168.2.50", ["192.168.1.0/24", ".example.com"], False),
|
184
|
+
("other.com", ["192.168.1.0/24", ".example.com"], False),
|
185
|
+
# Multiple CIDR patterns
|
186
|
+
("192.168.1.50", ["10.0.0.0/8", "192.168.0.0/16"], True),
|
187
|
+
("10.5.0.1", ["10.0.0.0/8", "192.168.0.0/16"], True),
|
188
|
+
("172.16.0.1", ["10.0.0.0/8", "192.168.0.0/16"], False),
|
189
|
+
# Domain names should not match CIDR patterns
|
190
|
+
("example.com", ["192.168.1.0/24"], False),
|
191
|
+
("192.168.1.com", ["192.168.1.0/24"], False),
|
192
|
+
# Non-IP strings should not match CIDR
|
193
|
+
("not-an-ip", ["192.168.1.0/24"], False),
|
194
|
+
("192.168.1", ["192.168.1.0/24"], False), # Incomplete IP
|
195
|
+
# Invalid CIDR patterns should be ignored (treated as literal)
|
196
|
+
("192.168.1.0/24", ["192.168.1.0/24"], False), # Literal match of CIDR string
|
197
|
+
("192.168.1.100", ["192.168.1.0/999"], False), # Invalid CIDR
|
198
|
+
("192.168.1.100", ["192.168.1.0/"], False), # Invalid CIDR
|
199
|
+
# CIDR with wildcard - wildcard should take precedence
|
200
|
+
("anything.com", ["*", "192.168.1.0/24"], True),
|
201
|
+
("172.16.0.1", ["*", "192.168.1.0/24"], True),
|
202
|
+
# Edge cases
|
203
|
+
("0.0.0.0", ["0.0.0.0/0"], True), # Match all IPv4
|
204
|
+
("255.255.255.255", ["0.0.0.0/0"], True),
|
205
|
+
(
|
206
|
+
"[ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff]",
|
207
|
+
["[::]/0"],
|
208
|
+
True,
|
209
|
+
), # Match all IPv6
|
210
|
+
],
|
211
|
+
)
|
212
|
+
def test_validate_host_cidr(host, allowed_hosts, expected):
|
213
|
+
"""Test validate_host function with CIDR notation patterns."""
|
214
|
+
assert validate_host(host, allowed_hosts) is expected
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|