plain 0.65.0__tar.gz → 0.66.0__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.65.0 → plain-0.66.0}/PKG-INFO +1 -1
  2. {plain-0.65.0 → plain-0.66.0}/plain/CHANGELOG.md +25 -0
  3. {plain-0.65.0 → plain-0.66.0}/plain/csrf/middleware.py +5 -6
  4. {plain-0.65.0 → plain-0.66.0}/plain/exceptions.py +0 -6
  5. {plain-0.65.0 → plain-0.66.0}/plain/http/request.py +21 -40
  6. {plain-0.65.0 → plain-0.66.0}/plain/internal/handlers/base.py +1 -0
  7. {plain-0.65.0/plain/http → plain-0.66.0/plain/internal/middleware}/hosts.py +59 -13
  8. {plain-0.65.0 → plain-0.66.0}/plain/internal/middleware/https.py +1 -2
  9. {plain-0.65.0 → plain-0.66.0}/plain/internal/middleware/slash.py +1 -1
  10. {plain-0.65.0 → plain-0.66.0}/plain/runtime/global_settings.py +1 -1
  11. {plain-0.65.0 → plain-0.66.0}/pyproject.toml +1 -1
  12. {plain-0.65.0 → plain-0.66.0}/tests/app/settings.py +1 -0
  13. {plain-0.65.0 → plain-0.66.0}/tests/test_http_hosts.py +1 -1
  14. {plain-0.65.0 → plain-0.66.0}/tests/test_wsgi.py +0 -1
  15. {plain-0.65.0 → plain-0.66.0}/.gitignore +0 -0
  16. {plain-0.65.0 → plain-0.66.0}/LICENSE +0 -0
  17. {plain-0.65.0 → plain-0.66.0}/README.md +0 -0
  18. {plain-0.65.0 → plain-0.66.0}/plain/AGENTS.md +0 -0
  19. {plain-0.65.0 → plain-0.66.0}/plain/README.md +0 -0
  20. {plain-0.65.0 → plain-0.66.0}/plain/__main__.py +0 -0
  21. {plain-0.65.0 → plain-0.66.0}/plain/assets/README.md +0 -0
  22. {plain-0.65.0 → plain-0.66.0}/plain/assets/__init__.py +0 -0
  23. {plain-0.65.0 → plain-0.66.0}/plain/assets/compile.py +0 -0
  24. {plain-0.65.0 → plain-0.66.0}/plain/assets/finders.py +0 -0
  25. {plain-0.65.0 → plain-0.66.0}/plain/assets/fingerprints.py +0 -0
  26. {plain-0.65.0 → plain-0.66.0}/plain/assets/urls.py +0 -0
  27. {plain-0.65.0 → plain-0.66.0}/plain/assets/views.py +0 -0
  28. {plain-0.65.0 → plain-0.66.0}/plain/chores/README.md +0 -0
  29. {plain-0.65.0 → plain-0.66.0}/plain/chores/__init__.py +0 -0
  30. {plain-0.65.0 → plain-0.66.0}/plain/chores/registry.py +0 -0
  31. {plain-0.65.0 → plain-0.66.0}/plain/cli/README.md +0 -0
  32. {plain-0.65.0 → plain-0.66.0}/plain/cli/__init__.py +0 -0
  33. {plain-0.65.0 → plain-0.66.0}/plain/cli/agent/__init__.py +0 -0
  34. {plain-0.65.0 → plain-0.66.0}/plain/cli/agent/docs.py +0 -0
  35. {plain-0.65.0 → plain-0.66.0}/plain/cli/agent/llmdocs.py +0 -0
  36. {plain-0.65.0 → plain-0.66.0}/plain/cli/agent/md.py +0 -0
  37. {plain-0.65.0 → plain-0.66.0}/plain/cli/agent/prompt.py +0 -0
  38. {plain-0.65.0 → plain-0.66.0}/plain/cli/agent/request.py +0 -0
  39. {plain-0.65.0 → plain-0.66.0}/plain/cli/build.py +0 -0
  40. {plain-0.65.0 → plain-0.66.0}/plain/cli/changelog.py +0 -0
  41. {plain-0.65.0 → plain-0.66.0}/plain/cli/chores.py +0 -0
  42. {plain-0.65.0 → plain-0.66.0}/plain/cli/core.py +0 -0
  43. {plain-0.65.0 → plain-0.66.0}/plain/cli/docs.py +0 -0
  44. {plain-0.65.0 → plain-0.66.0}/plain/cli/formatting.py +0 -0
  45. {plain-0.65.0 → plain-0.66.0}/plain/cli/install.py +0 -0
  46. {plain-0.65.0 → plain-0.66.0}/plain/cli/output.py +0 -0
  47. {plain-0.65.0 → plain-0.66.0}/plain/cli/preflight.py +0 -0
  48. {plain-0.65.0 → plain-0.66.0}/plain/cli/print.py +0 -0
  49. {plain-0.65.0 → plain-0.66.0}/plain/cli/registry.py +0 -0
  50. {plain-0.65.0 → plain-0.66.0}/plain/cli/scaffold.py +0 -0
  51. {plain-0.65.0 → plain-0.66.0}/plain/cli/settings.py +0 -0
  52. {plain-0.65.0 → plain-0.66.0}/plain/cli/shell.py +0 -0
  53. {plain-0.65.0 → plain-0.66.0}/plain/cli/startup.py +0 -0
  54. {plain-0.65.0 → plain-0.66.0}/plain/cli/upgrade.py +0 -0
  55. {plain-0.65.0 → plain-0.66.0}/plain/cli/urls.py +0 -0
  56. {plain-0.65.0 → plain-0.66.0}/plain/cli/utils.py +0 -0
  57. {plain-0.65.0 → plain-0.66.0}/plain/csrf/README.md +0 -0
  58. {plain-0.65.0 → plain-0.66.0}/plain/csrf/views.py +0 -0
  59. {plain-0.65.0 → plain-0.66.0}/plain/debug.py +0 -0
  60. {plain-0.65.0 → plain-0.66.0}/plain/forms/README.md +0 -0
  61. {plain-0.65.0 → plain-0.66.0}/plain/forms/__init__.py +0 -0
  62. {plain-0.65.0 → plain-0.66.0}/plain/forms/boundfield.py +0 -0
  63. {plain-0.65.0 → plain-0.66.0}/plain/forms/exceptions.py +0 -0
  64. {plain-0.65.0 → plain-0.66.0}/plain/forms/fields.py +0 -0
  65. {plain-0.65.0 → plain-0.66.0}/plain/forms/forms.py +0 -0
  66. {plain-0.65.0 → plain-0.66.0}/plain/http/README.md +0 -0
  67. {plain-0.65.0 → plain-0.66.0}/plain/http/__init__.py +0 -0
  68. {plain-0.65.0 → plain-0.66.0}/plain/http/cookie.py +0 -0
  69. {plain-0.65.0 → plain-0.66.0}/plain/http/multipartparser.py +0 -0
  70. {plain-0.65.0 → plain-0.66.0}/plain/http/response.py +0 -0
  71. {plain-0.65.0 → plain-0.66.0}/plain/internal/__init__.py +0 -0
  72. {plain-0.65.0 → plain-0.66.0}/plain/internal/files/__init__.py +0 -0
  73. {plain-0.65.0 → plain-0.66.0}/plain/internal/files/base.py +0 -0
  74. {plain-0.65.0 → plain-0.66.0}/plain/internal/files/locks.py +0 -0
  75. {plain-0.65.0 → plain-0.66.0}/plain/internal/files/move.py +0 -0
  76. {plain-0.65.0 → plain-0.66.0}/plain/internal/files/temp.py +0 -0
  77. {plain-0.65.0 → plain-0.66.0}/plain/internal/files/uploadedfile.py +0 -0
  78. {plain-0.65.0 → plain-0.66.0}/plain/internal/files/uploadhandler.py +0 -0
  79. {plain-0.65.0 → plain-0.66.0}/plain/internal/files/utils.py +0 -0
  80. {plain-0.65.0 → plain-0.66.0}/plain/internal/handlers/__init__.py +0 -0
  81. {plain-0.65.0 → plain-0.66.0}/plain/internal/handlers/exception.py +0 -0
  82. {plain-0.65.0 → plain-0.66.0}/plain/internal/handlers/wsgi.py +0 -0
  83. {plain-0.65.0 → plain-0.66.0}/plain/internal/middleware/__init__.py +0 -0
  84. {plain-0.65.0 → plain-0.66.0}/plain/internal/middleware/headers.py +0 -0
  85. {plain-0.65.0 → plain-0.66.0}/plain/json.py +0 -0
  86. {plain-0.65.0 → plain-0.66.0}/plain/logs/README.md +0 -0
  87. {plain-0.65.0 → plain-0.66.0}/plain/logs/__init__.py +0 -0
  88. {plain-0.65.0 → plain-0.66.0}/plain/logs/configure.py +0 -0
  89. {plain-0.65.0 → plain-0.66.0}/plain/logs/debug.py +0 -0
  90. {plain-0.65.0 → plain-0.66.0}/plain/logs/formatters.py +0 -0
  91. {plain-0.65.0 → plain-0.66.0}/plain/logs/loggers.py +0 -0
  92. {plain-0.65.0 → plain-0.66.0}/plain/logs/utils.py +0 -0
  93. {plain-0.65.0 → plain-0.66.0}/plain/packages/README.md +0 -0
  94. {plain-0.65.0 → plain-0.66.0}/plain/packages/__init__.py +0 -0
  95. {plain-0.65.0 → plain-0.66.0}/plain/packages/config.py +0 -0
  96. {plain-0.65.0 → plain-0.66.0}/plain/packages/registry.py +0 -0
  97. {plain-0.65.0 → plain-0.66.0}/plain/paginator.py +0 -0
  98. {plain-0.65.0 → plain-0.66.0}/plain/preflight/README.md +0 -0
  99. {plain-0.65.0 → plain-0.66.0}/plain/preflight/__init__.py +0 -0
  100. {plain-0.65.0 → plain-0.66.0}/plain/preflight/files.py +0 -0
  101. {plain-0.65.0 → plain-0.66.0}/plain/preflight/messages.py +0 -0
  102. {plain-0.65.0 → plain-0.66.0}/plain/preflight/registry.py +0 -0
  103. {plain-0.65.0 → plain-0.66.0}/plain/preflight/security.py +0 -0
  104. {plain-0.65.0 → plain-0.66.0}/plain/preflight/urls.py +0 -0
  105. {plain-0.65.0 → plain-0.66.0}/plain/runtime/README.md +0 -0
  106. {plain-0.65.0 → plain-0.66.0}/plain/runtime/__init__.py +0 -0
  107. {plain-0.65.0 → plain-0.66.0}/plain/runtime/user_settings.py +0 -0
  108. {plain-0.65.0 → plain-0.66.0}/plain/runtime/utils.py +0 -0
  109. {plain-0.65.0 → plain-0.66.0}/plain/signals/README.md +0 -0
  110. {plain-0.65.0 → plain-0.66.0}/plain/signals/__init__.py +0 -0
  111. {plain-0.65.0 → plain-0.66.0}/plain/signals/dispatch/__init__.py +0 -0
  112. {plain-0.65.0 → plain-0.66.0}/plain/signals/dispatch/dispatcher.py +0 -0
  113. {plain-0.65.0 → plain-0.66.0}/plain/signals/dispatch/license.txt +0 -0
  114. {plain-0.65.0 → plain-0.66.0}/plain/signing.py +0 -0
  115. {plain-0.65.0 → plain-0.66.0}/plain/templates/AGENTS.md +0 -0
  116. {plain-0.65.0 → plain-0.66.0}/plain/templates/README.md +0 -0
  117. {plain-0.65.0 → plain-0.66.0}/plain/templates/__init__.py +0 -0
  118. {plain-0.65.0 → plain-0.66.0}/plain/templates/core.py +0 -0
  119. {plain-0.65.0 → plain-0.66.0}/plain/templates/jinja/__init__.py +0 -0
  120. {plain-0.65.0 → plain-0.66.0}/plain/templates/jinja/environments.py +0 -0
  121. {plain-0.65.0 → plain-0.66.0}/plain/templates/jinja/extensions.py +0 -0
  122. {plain-0.65.0 → plain-0.66.0}/plain/templates/jinja/filters.py +0 -0
  123. {plain-0.65.0 → plain-0.66.0}/plain/templates/jinja/globals.py +0 -0
  124. {plain-0.65.0 → plain-0.66.0}/plain/test/README.md +0 -0
  125. {plain-0.65.0 → plain-0.66.0}/plain/test/__init__.py +0 -0
  126. {plain-0.65.0 → plain-0.66.0}/plain/test/client.py +0 -0
  127. {plain-0.65.0 → plain-0.66.0}/plain/test/encoding.py +0 -0
  128. {plain-0.65.0 → plain-0.66.0}/plain/test/exceptions.py +0 -0
  129. {plain-0.65.0 → plain-0.66.0}/plain/urls/README.md +0 -0
  130. {plain-0.65.0 → plain-0.66.0}/plain/urls/__init__.py +0 -0
  131. {plain-0.65.0 → plain-0.66.0}/plain/urls/converters.py +0 -0
  132. {plain-0.65.0 → plain-0.66.0}/plain/urls/exceptions.py +0 -0
  133. {plain-0.65.0 → plain-0.66.0}/plain/urls/patterns.py +0 -0
  134. {plain-0.65.0 → plain-0.66.0}/plain/urls/resolvers.py +0 -0
  135. {plain-0.65.0 → plain-0.66.0}/plain/urls/routers.py +0 -0
  136. {plain-0.65.0 → plain-0.66.0}/plain/urls/utils.py +0 -0
  137. {plain-0.65.0 → plain-0.66.0}/plain/utils/README.md +0 -0
  138. {plain-0.65.0 → plain-0.66.0}/plain/utils/__init__.py +0 -0
  139. {plain-0.65.0 → plain-0.66.0}/plain/utils/cache.py +0 -0
  140. {plain-0.65.0 → plain-0.66.0}/plain/utils/crypto.py +0 -0
  141. {plain-0.65.0 → plain-0.66.0}/plain/utils/datastructures.py +0 -0
  142. {plain-0.65.0 → plain-0.66.0}/plain/utils/dateparse.py +0 -0
  143. {plain-0.65.0 → plain-0.66.0}/plain/utils/deconstruct.py +0 -0
  144. {plain-0.65.0 → plain-0.66.0}/plain/utils/decorators.py +0 -0
  145. {plain-0.65.0 → plain-0.66.0}/plain/utils/duration.py +0 -0
  146. {plain-0.65.0 → plain-0.66.0}/plain/utils/encoding.py +0 -0
  147. {plain-0.65.0 → plain-0.66.0}/plain/utils/functional.py +0 -0
  148. {plain-0.65.0 → plain-0.66.0}/plain/utils/hashable.py +0 -0
  149. {plain-0.65.0 → plain-0.66.0}/plain/utils/html.py +0 -0
  150. {plain-0.65.0 → plain-0.66.0}/plain/utils/http.py +0 -0
  151. {plain-0.65.0 → plain-0.66.0}/plain/utils/inspect.py +0 -0
  152. {plain-0.65.0 → plain-0.66.0}/plain/utils/ipv6.py +0 -0
  153. {plain-0.65.0 → plain-0.66.0}/plain/utils/itercompat.py +0 -0
  154. {plain-0.65.0 → plain-0.66.0}/plain/utils/module_loading.py +0 -0
  155. {plain-0.65.0 → plain-0.66.0}/plain/utils/regex_helper.py +0 -0
  156. {plain-0.65.0 → plain-0.66.0}/plain/utils/safestring.py +0 -0
  157. {plain-0.65.0 → plain-0.66.0}/plain/utils/text.py +0 -0
  158. {plain-0.65.0 → plain-0.66.0}/plain/utils/timesince.py +0 -0
  159. {plain-0.65.0 → plain-0.66.0}/plain/utils/timezone.py +0 -0
  160. {plain-0.65.0 → plain-0.66.0}/plain/utils/tree.py +0 -0
  161. {plain-0.65.0 → plain-0.66.0}/plain/validators.py +0 -0
  162. {plain-0.65.0 → plain-0.66.0}/plain/views/README.md +0 -0
  163. {plain-0.65.0 → plain-0.66.0}/plain/views/__init__.py +0 -0
  164. {plain-0.65.0 → plain-0.66.0}/plain/views/base.py +0 -0
  165. {plain-0.65.0 → plain-0.66.0}/plain/views/errors.py +0 -0
  166. {plain-0.65.0 → plain-0.66.0}/plain/views/exceptions.py +0 -0
  167. {plain-0.65.0 → plain-0.66.0}/plain/views/forms.py +0 -0
  168. {plain-0.65.0 → plain-0.66.0}/plain/views/objects.py +0 -0
  169. {plain-0.65.0 → plain-0.66.0}/plain/views/redirect.py +0 -0
  170. {plain-0.65.0 → plain-0.66.0}/plain/views/templates.py +0 -0
  171. {plain-0.65.0 → plain-0.66.0}/plain/wsgi.py +0 -0
  172. {plain-0.65.0 → plain-0.66.0}/tests/.gitignore +0 -0
  173. {plain-0.65.0 → plain-0.66.0}/tests/app/.gitignore +0 -0
  174. {plain-0.65.0 → plain-0.66.0}/tests/app/test/__init__.py +0 -0
  175. {plain-0.65.0 → plain-0.66.0}/tests/app/test/default_settings.py +0 -0
  176. {plain-0.65.0 → plain-0.66.0}/tests/app/urls.py +0 -0
  177. {plain-0.65.0 → plain-0.66.0}/tests/conftest.py +0 -0
  178. {plain-0.65.0 → plain-0.66.0}/tests/test_cli.py +0 -0
  179. {plain-0.65.0 → plain-0.66.0}/tests/test_csrf.py +0 -0
  180. {plain-0.65.0 → plain-0.66.0}/tests/test_logs.py +0 -0
  181. {plain-0.65.0 → plain-0.66.0}/tests/test_runtime.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.65.0
