plain 0.65.1__tar.gz → 0.67.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.
- {plain-0.65.1 → plain-0.67.0}/PKG-INFO +1 -1
- {plain-0.65.1 → plain-0.67.0}/plain/CHANGELOG.md +25 -0
- plain-0.67.0/plain/cli/agent/request.py +172 -0
- {plain-0.65.1 → plain-0.67.0}/plain/csrf/middleware.py +5 -6
- {plain-0.65.1 → plain-0.67.0}/plain/exceptions.py +0 -6
- {plain-0.65.1 → plain-0.67.0}/plain/http/request.py +17 -35
- {plain-0.65.1 → plain-0.67.0}/plain/internal/handlers/base.py +2 -5
- {plain-0.65.1/plain/http → plain-0.67.0/plain/internal/middleware}/hosts.py +59 -18
- {plain-0.65.1 → plain-0.67.0}/plain/internal/middleware/https.py +1 -2
- {plain-0.65.1 → plain-0.67.0}/plain/internal/middleware/slash.py +1 -1
- {plain-0.65.1 → plain-0.67.0}/plain/preflight/README.md +2 -2
- {plain-0.65.1 → plain-0.67.0}/plain/preflight/security.py +3 -3
- {plain-0.65.1 → plain-0.67.0}/plain/runtime/global_settings.py +3 -2
- {plain-0.65.1 → plain-0.67.0}/pyproject.toml +1 -1
- {plain-0.65.1 → plain-0.67.0}/tests/test_csrf.py +26 -43
- {plain-0.65.1 → plain-0.67.0}/tests/test_http_hosts.py +2 -27
- {plain-0.65.1 → plain-0.67.0}/tests/test_wsgi.py +0 -1
- plain-0.65.1/plain/cli/agent/request.py +0 -181
- {plain-0.65.1 → plain-0.67.0}/.gitignore +0 -0
- {plain-0.65.1 → plain-0.67.0}/LICENSE +0 -0
- {plain-0.65.1 → plain-0.67.0}/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/AGENTS.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/__main__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/assets/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/assets/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/assets/compile.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/assets/finders.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/assets/fingerprints.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/assets/urls.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/assets/views.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/chores/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/chores/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/chores/registry.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/agent/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/agent/docs.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/agent/llmdocs.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/agent/md.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/agent/prompt.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/build.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/changelog.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/chores.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/core.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/docs.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/formatting.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/install.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/output.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/preflight.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/print.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/registry.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/scaffold.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/settings.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/shell.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/startup.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/upgrade.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/urls.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/cli/utils.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/csrf/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/csrf/views.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/debug.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/forms/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/forms/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/forms/boundfield.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/forms/exceptions.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/forms/fields.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/forms/forms.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/http/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/http/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/http/cookie.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/http/multipartparser.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/http/response.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/files/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/files/base.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/files/locks.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/files/move.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/files/temp.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/files/utils.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/handlers/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/handlers/exception.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/middleware/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/internal/middleware/headers.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/json.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/logs/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/logs/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/logs/configure.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/logs/debug.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/logs/formatters.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/logs/loggers.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/logs/utils.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/packages/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/packages/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/packages/config.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/packages/registry.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/paginator.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/preflight/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/preflight/files.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/preflight/messages.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/preflight/registry.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/preflight/urls.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/runtime/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/runtime/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/runtime/user_settings.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/runtime/utils.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/signals/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/signals/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/signing.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/templates/AGENTS.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/templates/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/templates/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/templates/core.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/templates/jinja/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/templates/jinja/environments.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/templates/jinja/filters.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/templates/jinja/globals.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/test/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/test/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/test/client.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/test/encoding.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/test/exceptions.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/urls/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/urls/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/urls/converters.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/urls/exceptions.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/urls/patterns.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/urls/resolvers.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/urls/routers.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/urls/utils.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/cache.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/crypto.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/datastructures.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/dateparse.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/deconstruct.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/decorators.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/duration.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/encoding.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/functional.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/hashable.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/html.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/http.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/inspect.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/ipv6.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/itercompat.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/module_loading.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/regex_helper.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/safestring.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/text.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/timesince.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/timezone.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/utils/tree.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/validators.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/views/README.md +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/views/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/views/base.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/views/errors.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/views/exceptions.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/views/forms.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/views/objects.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/views/redirect.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/views/templates.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/plain/wsgi.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/tests/.gitignore +0 -0
- {plain-0.65.1 → plain-0.67.0}/tests/app/.gitignore +0 -0
- {plain-0.65.1 → plain-0.67.0}/tests/app/settings.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/tests/app/test/__init__.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/tests/app/test/default_settings.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/tests/app/urls.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/tests/conftest.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/tests/test_cli.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/tests/test_logs.py +0 -0
- {plain-0.65.1 → plain-0.67.0}/tests/test_runtime.py +0 -0
@@ -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
|
@@ -0,0 +1,172 @@
|
|
1
|
+
import json
|
2
|
+
|
3
|
+
import click
|
4
|
+
|
5
|
+
from plain.runtime import settings
|
6
|
+
from plain.test import Client
|
7
|
+
|
8
|
+
|
9
|
+
@click.command()
|
10
|
+
@click.argument("path")
|
11
|
+
@click.option(
|
12
|
+
"--method",
|
13
|
+
default="GET",
|
14
|
+
help="HTTP method (GET, POST, PUT, PATCH, DELETE, etc.)",
|
15
|
+
)
|
16
|
+
@click.option(
|
17
|
+
"--data",
|
18
|
+
help="Request data (JSON string for POST/PUT/PATCH)",
|
19
|
+
)
|
20
|
+
@click.option(
|
21
|
+
"--user",
|
22
|
+
"user_id",
|
23
|
+
help="User ID to authenticate as (skips normal authentication)",
|
24
|
+
)
|
25
|
+
@click.option(
|
26
|
+
"--follow/--no-follow",
|
27
|
+
default=True,
|
28
|
+
help="Follow redirects (default: True)",
|
29
|
+
)
|
30
|
+
@click.option(
|
31
|
+
"--content-type",
|
32
|
+
help="Content-Type header for request data",
|
33
|
+
)
|
34
|
+
@click.option(
|
35
|
+
"--header",
|
36
|
+
"headers",
|
37
|
+
multiple=True,
|
38
|
+
help="Additional headers (format: 'Name: Value')",
|
39
|
+
)
|
40
|
+
def request(path, method, data, user_id, follow, content_type, headers):
|
41
|
+
"""Make an HTTP request using the test client against the development database."""
|
42
|
+
|
43
|
+
try:
|
44
|
+
# Only allow in DEBUG mode for security
|
45
|
+
if not settings.DEBUG:
|
46
|
+
click.secho("This command only works when DEBUG=True", fg="red", err=True)
|
47
|
+
return
|
48
|
+
|
49
|
+
# Create test client
|
50
|
+
client = Client()
|
51
|
+
|
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
|
57
|
+
|
58
|
+
User = get_user_model()
|
59
|
+
|
60
|
+
# Get the user
|
61
|
+
try:
|
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)
|
69
|
+
return
|
70
|
+
|
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)
|
89
|
+
return
|
90
|
+
|
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:
|
158
|
+
click.secho("Response Body:", fg="yellow", bold=True)
|
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)
|
164
|
+
else:
|
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)
|
170
|
+
|
171
|
+
except Exception as e:
|
172
|
+
click.secho(f"Request failed: {e}", fg="red", err=True)
|
@@ -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
|
-
|
85
|
-
|
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.
|
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
|
-
|
114
|
-
|
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
|
-
|
123
|
+
@cached_property
|
124
|
+
def host(self):
|
127
125
|
"""
|
128
|
-
Return the HTTP host using the environment or request headers.
|
129
|
-
|
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.
|
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
|
-
|
145
|
-
|
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
|
-
|
154
|
+
"""
|
155
|
+
Return the full path for the request, including query string.
|
176
156
|
|
177
|
-
|
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.
|
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
|
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
|
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
|
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
|
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 =
|
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 :=
|
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
|
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.
|
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
|
@@ -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.
|
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.
|
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
|
-
|
84
|
+
Error(
|
85
85
|
"ALLOWED_HOSTS must not be empty in deployment.",
|
86
|
-
id="security.
|
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
|
-
#
|
27
|
-
# "
|
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.
|