plain 0.65.1__py3-none-any.whl → 0.67.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plain/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.67.0](https://github.com/dropseed/plain/releases/plain@0.67.0) (2025-09-22)
4
+
5
+ ### What's changed
6
+
7
+ - `ALLOWED_HOSTS` now defaults to `[]` (empty list) which allows all hosts, making it easier for development setups ([d3cb7712b9](https://github.com/dropseed/plain/commit/d3cb7712b9))
8
+ - Empty `ALLOWED_HOSTS` in production now triggers a preflight error instead of a warning to ensure proper security configuration ([d3cb7712b9](https://github.com/dropseed/plain/commit/d3cb7712b9))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
14
+ ## [0.66.0](https://github.com/dropseed/plain/releases/plain@0.66.0) (2025-09-22)
15
+
16
+ ### What's changed
17
+
18
+ - Host validation moved to dedicated middleware and `ALLOWED_HOSTS` setting is now required ([6a4b7be](https://github.com/dropseed/plain/commit/6a4b7be220))
19
+ - Changed `request.get_port()` method to `request.port` cached property ([544f3e1](https://github.com/dropseed/plain/commit/544f3e19f8))
20
+ - Removed internal `request._get_full_path()` method ([50cdb58](https://github.com/dropseed/plain/commit/50cdb58d4e))
21
+
22
+ ### Upgrade instructions
23
+
24
+ - Add `ALLOWED_HOSTS` setting to your configuration if not already present (required for host validation)
25
+ - Replace any usage of `request.get_host()` with `request.host`
26
+ - Replace any usage of `request.get_port()` with `request.port`
27
+
3
28
  ## [0.65.1](https://github.com/dropseed/plain/releases/plain@0.65.1) (2025-09-22)
4
29
 
5
30
  ### What's changed
@@ -46,136 +46,127 @@ def request(path, method, data, user_id, follow, content_type, headers):
46
46
  click.secho("This command only works when DEBUG=True", fg="red", err=True)
47
47
  return
48
48
 
49
- # Temporarily add testserver to ALLOWED_HOSTS so the test client can make requests
50
- original_allowed_hosts = settings.ALLOWED_HOSTS
51
- settings.ALLOWED_HOSTS = ["*"]
49
+ # Create test client
50
+ client = Client()
52
51
 
53
- try:
54
- # Create test client
55
- client = Client()
52
+ # If user_id provided, force login
53
+ if user_id:
54
+ try:
55
+ # Get the User model using plain.auth utility
56
+ from plain.auth import get_user_model
56
57
 
57
- # If user_id provided, force login
58
- if user_id:
59
- try:
60
- # Get the User model using plain.auth utility
61
- from plain.auth import get_user_model
62
-
63
- User = get_user_model()
64
-
65
- # Get the user
66
- try:
67
- user = User.query.get(id=user_id)
68
- client.force_login(user)
69
- click.secho(
70
- f"Authenticated as user {user_id}", fg="green", dim=True
71
- )
72
- except User.DoesNotExist:
73
- click.secho(f"User {user_id} not found", fg="red", err=True)
74
- return
75
-
76
- except Exception as e:
77
- click.secho(f"Authentication error: {e}", fg="red", err=True)
78
- return
79
-
80
- # Parse additional headers
81
- header_dict = {}
82
- for header in headers:
83
- if ":" in header:
84
- key, value = header.split(":", 1)
85
- header_dict[key.strip()] = value.strip()
58
+ User = get_user_model()
86
59
 
87
- # Prepare request data
88
- if data and content_type and "json" in content_type.lower():
60
+ # Get the user
89
61
  try:
90
- # Validate JSON
91
- json.loads(data)
92
- except json.JSONDecodeError as e:
93
- click.secho(f"Invalid JSON data: {e}", fg="red", err=True)
62
+ user = User.query.get(id=user_id)
63
+ client.force_login(user)
64
+ click.secho(
65
+ f"Authenticated as user {user_id}", fg="green", dim=True
66
+ )
67
+ except User.DoesNotExist:
68
+ click.secho(f"User {user_id} not found", fg="red", err=True)
94
69
  return
95
70
 
96
- # Make the request
97
- method = method.upper()
98
- kwargs = {
99
- "path": path,
100
- "follow": follow,
101
- "headers": header_dict or None,
102
- }
103
-
104
- if method in ("POST", "PUT", "PATCH") and data:
105
- kwargs["data"] = data
106
- if content_type:
107
- kwargs["content_type"] = content_type
108
-
109
- # Call the appropriate client method
110
- if method == "GET":
111
- response = client.get(**kwargs)
112
- elif method == "POST":
113
- response = client.post(**kwargs)
114
- elif method == "PUT":
115
- response = client.put(**kwargs)
116
- elif method == "PATCH":
117
- response = client.patch(**kwargs)
118
- elif method == "DELETE":
119
- response = client.delete(**kwargs)
120
- elif method == "HEAD":
121
- response = client.head(**kwargs)
122
- elif method == "OPTIONS":
123
- response = client.options(**kwargs)
124
- elif method == "TRACE":
125
- response = client.trace(**kwargs)
126
- else:
127
- click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
71
+ except Exception as e:
72
+ click.secho(f"Authentication error: {e}", fg="red", err=True)
73
+ return
74
+
75
+ # Parse additional headers
76
+ header_dict = {}
77
+ for header in headers:
78
+ if ":" in header:
79
+ key, value = header.split(":", 1)
80
+ header_dict[key.strip()] = value.strip()
81
+
82
+ # Prepare request data
83
+ if data and content_type and "json" in content_type.lower():
84
+ try:
85
+ # Validate JSON
86
+ json.loads(data)
87
+ except json.JSONDecodeError as e:
88
+ click.secho(f"Invalid JSON data: {e}", fg="red", err=True)
128
89
  return
129
90
 
130
- # Display response information
131
- click.secho(
132
- f"HTTP {response.status_code}",
133
- fg="green" if response.status_code < 400 else "red",
134
- bold=True,
135
- )
136
-
137
- # Show additional response info first
138
- if hasattr(response, "user"):
139
- click.secho(f"Authenticated user: {response.user}", fg="blue", dim=True)
140
-
141
- if hasattr(response, "resolver_match") and response.resolver_match:
142
- match = response.resolver_match
143
- url_name = match.namespaced_url_name or match.url_name or "unnamed"
144
- click.secho(f"URL pattern matched: {url_name}", fg="blue", dim=True)
145
-
146
- # Show headers
147
- if response.headers:
148
- click.secho("Response Headers:", fg="yellow", bold=True)
149
- for key, value in response.headers.items():
150
- click.echo(f" {key}: {value}")
151
- click.echo()
152
-
153
- # Show response content last
154
- if response.content:
155
- content_type = response.headers.get("Content-Type", "")
156
-
157
- if "json" in content_type.lower():
158
- try:
159
- json_data = response.json()
160
- click.secho("Response Body (JSON):", fg="yellow", bold=True)
161
- click.echo(json.dumps(json_data, indent=2))
162
- except Exception:
163
- click.secho("Response Body:", fg="yellow", bold=True)
164
- click.echo(response.content.decode("utf-8", errors="replace"))
165
- elif "html" in content_type.lower():
166
- click.secho("Response Body (HTML):", fg="yellow", bold=True)
167
- content = response.content.decode("utf-8", errors="replace")
168
- click.echo(content)
169
- else:
91
+ # Make the request
92
+ method = method.upper()
93
+ kwargs = {
94
+ "path": path,
95
+ "follow": follow,
96
+ "headers": header_dict or None,
97
+ }
98
+
99
+ if method in ("POST", "PUT", "PATCH") and data:
100
+ kwargs["data"] = data
101
+ if content_type:
102
+ kwargs["content_type"] = content_type
103
+
104
+ # Call the appropriate client method
105
+ if method == "GET":
106
+ response = client.get(**kwargs)
107
+ elif method == "POST":
108
+ response = client.post(**kwargs)
109
+ elif method == "PUT":
110
+ response = client.put(**kwargs)
111
+ elif method == "PATCH":
112
+ response = client.patch(**kwargs)
113
+ elif method == "DELETE":
114
+ response = client.delete(**kwargs)
115
+ elif method == "HEAD":
116
+ response = client.head(**kwargs)
117
+ elif method == "OPTIONS":
118
+ response = client.options(**kwargs)
119
+ elif method == "TRACE":
120
+ response = client.trace(**kwargs)
121
+ else:
122
+ click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
123
+ return
124
+
125
+ # Display response information
126
+ click.secho(
127
+ f"HTTP {response.status_code}",
128
+ fg="green" if response.status_code < 400 else "red",
129
+ bold=True,
130
+ )
131
+
132
+ # Show additional response info first
133
+ if hasattr(response, "user"):
134
+ click.secho(f"Authenticated user: {response.user}", fg="blue", dim=True)
135
+
136
+ if hasattr(response, "resolver_match") and response.resolver_match:
137
+ match = response.resolver_match
138
+ url_name = match.namespaced_url_name or match.url_name or "unnamed"
139
+ click.secho(f"URL pattern matched: {url_name}", fg="blue", dim=True)
140
+
141
+ # Show headers
142
+ if response.headers:
143
+ click.secho("Response Headers:", fg="yellow", bold=True)
144
+ for key, value in response.headers.items():
145
+ click.echo(f" {key}: {value}")
146
+ click.echo()
147
+
148
+ # Show response content last
149
+ if response.content:
150
+ content_type = response.headers.get("Content-Type", "")
151
+
152
+ if "json" in content_type.lower():
153
+ try:
154
+ json_data = response.json()
155
+ click.secho("Response Body (JSON):", fg="yellow", bold=True)
156
+ click.echo(json.dumps(json_data, indent=2))
157
+ except Exception:
170
158
  click.secho("Response Body:", fg="yellow", bold=True)
171
- content = response.content.decode("utf-8", errors="replace")
172
- click.echo(content)
159
+ click.echo(response.content.decode("utf-8", errors="replace"))
160
+ elif "html" in content_type.lower():
161
+ click.secho("Response Body (HTML):", fg="yellow", bold=True)
162
+ content = response.content.decode("utf-8", errors="replace")
163
+ click.echo(content)
173
164
  else:
174
- click.secho("(No response body)", fg="yellow", dim=True)
175
-
176
- finally:
177
- # Restore original ALLOWED_HOSTS
178
- settings.ALLOWED_HOSTS = original_allowed_hosts
165
+ click.secho("Response Body:", fg="yellow", bold=True)
166
+ content = response.content.decode("utf-8", errors="replace")
167
+ click.echo(content)
168
+ else:
169
+ click.secho("(No response body)", fg="yellow", dim=True)
179
170
 
180
171
  except Exception as e:
181
172
  click.secho(f"Request failed: {e}", fg="red", err=True)
plain/csrf/middleware.py 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 (
plain/exceptions.py CHANGED
@@ -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
plain/http/request.py CHANGED
@@ -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,7 +202,7 @@ class HttpRequest:
220
202
  location = str(location)
221
203
  bits = urlsplit(location)
222
204
  if not (bits.scheme and bits.netloc):
223
- current_scheme_host = f"{self.scheme}://{self.get_host()}"
205
+ current_scheme_host = f"{self.scheme}://{self.host}"
224
206
 
225
207
  # Handle the simple, most common case. If the location is absolute
226
208
  # and a scheme or host (netloc) isn't provided, skip an expensive
@@ -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 DisallowedHost, ImproperlyConfigured
7
+ from plain.exceptions import 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
@@ -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...
@@ -78,10 +79,6 @@ class BaseHandler:
78
79
  except KeyError:
79
80
  # Missing required WSGI environment variables (e.g. in tests)
80
81
  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
85
82
 
86
83
  # Add query string if present
87
84
  if query_string := request.meta.get("QUERY_STRING"):
@@ -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
  """
@@ -113,7 +159,6 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
113
159
  Check that the host looks valid and matches a host or host pattern in the
114
160
  given list of ``allowed_hosts``. Supported patterns:
115
161
 
116
- - ``*`` matches anything
117
162
  - ``.example.com`` matches a domain and all its subdomains
118
163
  (e.g. ``example.com`` and ``sub.example.com``)
119
164
  - ``example.com`` matches exactly that domain
@@ -127,21 +172,17 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
127
172
  Return ``True`` for a valid host, ``False`` otherwise.
128
173
  """
129
174
  # Parse the host as an IP address if possible
130
- host_ip = _parse_ip_address(host)
175
+ host_ip = parse_ip_address(host)
131
176
 
132
177
  for pattern in allowed_hosts:
133
- # Wildcard matches everything
134
- if pattern == "*":
135
- return True
136
-
137
178
  # Check CIDR notation patterns using walrus operator
138
- if network := _parse_cidr_pattern(pattern):
179
+ if network := parse_cidr_pattern(pattern):
139
180
  if host_ip and host_ip in network:
140
181
  return True
141
182
  continue
142
183
 
143
184
  # For non-CIDR patterns, use existing domain matching logic
144
- if _is_same_domain(host, pattern):
185
+ if is_same_domain(host, pattern):
145
186
  return True
146
187
 
147
188
  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
plain/preflight/README.md CHANGED
@@ -51,11 +51,11 @@ def custom_deploy_check(package_configs, **kwargs):
51
51
 
52
52
  ## Silencing preflight checks
53
53
 
54
- The `settings.PREFLIGHT_SILENCED_CHECKS` setting can be used to silence individual checks by their ID (ex. `security.W020`).
54
+ The `settings.PREFLIGHT_SILENCED_CHECKS` setting can be used to silence individual checks by their ID (ex. `security.E020`).
55
55
 
56
56
  ```python
57
57
  # app/settings.py
58
58
  PREFLIGHT_SILENCED_CHECKS = [
59
- "security.W020",
59
+ "security.E020", # Allow empty ALLOWED_HOSTS in deployment
60
60
  ]
61
61
  ```
@@ -1,7 +1,7 @@
1
1
  from plain.exceptions import ImproperlyConfigured
2
2
  from plain.runtime import settings
3
3
 
4
- from .messages import Warning
4
+ from .messages import Error, Warning
5
5
  from .registry import register_check
6
6
 
7
7
  SECRET_KEY_MIN_LENGTH = 50
@@ -81,9 +81,9 @@ def check_allowed_hosts(package_configs, **kwargs):
81
81
  []
82
82
  if settings.ALLOWED_HOSTS
83
83
  else [
84
- Warning(
84
+ Error(
85
85
  "ALLOWED_HOSTS must not be empty in deployment.",
86
- id="security.W020",
86
+ id="security.E020",
87
87
  )
88
88
  ]
89
89
  )
@@ -23,8 +23,9 @@ URLS_ROUTER: str
23
23
  # MARK: HTTP and Security
24
24
 
25
25
  # Hosts/domain names that are valid for this site.
26
- # "*" matches anything, ".example.com" matches example.com and all subdomains
27
- # "192.168.1.0/24" matches IP addresses in that CIDR range
26
+ # - An empty list [] allows all hosts (useful for development).
27
+ # - ".example.com" matches example.com and all subdomains
28
+ # - "192.168.1.0/24" matches IP addresses in that CIDR range
28
29
  ALLOWED_HOSTS: list[str] = []
29
30
 
30
31
  # Default headers for all responses.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.65.1
3
+ Version: 0.67.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,9 +1,9 @@
1
1
  plain/AGENTS.md,sha256=5XMGBpJgbCNIpp60DPXB7bpAtFk8FAzqiZke95T965o,1038
2
- plain/CHANGELOG.md,sha256=NlQC7IqcrH9tiUMqkCgyG6Pn7xQiaDUE3Xtfn6g4nIc,15292
2
+ plain/CHANGELOG.md,sha256=8Ljw7VTQP1cn6sUy_1Pum2cmHsnmeRqbL8oBIWvxw-s,16604
3
3
  plain/README.md,sha256=5BJyKhf0TDanWVbOQyZ3zsi5Lov9xk-LlJYCDWofM6Y,4078
4
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
5
5
  plain/debug.py,sha256=XdjnXcbPGsi0J2SpHGaLthhYU5AjhBlkHdemaP4sbYY,758
6
- plain/exceptions.py,sha256=ljOLqgVwPKlGWqaNmjHcHQf6y053bZ9ogkLFEGcs-Gg,5973
6
+ plain/exceptions.py,sha256=QepC5UG5IBGW3h2cimOqGtjslAzvYdLYIn6xHdyco-Y,5868
7
7
  plain/json.py,sha256=McJdsbMT1sYwkGRG--f2NSZz0hVXPMix9x3nKaaak2o,1262
8
8
  plain/paginator.py,sha256=iXiOyt2r_YwNrkqCRlaU7V-M_BKaaQ8XZElUBVa6yeU,5844
9
9
  plain/signing.py,sha256=i8Bf12c96u_1BZYjETiixhsLAWMAt_y4CIYZOsI6IVA,8295
@@ -44,9 +44,9 @@ plain/cli/agent/docs.py,sha256=ubX3ZeRHxVaetLk9fjiN9mJ07GZExC-CHUvQoX2DD7c,2464
44
44
  plain/cli/agent/llmdocs.py,sha256=AUpNDb1xSOsSpzGOiFvpzUe4f7PUGMiR9cI13aVZouo,5038
45
45
  plain/cli/agent/md.py,sha256=7r1II8ckubBFOZNGPASWaPmJdgByWFPINLqIOzRetLQ,2581
46
46
  plain/cli/agent/prompt.py,sha256=rugYyQHV7JDNqGrx3_PPShwwqYlnEVbxw8RsczOo8tg,1253
47
- plain/cli/agent/request.py,sha256=U4acLmpXlbFRjivrPtVv_r8DBts8OXg3m3-qotQxGL4,6547
47
+ plain/cli/agent/request.py,sha256=uUbZoAhSSjOLtzgAAzacvqCS_27H-AvoeqIxpzjUjAA,5800
48
48
  plain/csrf/README.md,sha256=ApWpB-qlEf0LkOKm9Yr-6f_lB9XJEvGFDo_fraw8ghI,2391
49
- plain/csrf/middleware.py,sha256=n5_7v6qwFKgiAnKVyJa7RhwHoWepLkPudzIgZtdku5A,5119
49
+ plain/csrf/middleware.py,sha256=KF8ngFadWdS0MHXC1dTLx-K3VtD6Xs-3RDjFqpiiEjQ,5053
50
50
  plain/csrf/views.py,sha256=HwQqfI6KPelHP9gSXhjfZaTLQic71PKsoZ6DPhr1rKI,572
51
51
  plain/forms/README.md,sha256=7MJQxNBoKkg0rW16qF6bGpUBxZrMrWjl2DZZk6gjzAU,2258
52
52
  plain/forms/__init__.py,sha256=UxqPwB8CiYPCQdHmUc59jadqaXqDmXBH8y4bt9vTPms,226
@@ -57,9 +57,8 @@ plain/forms/forms.py,sha256=hF7Dl8rEaiBTZhFQyfbh1Zf54BSEka8RYpBiGqkTa8I,10441
57
57
  plain/http/README.md,sha256=hkjTJJ2_WEGm7vaIxjjNHrzD6EN5VI6pjDJPIR9l1jo,760
58
58
  plain/http/__init__.py,sha256=PfmXBIq7onLO_bbOrVdj5rJeHxqJMYqrIobVKupUzUA,1003
59
59
  plain/http/cookie.py,sha256=THd7nOl-2ugeBPKgOhbD87aM2oxUbNH8HWrarUn0fpM,1955
60
- plain/http/hosts.py,sha256=GbvFzle2VgPC2ehtXW8MM_1XD9gKuv7h-8HTOX3RiJA,4567
61
60
  plain/http/multipartparser.py,sha256=Z1dFJNAd8N5RHUuF67jh1jBfZOFepORsre_3ee6CgOQ,27266
62
- plain/http/request.py,sha256=6kimxbIZMK2ckx366fQHDzrNTf6H8ih6YJ4y9yx2aAI,24623
61
+ plain/http/request.py,sha256=4VkejuFitAwlmHs45hV6g-BfZxKM-D9vqnNTTljzj28,23864
63
62
  plain/http/response.py,sha256=FM3otFkKEEkAaV_pVB3JUSMhMk7x_zf5JcDWKhOKviM,23223
64
63
  plain/internal/__init__.py,sha256=fVBaYLCXEQc-7riHMSlw3vMTTuF7-0Bj2I8aGzv0o0w,171
65
64
  plain/internal/files/__init__.py,sha256=VctFgox4Q1AWF3klPaoCC5GIw5KeLafYjY5JmN8mAVw,63
@@ -71,13 +70,14 @@ plain/internal/files/uploadedfile.py,sha256=JRB7T3quQjg-1y3l1ASPxywtSQZhaeMc45uF
71
70
  plain/internal/files/uploadhandler.py,sha256=63_QUwAwfq3bevw79i0S7zt2EB2UBoO7MaauvezaVMY,7198
72
71
  plain/internal/files/utils.py,sha256=xN4HTJXDRdcoNyrL1dFd528MBwodRlHZM8DGTD_oBIg,2646
73
72
  plain/internal/handlers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
74
- plain/internal/handlers/base.py,sha256=rrPq-LqMOsDUP2IQ-d_FUNq8fZfYqbLVMYOeqrftoXQ,6228
73
+ plain/internal/handlers/base.py,sha256=YAfb43PPtkVtBzAlS7UsaCPh10a9h_K7qdIUoNg6LdE,6100
75
74
  plain/internal/handlers/exception.py,sha256=TbPYtgZ7ITJahUKhQWkptHK28Lb4zh_nOviNctC2EYs,4815
76
75
  plain/internal/handlers/wsgi.py,sha256=dgPT29t_F9llB-c5RYU3SHxGuZNaZ83xRjOfuOmtOl8,8209
77
76
  plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
78
77
  plain/internal/middleware/headers.py,sha256=ENIW1Gwat54hv-ejgp2R8QTZm-PlaI7k44WU01YQrNk,964
79
- plain/internal/middleware/https.py,sha256=ctW90yJnn-blMb1lv17IsSWlAThHJ4RDku5XFfFNECM,834
80
- plain/internal/middleware/slash.py,sha256=JWcIfGbXEKH00I7STq1AMdHhFGmQHC8lkKENa6280ko,2846
78
+ plain/internal/middleware/hosts.py,sha256=vKdjElDk1-Ju6FUXtm5t0AfPPN-DPhjS31PkRrhM6vc,5799
79
+ plain/internal/middleware/https.py,sha256=bjysNX6_2l4Rp4DpzF2XYjH-m6gfHNq2x-MN2hHGW78,804
80
+ plain/internal/middleware/slash.py,sha256=qBTJ-yvOM0d8gUISUC7Fx22LG6aj76uhOiNHjvTkAAs,2840
81
81
  plain/logs/README.md,sha256=rzOHfngjizLgXL21g0svC1Cdya2s_gBA_E-IljtHpy8,4069
82
82
  plain/logs/__init__.py,sha256=gFVMcNn5D6z0JrvUJgGsOeYj1NKNtEXhw0MvPDtkN6w,58
83
83
  plain/logs/configure.py,sha256=G5kLP-92hOWE7vlWG3lhSbzOKXobavFbqjohevJF1Jg,1322
@@ -89,16 +89,16 @@ plain/packages/README.md,sha256=iNqMtwFDVNf2TqKUzLKQW5Y4_GsssmdB4cVerzu27Ro,2674
89
89
  plain/packages/__init__.py,sha256=OpQny0xLplPdPpozVUUkrW2gB-IIYyDT1b4zMzOcCC4,160
90
90
  plain/packages/config.py,sha256=2U7b1cp_kqIuLdSeHGCLrSUV77TdfGRsww3PcXOazaA,2910
91
91
  plain/packages/registry.py,sha256=6ogeHZ8t3kBSoLoI7998r0kIbkEPhLGn-7yi-1qVjVo,7969
92
- plain/preflight/README.md,sha256=Ae7ujHmsJhTpRGX0pghBwGD2CG7SIxngenyb5CSbc2M,1721
92
+ plain/preflight/README.md,sha256=6fA6zXmNUtO5xMrVV-0JLyzSJsIIEEGZyFSAW53nYYE,1764
93
93
  plain/preflight/__init__.py,sha256=j4-yPnrM5hmjumrdkBLOQjFHzRHpA6wCjiFpMNBjIqY,619
94
94
  plain/preflight/files.py,sha256=D_pBSwRXpXy2-3FWywweozuxrhIaR8w5hpPA2d6XMPs,522
95
95
  plain/preflight/messages.py,sha256=B6VyXzu7HTJHaPVK4G1L_1HVHG87CT7JPtcDk8QYSeE,2322
96
96
  plain/preflight/registry.py,sha256=vcqzaE1MIneNL_ydKPy_1zrSThnzsrWARSClLCJ-4b8,2331
97
- plain/preflight/security.py,sha256=oxUZBp2M0bpBfUoLYepIxoex2Y90nyjlrL8XU8UTHYY,2438
97
+ plain/preflight/security.py,sha256=71cW35LMOlciAbtWXf50EhKhQQCZ-2LlQgXJ6Zxhxv4,2443
98
98
  plain/preflight/urls.py,sha256=cQ-WnFa_5oztpKdtwhuIGb7pXEml__bHsjs1SWO2YNI,1468
99
99
  plain/runtime/README.md,sha256=sTqXXJkckwqkk9O06XMMSNRokAYjrZBnB50JD36BsYI,4873
100
100
  plain/runtime/__init__.py,sha256=byFYnHrpUCwkpkHtdNhxr9iUdLDCWJjy92HPj30Ilck,2478
101
- plain/runtime/global_settings.py,sha256=GxcXFjXui5GLkiLlWe8P8X91ndShnmuJK0Ql6xjn24s,5826
101
+ plain/runtime/global_settings.py,sha256=EZ9mdDkwJkXGWDs0bnYnNmpfX49GP30RyU5HgxlrRPc,5872
102
102
  plain/runtime/user_settings.py,sha256=OzMiEkE6ZQ50nxd1WIqirXPiNuMAQULklYHEzgzLWgA,11027
103
103
  plain/runtime/utils.py,sha256=p5IuNTzc7Kq-9Ym7etYnt_xqHw5TioxfSkFeq1bKdgk,832
104
104
  plain/signals/README.md,sha256=XefXqROlDhzw7Z5l_nx6Mhq6n9jjQ-ECGbH0vvhKWYg,272
@@ -161,8 +161,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
161
161
  plain/views/objects.py,sha256=v3Vgvdoc1s0QW6JNWWrO5XXy9zF7vgwndgxX1eOSQoE,4999
162
162
  plain/views/redirect.py,sha256=Xpb3cB7nZYvKgkNqcAxf9Jwm2SWcQ0u2xz4oO5M3vP8,1909
163
163
  plain/views/templates.py,sha256=oAlebEyfES0rzBhfyEJzFmgLkpkbleA6Eip-8zDp-yk,1863
164
- plain-0.65.1.dist-info/METADATA,sha256=MnXWc7Ncpu8r1geVUL6uiAorVD_1aJ0CJ5KcUs4jkQI,4488
165
- plain-0.65.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
166
- plain-0.65.1.dist-info/entry_points.txt,sha256=iGx7EijzXy87htbSv90RhtAcjhSTH_kvE8aeRCn1TRA,129
167
- plain-0.65.1.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
168
- plain-0.65.1.dist-info/RECORD,,
164
+ plain-0.67.0.dist-info/METADATA,sha256=w_ZT2zv65VtVbl4yh0tKrVw8q9PeDj0cKWeeLm3pPNU,4488
165
+ plain-0.67.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
166
+ plain-0.67.0.dist-info/entry_points.txt,sha256=iGx7EijzXy87htbSv90RhtAcjhSTH_kvE8aeRCn1TRA,129
167
+ plain-0.67.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
168
+ plain-0.67.0.dist-info/RECORD,,
File without changes