3
+ Version: 0.66.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,30 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.66.0](https://github.com/dropseed/plain/releases/plain@0.66.0) (2025-09-22)
4
+
5
+ ### What's changed
6
+
7
+ - Host validation moved to dedicated middleware and `ALLOWED_HOSTS` setting is now required ([6a4b7be](https://github.com/dropseed/plain/commit/6a4b7be220))
8
+ - Changed `request.get_port()` method to `request.port` cached property ([544f3e1](https://github.com/dropseed/plain/commit/544f3e19f8))
9
+ - Removed internal `request._get_full_path()` method ([50cdb58](https://github.com/dropseed/plain/commit/50cdb58d4e))
10
+
11
+ ### Upgrade instructions
12
+
13
+ - Add `ALLOWED_HOSTS` setting to your configuration if not already present (required for host validation)
14
+ - Replace any usage of `request.get_host()` with `request.host`
15
+ - Replace any usage of `request.get_port()` with `request.port`
16
+
17
+ ## [0.65.1](https://github.com/dropseed/plain/releases/plain@0.65.1) (2025-09-22)
18
+
19
+ ### What's changed
20
+
21
+ - Fixed DisallowedHost exception handling in request span attributes to prevent telemetry errors ([bcc0005](https://github.com/dropseed/plain/commit/bcc000575b))
22
+ - Removed cached property optimization for scheme/host to improve request processing reliability ([3a52690](https://github.com/dropseed/plain/commit/3a52690d47))
23
+
24
+ ### Upgrade instructions
25
+
26
+ - No changes required
27
+
3
28
  ## [0.65.0](https://github.com/dropseed/plain/releases/plain@0.65.0) (2025-09-22)
4
29
 
5
30
  ### What's changed
@@ -2,7 +2,6 @@ import logging
2
2
  import re
3
3
  from urllib.parse import urlparse
4
4
 
5
- from plain.exceptions import DisallowedHost
6
5
  from plain.logs.utils import log_response
7
6
  from plain.runtime import settings
8
7
 
@@ -81,8 +80,8 @@ class CsrfViewMiddleware:
81
80
  if origin == "null":
82
81
  return False, "Cross-origin request detected - null Origin header"
83
82
 
84
- try:
85
- if (parsed_origin := urlparse(origin)) and (host := request.get_host()):
83
+ if (parsed_origin := urlparse(origin)) and (host := request.host):
84
+ try:
86
85
  # Scheme-agnostic host:port comparison
87
86
  origin_host = parsed_origin.hostname
88
87
  origin_port = parsed_origin.port or (
@@ -97,7 +96,7 @@ class CsrfViewMiddleware:
97
96
  # Use a fake scheme since we only care about host parsing
98
97
  parsed_host = urlparse(f"http://{host}")
99
98
  request_host = parsed_host.hostname or host
100
- request_port = request.get_port()
99
+ request_port = request.port
101
100
 
102
101
  # Compare hostname and port (scheme-agnostic)
103
102
  # Both origin_host and request_host are normalized by urlparse (IPv6 brackets stripped)
@@ -110,8 +109,8 @@ class CsrfViewMiddleware:
110
109
  True,
111
110
  f"Same-origin request - Origin {origin} matches Host {host}",
112
111
  )
113
- except (ValueError, DisallowedHost):
114
- pass
112
+ except ValueError:
113
+ pass
115
114
 
116
115
  # Origin present but doesn't match host
117
116
  return (
@@ -67,12 +67,6 @@ class SuspiciousFileOperation(SuspiciousOperation):
67
67
  pass
68
68
 
69
69
 
70
- class DisallowedHost(SuspiciousOperation):
71
- """HTTP_HOST header contains invalid value"""
72
-
73
- pass
74
-
75
-
76
70
  class TooManyFieldsSent(SuspiciousOperation):
77
71
  """
78
72
  The number of fields in a GET or POST request exceeded
@@ -8,7 +8,6 @@ from itertools import chain
8
8
  from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit
9
9
 
10
10
  from plain.exceptions import (
11
- DisallowedHost,
12
11
  ImproperlyConfigured,
13
12
  RequestDataTooBig,
14
13
  TooManyFieldsSent,
@@ -29,8 +28,6 @@ from plain.utils.datastructures import (
29
28
  from plain.utils.encoding import iri_to_uri
30
29
  from plain.utils.http import parse_header_parameters
31
30
 
32
- from .hosts import split_domain_port, validate_host
33
-
34
31
 
35
32
  class UnreadablePostError(OSError):
36
33
  pass
@@ -123,10 +120,13 @@ class HttpRequest:
123
120
  else:
124
121
  self.encoding = self.content_params["charset"]
125
122
 
126
- def _get_raw_host(self):
123
+ @cached_property
124
+ def host(self):
127
125
  """
128
- Return the HTTP host using the environment or request headers. Skip
129
- allowed hosts protection, so may return an insecure host.
126
+ Return the HTTP host using the environment or request headers.
127
+
128
+ Host validation is performed by HostValidationMiddleware, so this
129
+ property can safely return the host without any validation.
130
130
  """
131
131
  # We try three options, in order of decreasing preference.
132
132
  if settings.USE_X_FORWARDED_HOST and ("HTTP_X_FORWARDED_HOST" in self.meta):
@@ -136,34 +136,13 @@ class HttpRequest:
136
136
  else:
137
137
  # Reconstruct the host using the algorithm from PEP 333.
138
138
  host = self.meta["SERVER_NAME"]
139
- server_port = self.get_port()
139
+ server_port = self.port
140
140
  if server_port != ("443" if self.is_https() else "80"):
141
141
  host = f"{host}:{server_port}"
142
142
  return host
143
143
 
144
- def get_host(self):
145
- """Return the HTTP host using the environment or request headers."""
146
- host = self._get_raw_host()
147
-
148
- # Allow variants of localhost if ALLOWED_HOSTS is empty and DEBUG=True.
149
- allowed_hosts = settings.ALLOWED_HOSTS
150
- if settings.DEBUG and not allowed_hosts:
151
- allowed_hosts = [".localhost", "127.0.0.1", "[::1]"]
152
-
153
- domain, port = split_domain_port(host)
154
- if domain and validate_host(domain, allowed_hosts):
155
- return host
156
- else:
157
- msg = f"Invalid HTTP_HOST header: {host!r}."
158
- if domain:
159
- msg += f" You may need to add {domain!r} to ALLOWED_HOSTS."
160
- else:
161
- msg += (
162
- " The domain name provided is not valid according to RFC 1034/1035."
163
- )
164
- raise DisallowedHost(msg)
165
-
166
- def get_port(self):
144
+ @cached_property
145
+ def port(self):
167
146
  """Return the port number for the request as a string."""
168
147
  if settings.USE_X_FORWARDED_PORT and "HTTP_X_FORWARDED_PORT" in self.meta:
169
148
  port = self.meta["HTTP_X_FORWARDED_PORT"]
@@ -172,9 +151,12 @@ class HttpRequest:
172
151
  return str(port)
173
152
 
174
153
  def get_full_path(self, force_append_slash=False):
175
- return self._get_full_path(self.path, force_append_slash)
154
+ """
155
+ Return the full path for the request, including query string.
176
156
 
177
- def _get_full_path(self, path, force_append_slash):
157
+ If force_append_slash is True, append a trailing slash if the path
158
+ doesn't already end with one.
159
+ """
178
160
  # RFC 3986 requires query string arguments to be in the ASCII range.
179
161
  # Rather than crash if this doesn't happen, we encode defensively.
180
162
 
@@ -195,8 +177,8 @@ class HttpRequest:
195
177
  return quote(path, safe="/:@&+$,-_.!~*'()")
196
178
 
197
179
  return "{}{}{}".format(
198
- escape_uri_path(path),
199
- "/" if force_append_slash and not path.endswith("/") else "",
180
+ escape_uri_path(self.path),
181
+ "/" if force_append_slash and not self.path.endswith("/") else "",
200
182
  ("?" + iri_to_uri(self.meta.get("QUERY_STRING", "")))
201
183
  if self.meta.get("QUERY_STRING", "")
202
184
  else "",
@@ -220,6 +202,8 @@ class HttpRequest:
220
202
  location = str(location)
221
203
  bits = urlsplit(location)
222
204
  if not (bits.scheme and bits.netloc):
205
+ current_scheme_host = f"{self.scheme}://{self.host}"
206
+
223
207
  # Handle the simple, most common case. If the location is absolute
224
208
  # and a scheme or host (netloc) isn't provided, skip an expensive
225
209
  # urljoin() as long as no path segments are '.' or '..'.
@@ -233,17 +217,14 @@ class HttpRequest:
233
217
  # If location starts with '//' but has no netloc, reuse the
234
218
  # schema and netloc from the current request. Strip the double
235
219
  # slashes and continue as if it wasn't specified.
236
- location = self._current_scheme_host + location.removeprefix("//")
220
+ location = current_scheme_host + location.removeprefix("//")
237
221
  else:
238
222
  # Join the constructed URL with the provided location, which
239
223
  # allows the provided location to apply query strings to the
240
224
  # base path.
241
- location = urljoin(self._current_scheme_host + self.path, location)
242
- return iri_to_uri(location)
225
+ location = urljoin(current_scheme_host + self.path, location)
243
226
 
244
- @cached_property
245
- def _current_scheme_host(self):
246
- return f"{self.scheme}://{self.get_host()}"
227
+ return iri_to_uri(location)
247
228
 
248
229
  def _get_scheme(self):
249
230
  """
@@ -17,6 +17,7 @@ logger = logging.getLogger("plain.request")
17
17
 
18
18
  # These middleware classes are always used by Plain.
19
19
  BUILTIN_BEFORE_MIDDLEWARE = [
20
+ "plain.internal.middleware.hosts.HostValidationMiddleware", # Validate Host header first
20
21
  "plain.internal.middleware.headers.DefaultHeadersMiddleware", # Runs after response, to set missing headers
21
22
  "plain.internal.middleware.https.HttpsRedirectMiddleware", # Runs before response, to redirect to HTTPS quickly
22
23
  "plain.csrf.middleware.CsrfViewMiddleware", # Runs before and after get_response...
@@ -1,19 +1,65 @@
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
1
  import ipaddress
2
+ import logging
9
3
 
4
+ from plain.http import HttpRequest, ResponseBadRequest
5
+ from plain.runtime import settings
10
6
  from plain.utils.regex_helper import _lazy_re_compile
11
7
 
8
+ logger = logging.getLogger(__name__)
9
+
12
10
  host_validation_re = _lazy_re_compile(
13
11
  r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:[0-9]+)?$"
14
12
  )
15
13
 
16
14
 
15
+ class HostValidationMiddleware:
16
+ """
17
+ Middleware to validate the Host header against ALLOWED_HOSTS.
18
+
19
+ This middleware should run first to ensure all subsequent code can trust
20
+ that the host header is valid. Returns a 400 Bad Request response if the
21
+ host is not allowed.
22
+ """
23
+
24
+ def __init__(self, get_response):
25
+ self.get_response = get_response
26
+
27
+ def __call__(self, request):
28
+ if not is_host_valid(request):
29
+ host = request.host
30
+ msg = f"Invalid HTTP_HOST header: {host!r}."
31
+
32
+ domain, _ = split_domain_port(host)
33
+ if domain:
34
+ msg += f" You may need to add {domain!r} to ALLOWED_HOSTS."
35
+ else:
36
+ msg += (
37
+ " The domain name provided is not valid according to RFC 1034/1035."
38
+ )
39
+
40
+ logger.warning(
41
+ msg,
42
+ extra={"status_code": 400, "request": request},
43
+ )
44
+
45
+ return ResponseBadRequest()
46
+
47
+ return self.get_response(request)
48
+
49
+
50
+ def is_host_valid(request: HttpRequest) -> bool:
51
+ """
52
+ Check if the host is valid according to ALLOWED_HOSTS settings.
53
+ """
54
+ allowed_hosts = settings.ALLOWED_HOSTS
55
+
56
+ if not allowed_hosts:
57
+ return True # Allow all hosts if ALLOWED_HOSTS is empty
58
+
59
+ domain, _ = split_domain_port(request.host)
60
+ return bool(domain) and validate_host(domain, allowed_hosts)
61
+
62
+
17
63
  def split_domain_port(host: str) -> tuple[str, str]:
18
64
  """
19
65
  Return a (domain, port) tuple from a given host.
@@ -36,7 +82,7 @@ def split_domain_port(host: str) -> tuple[str, str]:
36
82
  return domain, port
37
83
 
38
84
 
39
- def _is_same_domain(host: str, pattern: str) -> bool:
85
+ def is_same_domain(host: str, pattern: str) -> bool:
40
86
  """
41
87
  Return ``True`` if the host is either an exact match or a match
42
88
  to the wildcard pattern.
@@ -56,7 +102,7 @@ def _is_same_domain(host: str, pattern: str) -> bool:
56
102
  )
57
103
 
58
104
 
59
- def _parse_ip_address(
105
+ def parse_ip_address(
60
106
  host: str,
61
107
  ) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
62
108
  """
@@ -75,7 +121,7 @@ def _parse_ip_address(
75
121
  return None
76
122
 
77
123
 
78
- def _parse_cidr_pattern(
124
+ def parse_cidr_pattern(
79
125
  pattern: str,
80
126
  ) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None:
81
127
  """
@@ -127,7 +173,7 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
127
173
  Return ``True`` for a valid host, ``False`` otherwise.
128
174
  """
129
175
  # Parse the host as an IP address if possible
130
- host_ip = _parse_ip_address(host)
176
+ host_ip = parse_ip_address(host)
131
177
 
132
178
  for pattern in allowed_hosts:
133
179
  # Wildcard matches everything
@@ -135,13 +181,13 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
135
181
  return True
136
182
 
137
183
  # Check CIDR notation patterns using walrus operator
138
- if network := _parse_cidr_pattern(pattern):
184
+ if network := parse_cidr_pattern(pattern):
139
185
  if host_ip and host_ip in network:
140
186
  return True
141
187
  continue
142
188
 
143
189
  # For non-CIDR patterns, use existing domain matching logic
144
- if _is_same_domain(host, pattern):
190
+ if is_same_domain(host, pattern):
145
191
  return True
146
192
 
147
193
  return False
@@ -21,7 +21,6 @@ class HttpsRedirectMiddleware:
21
21
 
22
22
  def maybe_https_redirect(self, request):
23
23
  if self.https_redirect_enabled and not request.is_https():
24
- host = request.get_host()
25
24
  return ResponseRedirect(
26
- f"https://{host}{request.get_full_path()}", status_code=301
25
+ f"https://{request.host}{request.get_full_path()}", status_code=301
27
26
  )
@@ -65,7 +65,7 @@ class RedirectSlashMiddleware:
65
65
  f"You called this URL via {request.method}, but the URL doesn't end "
66
66
  "in a slash and you have APPEND_SLASH set. Plain can't "
67
67
  f"redirect to the slash URL while maintaining {request.method} data. "
68
- f"Change your form to point to {request.get_host() + new_path} (note the trailing "
68
+ f"Change your form to point to {request.host + new_path} (note the trailing "
69
69
  "slash), or set APPEND_SLASH=False in your Plain settings."
70
70
  )
71
71
  return new_path
@@ -25,7 +25,7 @@ URLS_ROUTER: str
25
25
  # Hosts/domain names that are valid for this site.
26
26
  # "*" matches anything, ".example.com" matches example.com and all subdomains
27
27
  # "192.168.1.0/24" matches IP addresses in that CIDR range
28
- ALLOWED_HOSTS: list[str] = []
28
+ ALLOWED_HOSTS: list[str]
29
29
 
30
30
  # Default headers for all responses.
31
31
  DEFAULT_RESPONSE_HEADERS = {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain"
3
- version = "0.65.0"
3
+ version = "0.66.0"
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"
@@ -1,3 +1,4 @@
1
+ ALLOWED_HOSTS = []
1
2
  SECRET_KEY = "secret"
2
3
  DEBUG = True
3
4
 
@@ -1,6 +1,6 @@
1
1
  import pytest
2
2
 
3
- from plain.http.hosts import split_domain_port, validate_host
3
+ from plain.internal.middleware.hosts import split_domain_port, validate_host
4
4
 
5
5
 
6
6
  @pytest.mark.parametrize(
@@ -8,7 +8,6 @@ def test_wsgi_handler():
8
8
  Test the default plain.wsgi.app import and
9
9
  basic behavior with minimal environ input.
10
10
  """
11
-
12
11
  wsgi = WSGIHandler()
13
12
  response = wsgi(
14
13
  {
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
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
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
File without changes
File without changes
File without changes