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.
Files changed (181) hide show
  1. {plain-0.64.0 → plain-0.65.1}/PKG-INFO +1 -1
  2. {plain-0.64.0 → plain-0.65.1}/plain/CHANGELOG.md +21 -0
  3. plain-0.65.1/plain/http/hosts.py +147 -0
  4. {plain-0.64.0 → plain-0.65.1}/plain/http/request.py +7 -53
  5. {plain-0.64.0 → plain-0.65.1}/plain/internal/handlers/base.py +5 -1
  6. {plain-0.64.0 → plain-0.65.1}/plain/runtime/global_settings.py +1 -0
  7. {plain-0.64.0 → plain-0.65.1}/plain/utils/http.py +0 -20
  8. {plain-0.64.0 → plain-0.65.1}/pyproject.toml +1 -1
  9. plain-0.65.1/tests/test_http_hosts.py +214 -0
  10. {plain-0.64.0 → plain-0.65.1}/.gitignore +0 -0
  11. {plain-0.64.0 → plain-0.65.1}/LICENSE +0 -0
  12. {plain-0.64.0 → plain-0.65.1}/README.md +0 -0
  13. {plain-0.64.0 → plain-0.65.1}/plain/AGENTS.md +0 -0
  14. {plain-0.64.0 → plain-0.65.1}/plain/README.md +0 -0
  15. {plain-0.64.0 → plain-0.65.1}/plain/__main__.py +0 -0
  16. {plain-0.64.0 → plain-0.65.1}/plain/assets/README.md +0 -0
  17. {plain-0.64.0 → plain-0.65.1}/plain/assets/__init__.py +0 -0
  18. {plain-0.64.0 → plain-0.65.1}/plain/assets/compile.py +0 -0
  19. {plain-0.64.0 → plain-0.65.1}/plain/assets/finders.py +0 -0
  20. {plain-0.64.0 → plain-0.65.1}/plain/assets/fingerprints.py +0 -0
  21. {plain-0.64.0 → plain-0.65.1}/plain/assets/urls.py +0 -0
  22. {plain-0.64.0 → plain-0.65.1}/plain/assets/views.py +0 -0
  23. {plain-0.64.0 → plain-0.65.1}/plain/chores/README.md +0 -0
  24. {plain-0.64.0 → plain-0.65.1}/plain/chores/__init__.py +0 -0
  25. {plain-0.64.0 → plain-0.65.1}/plain/chores/registry.py +0 -0
  26. {plain-0.64.0 → plain-0.65.1}/plain/cli/README.md +0 -0
  27. {plain-0.64.0 → plain-0.65.1}/plain/cli/__init__.py +0 -0
  28. {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/__init__.py +0 -0
  29. {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/docs.py +0 -0
  30. {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/llmdocs.py +0 -0
  31. {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/md.py +0 -0
  32. {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/prompt.py +0 -0
  33. {plain-0.64.0 → plain-0.65.1}/plain/cli/agent/request.py +0 -0
  34. {plain-0.64.0 → plain-0.65.1}/plain/cli/build.py +0 -0
  35. {plain-0.64.0 → plain-0.65.1}/plain/cli/changelog.py +0 -0
  36. {plain-0.64.0 → plain-0.65.1}/plain/cli/chores.py +0 -0
  37. {plain-0.64.0 → plain-0.65.1}/plain/cli/core.py +0 -0
  38. {plain-0.64.0 → plain-0.65.1}/plain/cli/docs.py +0 -0
  39. {plain-0.64.0 → plain-0.65.1}/plain/cli/formatting.py +0 -0
  40. {plain-0.64.0 → plain-0.65.1}/plain/cli/install.py +0 -0
  41. {plain-0.64.0 → plain-0.65.1}/plain/cli/output.py +0 -0
  42. {plain-0.64.0 → plain-0.65.1}/plain/cli/preflight.py +0 -0
  43. {plain-0.64.0 → plain-0.65.1}/plain/cli/print.py +0 -0
  44. {plain-0.64.0 → plain-0.65.1}/plain/cli/registry.py +0 -0
  45. {plain-0.64.0 → plain-0.65.1}/plain/cli/scaffold.py +0 -0
  46. {plain-0.64.0 → plain-0.65.1}/plain/cli/settings.py +0 -0
  47. {plain-0.64.0 → plain-0.65.1}/plain/cli/shell.py +0 -0
  48. {plain-0.64.0 → plain-0.65.1}/plain/cli/startup.py +0 -0
  49. {plain-0.64.0 → plain-0.65.1}/plain/cli/upgrade.py +0 -0
  50. {plain-0.64.0 → plain-0.65.1}/plain/cli/urls.py +0 -0
  51. {plain-0.64.0 → plain-0.65.1}/plain/cli/utils.py +0 -0
  52. {plain-0.64.0 → plain-0.65.1}/plain/csrf/README.md +0 -0
  53. {plain-0.64.0 → plain-0.65.1}/plain/csrf/middleware.py +0 -0
  54. {plain-0.64.0 → plain-0.65.1}/plain/csrf/views.py +0 -0
  55. {plain-0.64.0 → plain-0.65.1}/plain/debug.py +0 -0
  56. {plain-0.64.0 → plain-0.65.1}/plain/exceptions.py +0 -0
  57. {plain-0.64.0 → plain-0.65.1}/plain/forms/README.md +0 -0
  58. {plain-0.64.0 → plain-0.65.1}/plain/forms/__init__.py +0 -0
  59. {plain-0.64.0 → plain-0.65.1}/plain/forms/boundfield.py +0 -0
  60. {plain-0.64.0 → plain-0.65.1}/plain/forms/exceptions.py +0 -0
  61. {plain-0.64.0 → plain-0.65.1}/plain/forms/fields.py +0 -0
  62. {plain-0.64.0 → plain-0.65.1}/plain/forms/forms.py +0 -0
  63. {plain-0.64.0 → plain-0.65.1}/plain/http/README.md +0 -0
  64. {plain-0.64.0 → plain-0.65.1}/plain/http/__init__.py +0 -0
  65. {plain-0.64.0 → plain-0.65.1}/plain/http/cookie.py +0 -0
  66. {plain-0.64.0 → plain-0.65.1}/plain/http/multipartparser.py +0 -0
  67. {plain-0.64.0 → plain-0.65.1}/plain/http/response.py +0 -0
  68. {plain-0.64.0 → plain-0.65.1}/plain/internal/__init__.py +0 -0
  69. {plain-0.64.0 → plain-0.65.1}/plain/internal/files/__init__.py +0 -0
  70. {plain-0.64.0 → plain-0.65.1}/plain/internal/files/base.py +0 -0
  71. {plain-0.64.0 → plain-0.65.1}/plain/internal/files/locks.py +0 -0
  72. {plain-0.64.0 → plain-0.65.1}/plain/internal/files/move.py +0 -0
  73. {plain-0.64.0 → plain-0.65.1}/plain/internal/files/temp.py +0 -0
  74. {plain-0.64.0 → plain-0.65.1}/plain/internal/files/uploadedfile.py +0 -0
  75. {plain-0.64.0 → plain-0.65.1}/plain/internal/files/uploadhandler.py +0 -0
  76. {plain-0.64.0 → plain-0.65.1}/plain/internal/files/utils.py +0 -0
  77. {plain-0.64.0 → plain-0.65.1}/plain/internal/handlers/__init__.py +0 -0
  78. {plain-0.64.0 → plain-0.65.1}/plain/internal/handlers/exception.py +0 -0
  79. {plain-0.64.0 → plain-0.65.1}/plain/internal/handlers/wsgi.py +0 -0
  80. {plain-0.64.0 → plain-0.65.1}/plain/internal/middleware/__init__.py +0 -0
  81. {plain-0.64.0 → plain-0.65.1}/plain/internal/middleware/headers.py +0 -0
  82. {plain-0.64.0 → plain-0.65.1}/plain/internal/middleware/https.py +0 -0
  83. {plain-0.64.0 → plain-0.65.1}/plain/internal/middleware/slash.py +0 -0
  84. {plain-0.64.0 → plain-0.65.1}/plain/json.py +0 -0
  85. {plain-0.64.0 → plain-0.65.1}/plain/logs/README.md +0 -0
  86. {plain-0.64.0 → plain-0.65.1}/plain/logs/__init__.py +0 -0
  87. {plain-0.64.0 → plain-0.65.1}/plain/logs/configure.py +0 -0
  88. {plain-0.64.0 → plain-0.65.1}/plain/logs/debug.py +0 -0
  89. {plain-0.64.0 → plain-0.65.1}/plain/logs/formatters.py +0 -0
  90. {plain-0.64.0 → plain-0.65.1}/plain/logs/loggers.py +0 -0
  91. {plain-0.64.0 → plain-0.65.1}/plain/logs/utils.py +0 -0
  92. {plain-0.64.0 → plain-0.65.1}/plain/packages/README.md +0 -0
  93. {plain-0.64.0 → plain-0.65.1}/plain/packages/__init__.py +0 -0
  94. {plain-0.64.0 → plain-0.65.1}/plain/packages/config.py +0 -0
  95. {plain-0.64.0 → plain-0.65.1}/plain/packages/registry.py +0 -0
  96. {plain-0.64.0 → plain-0.65.1}/plain/paginator.py +0 -0
  97. {plain-0.64.0 → plain-0.65.1}/plain/preflight/README.md +0 -0
  98. {plain-0.64.0 → plain-0.65.1}/plain/preflight/__init__.py +0 -0
  99. {plain-0.64.0 → plain-0.65.1}/plain/preflight/files.py +0 -0
  100. {plain-0.64.0 → plain-0.65.1}/plain/preflight/messages.py +0 -0
  101. {plain-0.64.0 → plain-0.65.1}/plain/preflight/registry.py +0 -0
  102. {plain-0.64.0 → plain-0.65.1}/plain/preflight/security.py +0 -0
  103. {plain-0.64.0 → plain-0.65.1}/plain/preflight/urls.py +0 -0
  104. {plain-0.64.0 → plain-0.65.1}/plain/runtime/README.md +0 -0
  105. {plain-0.64.0 → plain-0.65.1}/plain/runtime/__init__.py +0 -0
  106. {plain-0.64.0 → plain-0.65.1}/plain/runtime/user_settings.py +0 -0
  107. {plain-0.64.0 → plain-0.65.1}/plain/runtime/utils.py +0 -0
  108. {plain-0.64.0 → plain-0.65.1}/plain/signals/README.md +0 -0
  109. {plain-0.64.0 → plain-0.65.1}/plain/signals/__init__.py +0 -0
  110. {plain-0.64.0 → plain-0.65.1}/plain/signals/dispatch/__init__.py +0 -0
  111. {plain-0.64.0 → plain-0.65.1}/plain/signals/dispatch/dispatcher.py +0 -0
  112. {plain-0.64.0 → plain-0.65.1}/plain/signals/dispatch/license.txt +0 -0
  113. {plain-0.64.0 → plain-0.65.1}/plain/signing.py +0 -0
  114. {plain-0.64.0 → plain-0.65.1}/plain/templates/AGENTS.md +0 -0
  115. {plain-0.64.0 → plain-0.65.1}/plain/templates/README.md +0 -0
  116. {plain-0.64.0 → plain-0.65.1}/plain/templates/__init__.py +0 -0
  117. {plain-0.64.0 → plain-0.65.1}/plain/templates/core.py +0 -0
  118. {plain-0.64.0 → plain-0.65.1}/plain/templates/jinja/__init__.py +0 -0
  119. {plain-0.64.0 → plain-0.65.1}/plain/templates/jinja/environments.py +0 -0
  120. {plain-0.64.0 → plain-0.65.1}/plain/templates/jinja/extensions.py +0 -0
  121. {plain-0.64.0 → plain-0.65.1}/plain/templates/jinja/filters.py +0 -0
  122. {plain-0.64.0 → plain-0.65.1}/plain/templates/jinja/globals.py +0 -0
  123. {plain-0.64.0 → plain-0.65.1}/plain/test/README.md +0 -0
  124. {plain-0.64.0 → plain-0.65.1}/plain/test/__init__.py +0 -0
  125. {plain-0.64.0 → plain-0.65.1}/plain/test/client.py +0 -0
  126. {plain-0.64.0 → plain-0.65.1}/plain/test/encoding.py +0 -0
  127. {plain-0.64.0 → plain-0.65.1}/plain/test/exceptions.py +0 -0
  128. {plain-0.64.0 → plain-0.65.1}/plain/urls/README.md +0 -0
  129. {plain-0.64.0 → plain-0.65.1}/plain/urls/__init__.py +0 -0
  130. {plain-0.64.0 → plain-0.65.1}/plain/urls/converters.py +0 -0
  131. {plain-0.64.0 → plain-0.65.1}/plain/urls/exceptions.py +0 -0
  132. {plain-0.64.0 → plain-0.65.1}/plain/urls/patterns.py +0 -0
  133. {plain-0.64.0 → plain-0.65.1}/plain/urls/resolvers.py +0 -0
  134. {plain-0.64.0 → plain-0.65.1}/plain/urls/routers.py +0 -0
  135. {plain-0.64.0 → plain-0.65.1}/plain/urls/utils.py +0 -0
  136. {plain-0.64.0 → plain-0.65.1}/plain/utils/README.md +0 -0
  137. {plain-0.64.0 → plain-0.65.1}/plain/utils/__init__.py +0 -0
  138. {plain-0.64.0 → plain-0.65.1}/plain/utils/cache.py +0 -0
  139. {plain-0.64.0 → plain-0.65.1}/plain/utils/crypto.py +0 -0
  140. {plain-0.64.0 → plain-0.65.1}/plain/utils/datastructures.py +0 -0
  141. {plain-0.64.0 → plain-0.65.1}/plain/utils/dateparse.py +0 -0
  142. {plain-0.64.0 → plain-0.65.1}/plain/utils/deconstruct.py +0 -0
  143. {plain-0.64.0 → plain-0.65.1}/plain/utils/decorators.py +0 -0
  144. {plain-0.64.0 → plain-0.65.1}/plain/utils/duration.py +0 -0
  145. {plain-0.64.0 → plain-0.65.1}/plain/utils/encoding.py +0 -0
  146. {plain-0.64.0 → plain-0.65.1}/plain/utils/functional.py +0 -0
  147. {plain-0.64.0 → plain-0.65.1}/plain/utils/hashable.py +0 -0
  148. {plain-0.64.0 → plain-0.65.1}/plain/utils/html.py +0 -0
  149. {plain-0.64.0 → plain-0.65.1}/plain/utils/inspect.py +0 -0
  150. {plain-0.64.0 → plain-0.65.1}/plain/utils/ipv6.py +0 -0
  151. {plain-0.64.0 → plain-0.65.1}/plain/utils/itercompat.py +0 -0
  152. {plain-0.64.0 → plain-0.65.1}/plain/utils/module_loading.py +0 -0
  153. {plain-0.64.0 → plain-0.65.1}/plain/utils/regex_helper.py +0 -0
  154. {plain-0.64.0 → plain-0.65.1}/plain/utils/safestring.py +0 -0
  155. {plain-0.64.0 → plain-0.65.1}/plain/utils/text.py +0 -0
  156. {plain-0.64.0 → plain-0.65.1}/plain/utils/timesince.py +0 -0
  157. {plain-0.64.0 → plain-0.65.1}/plain/utils/timezone.py +0 -0
  158. {plain-0.64.0 → plain-0.65.1}/plain/utils/tree.py +0 -0
  159. {plain-0.64.0 → plain-0.65.1}/plain/validators.py +0 -0
  160. {plain-0.64.0 → plain-0.65.1}/plain/views/README.md +0 -0
  161. {plain-0.64.0 → plain-0.65.1}/plain/views/__init__.py +0 -0
  162. {plain-0.64.0 → plain-0.65.1}/plain/views/base.py +0 -0
  163. {plain-0.64.0 → plain-0.65.1}/plain/views/errors.py +0 -0
  164. {plain-0.64.0 → plain-0.65.1}/plain/views/exceptions.py +0 -0
  165. {plain-0.64.0 → plain-0.65.1}/plain/views/forms.py +0 -0
  166. {plain-0.64.0 → plain-0.65.1}/plain/views/objects.py +0 -0
  167. {plain-0.64.0 → plain-0.65.1}/plain/views/redirect.py +0 -0
  168. {plain-0.64.0 → plain-0.65.1}/plain/views/templates.py +0 -0
  169. {plain-0.64.0 → plain-0.65.1}/plain/wsgi.py +0 -0
  170. {plain-0.64.0 → plain-0.65.1}/tests/.gitignore +0 -0
  171. {plain-0.64.0 → plain-0.65.1}/tests/app/.gitignore +0 -0
  172. {plain-0.64.0 → plain-0.65.1}/tests/app/settings.py +0 -0
  173. {plain-0.64.0 → plain-0.65.1}/tests/app/test/__init__.py +0 -0
  174. {plain-0.64.0 → plain-0.65.1}/tests/app/test/default_settings.py +0 -0
  175. {plain-0.64.0 → plain-0.65.1}/tests/app/urls.py +0 -0
  176. {plain-0.64.0 → plain-0.65.1}/tests/conftest.py +0 -0
  177. {plain-0.64.0 → plain-0.65.1}/tests/test_cli.py +0 -0
  178. {plain-0.64.0 → plain-0.65.1}/tests/test_csrf.py +0 -0
  179. {plain-0.64.0 → plain-0.65.1}/tests/test_logs.py +0 -0
  180. {plain-0.64.0 → plain-0.65.1}/tests/test_runtime.py +0 -0
  181. {plain-0.64.0 → plain-0.65.1}/tests/test_wsgi.py +0 -0
@@ -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,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 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.
@@ -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
  [project]
2
2
  name = "plain"
3
- version = "0.64.0"
3
+ version = "0.65.1"
4
4
  description = "A web framework for building products with Python."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  readme = "README.md"
@@ -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