plain 0.66.0__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.66.0 → plain-0.67.0}/PKG-INFO +1 -1
- {plain-0.66.0 → plain-0.67.0}/plain/CHANGELOG.md +11 -0
- plain-0.67.0/plain/cli/agent/request.py +172 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/middleware/hosts.py +0 -5
- {plain-0.66.0 → plain-0.67.0}/plain/preflight/README.md +2 -2
- {plain-0.66.0 → plain-0.67.0}/plain/preflight/security.py +3 -3
- {plain-0.66.0 → plain-0.67.0}/plain/runtime/global_settings.py +4 -3
- {plain-0.66.0 → plain-0.67.0}/pyproject.toml +1 -1
- {plain-0.66.0 → plain-0.67.0}/tests/app/settings.py +0 -1
- {plain-0.66.0 → plain-0.67.0}/tests/test_csrf.py +26 -43
- {plain-0.66.0 → plain-0.67.0}/tests/test_http_hosts.py +1 -26
- plain-0.66.0/plain/cli/agent/request.py +0 -181
- {plain-0.66.0 → plain-0.67.0}/.gitignore +0 -0
- {plain-0.66.0 → plain-0.67.0}/LICENSE +0 -0
- {plain-0.66.0 → plain-0.67.0}/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/AGENTS.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/__main__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/assets/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/assets/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/assets/compile.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/assets/finders.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/assets/fingerprints.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/assets/urls.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/assets/views.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/chores/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/chores/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/chores/registry.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/agent/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/agent/docs.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/agent/llmdocs.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/agent/md.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/agent/prompt.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/build.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/changelog.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/chores.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/core.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/docs.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/formatting.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/install.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/output.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/preflight.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/print.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/registry.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/scaffold.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/settings.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/shell.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/startup.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/upgrade.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/urls.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/cli/utils.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/csrf/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/csrf/middleware.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/csrf/views.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/debug.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/exceptions.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/forms/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/forms/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/forms/boundfield.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/forms/exceptions.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/forms/fields.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/forms/forms.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/http/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/http/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/http/cookie.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/http/multipartparser.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/http/request.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/http/response.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/files/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/files/base.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/files/locks.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/files/move.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/files/temp.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/files/utils.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/handlers/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/handlers/base.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/handlers/exception.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/middleware/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/middleware/headers.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/middleware/https.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/internal/middleware/slash.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/json.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/logs/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/logs/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/logs/configure.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/logs/debug.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/logs/formatters.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/logs/loggers.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/logs/utils.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/packages/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/packages/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/packages/config.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/packages/registry.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/paginator.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/preflight/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/preflight/files.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/preflight/messages.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/preflight/registry.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/preflight/urls.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/runtime/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/runtime/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/runtime/user_settings.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/runtime/utils.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/signals/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/signals/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/signing.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/templates/AGENTS.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/templates/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/templates/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/templates/core.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/templates/jinja/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/templates/jinja/environments.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/templates/jinja/filters.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/templates/jinja/globals.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/test/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/test/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/test/client.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/test/encoding.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/test/exceptions.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/urls/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/urls/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/urls/converters.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/urls/exceptions.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/urls/patterns.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/urls/resolvers.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/urls/routers.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/urls/utils.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/cache.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/crypto.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/datastructures.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/dateparse.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/deconstruct.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/decorators.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/duration.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/encoding.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/functional.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/hashable.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/html.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/http.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/inspect.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/ipv6.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/itercompat.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/module_loading.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/regex_helper.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/safestring.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/text.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/timesince.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/timezone.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/utils/tree.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/validators.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/views/README.md +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/views/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/views/base.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/views/errors.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/views/exceptions.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/views/forms.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/views/objects.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/views/redirect.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/views/templates.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/plain/wsgi.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/tests/.gitignore +0 -0
- {plain-0.66.0 → plain-0.67.0}/tests/app/.gitignore +0 -0
- {plain-0.66.0 → plain-0.67.0}/tests/app/test/__init__.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/tests/app/test/default_settings.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/tests/app/urls.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/tests/conftest.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/tests/test_cli.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/tests/test_logs.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/tests/test_runtime.py +0 -0
- {plain-0.66.0 → plain-0.67.0}/tests/test_wsgi.py +0 -0
@@ -1,5 +1,16 @@
|
|
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
|
+
|
3
14
|
## [0.66.0](https://github.com/dropseed/plain/releases/plain@0.66.0) (2025-09-22)
|
4
15
|
|
5
16
|
### 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)
|
@@ -159,7 +159,6 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
|
|
159
159
|
Check that the host looks valid and matches a host or host pattern in the
|
160
160
|
given list of ``allowed_hosts``. Supported patterns:
|
161
161
|
|
162
|
-
- ``*`` matches anything
|
163
162
|
- ``.example.com`` matches a domain and all its subdomains
|
164
163
|
(e.g. ``example.com`` and ``sub.example.com``)
|
165
164
|
- ``example.com`` matches exactly that domain
|
@@ -176,10 +175,6 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
|
|
176
175
|
host_ip = parse_ip_address(host)
|
177
176
|
|
178
177
|
for pattern in allowed_hosts:
|
179
|
-
# Wildcard matches everything
|
180
|
-
if pattern == "*":
|
181
|
-
return True
|
182
|
-
|
183
178
|
# Check CIDR notation patterns using walrus operator
|
184
179
|
if network := parse_cidr_pattern(pattern):
|
185
180
|
if host_ip and host_ip in network:
|
@@ -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,9 +23,10 @@ URLS_ROUTER: str
|
|
23
23
|
# MARK: HTTP and Security
|
24
24
|
|
25
25
|
# Hosts/domain names that are valid for this site.
|
26
|
-
#
|
27
|
-
# "
|
28
|
-
|
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
|
29
|
+
ALLOWED_HOSTS: list[str] = []
|
29
30
|
|
30
31
|
# Default headers for all responses.
|
31
32
|
DEFAULT_RESPONSE_HEADERS = {
|
@@ -115,67 +115,50 @@ def test_old_browser_fallback(headers):
|
|
115
115
|
|
116
116
|
|
117
117
|
@pytest.mark.parametrize(
|
118
|
-
("origin", "expected_allowed", "expected_reason_contains"
|
118
|
+
("origin", "expected_allowed", "expected_reason_contains"),
|
119
119
|
[
|
120
120
|
# Origin matches host - should be allowed
|
121
121
|
(
|
122
122
|
"https://testserver",
|
123
123
|
True,
|
124
124
|
"Same-origin request - Origin https://testserver matches Host testserver",
|
125
|
-
True,
|
126
125
|
),
|
127
126
|
(
|
128
127
|
"https://testserver:443",
|
129
128
|
True,
|
130
129
|
"Same-origin request - Origin https://testserver:443 matches Host testserver",
|
131
|
-
True,
|
132
130
|
),
|
133
131
|
# Various rejection cases
|
134
|
-
("null", False, "Cross-origin request detected - null Origin header"
|
135
|
-
("https://attacker.com", False, "does not match Host"
|
136
|
-
("https://sub.testserver", False, "does not match Host"
|
137
|
-
("https://example.com:8080", False, "does not match Host"
|
138
|
-
("http://example.com", False, "does not match Host"
|
132
|
+
("null", False, "Cross-origin request detected - null Origin header"),
|
133
|
+
("https://attacker.com", False, "does not match Host"),
|
134
|
+
("https://sub.testserver", False, "does not match Host"),
|
135
|
+
("https://example.com:8080", False, "does not match Host"),
|
136
|
+
("http://example.com", False, "does not match Host"),
|
139
137
|
],
|
140
138
|
)
|
141
|
-
def test_origin_host_comparison(
|
142
|
-
origin, expected_allowed, expected_reason_contains, setup_allowed_hosts
|
143
|
-
):
|
139
|
+
def test_origin_host_comparison(origin, expected_allowed, expected_reason_contains):
|
144
140
|
"""Test Origin vs Host header comparison scenarios."""
|
145
|
-
|
146
|
-
|
147
|
-
original_allowed_hosts = None
|
148
|
-
if setup_allowed_hosts:
|
149
|
-
# Temporarily modify ALLOWED_HOSTS to include testserver
|
150
|
-
original_allowed_hosts = settings.ALLOWED_HOSTS
|
151
|
-
settings.ALLOWED_HOSTS = [*settings.ALLOWED_HOSTS, "testserver"]
|
152
|
-
|
153
|
-
try:
|
154
|
-
rf = RequestFactory()
|
155
|
-
csrf_middleware = CsrfViewMiddleware(lambda request: None)
|
141
|
+
rf = RequestFactory()
|
142
|
+
csrf_middleware = CsrfViewMiddleware(lambda request: None)
|
156
143
|
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
144
|
+
# Configure request based on the origin
|
145
|
+
request_kwargs = {"headers": {"Origin": origin}}
|
146
|
+
if origin == "https://testserver:443":
|
147
|
+
request_kwargs.update(
|
148
|
+
{
|
149
|
+
"SERVER_NAME": "testserver",
|
150
|
+
"SERVER_PORT": "443",
|
151
|
+
"secure": True,
|
152
|
+
}
|
153
|
+
)
|
154
|
+
elif origin == "https://testserver":
|
155
|
+
request_kwargs["secure"] = True
|
156
|
+
|
157
|
+
request = rf.post("/test/", **request_kwargs)
|
158
|
+
allowed, reason = csrf_middleware.should_allow_request(request)
|
172
159
|
|
173
|
-
|
174
|
-
|
175
|
-
finally:
|
176
|
-
if original_allowed_hosts is not None:
|
177
|
-
# Restore original ALLOWED_HOSTS
|
178
|
-
settings.ALLOWED_HOSTS = original_allowed_hosts
|
160
|
+
assert allowed is expected_allowed
|
161
|
+
assert expected_reason_contains in reason
|
179
162
|
|
180
163
|
|
181
164
|
def test_invalid_origin_url():
|
@@ -57,12 +57,6 @@ def test_split_domain_port(host, expected_domain, expected_port):
|
|
57
57
|
@pytest.mark.parametrize(
|
58
58
|
("host", "allowed_hosts", "expected"),
|
59
59
|
[
|
60
|
-
# Wildcard matching
|
61
|
-
("example.com", ["*"], True),
|
62
|
-
("sub.example.com", ["*"], True),
|
63
|
-
("127.0.0.1", ["*"], True),
|
64
|
-
("[::1]", ["*"], True),
|
65
|
-
("anything.at.all", ["*"], True),
|
66
60
|
# Subdomain pattern matching
|
67
61
|
("example.com", [".example.com"], True),
|
68
62
|
("sub.example.com", [".example.com"], True),
|
@@ -84,7 +78,7 @@ def test_split_domain_port(host, expected_domain, expected_port):
|
|
84
78
|
("127.0.0.1", ["example.com", ".api.example.com", "127.0.0.1"], True),
|
85
79
|
("sub.example.com", ["example.com", ".api.example.com", "127.0.0.1"], False),
|
86
80
|
("other.com", ["example.com", ".api.example.com", "127.0.0.1"], False),
|
87
|
-
# Literal asterisk pattern (
|
81
|
+
# Literal asterisk pattern (treated as literal string, not wildcard)
|
88
82
|
("*.test.com", ["*.test.com"], True),
|
89
83
|
("anything.test.com", ["*.test.com"], False),
|
90
84
|
("api.test.com", ["*.test.com"], False),
|
@@ -121,22 +115,6 @@ def test_split_domain_port(host, expected_domain, expected_port):
|
|
121
115
|
),
|
122
116
|
("example.com", [".api.example.com", ".staging.example.com"], False),
|
123
117
|
("www.example.com", [".api.example.com", ".staging.example.com"], False),
|
124
|
-
# Wildcard overrides other patterns
|
125
|
-
(
|
126
|
-
"anything.com",
|
127
|
-
["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
|
128
|
-
True,
|
129
|
-
),
|
130
|
-
(
|
131
|
-
"random.domain.org",
|
132
|
-
["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
|
133
|
-
True,
|
134
|
-
),
|
135
|
-
(
|
136
|
-
"192.168.1.100",
|
137
|
-
["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
|
138
|
-
True,
|
139
|
-
),
|
140
118
|
# Edge cases
|
141
119
|
("", ["example.com"], False),
|
142
120
|
("example .com", ["example.com"], False),
|
@@ -196,9 +174,6 @@ def test_validate_host(host, allowed_hosts, expected):
|
|
196
174
|
("192.168.1.0/24", ["192.168.1.0/24"], False), # Literal match of CIDR string
|
197
175
|
("192.168.1.100", ["192.168.1.0/999"], False), # Invalid CIDR
|
198
176
|
("192.168.1.100", ["192.168.1.0/"], False), # Invalid CIDR
|
199
|
-
# CIDR with wildcard - wildcard should take precedence
|
200
|
-
("anything.com", ["*", "192.168.1.0/24"], True),
|
201
|
-
("172.16.0.1", ["*", "192.168.1.0/24"], True),
|
202
177
|
# Edge cases
|
203
178
|
("0.0.0.0", ["0.0.0.0/0"], True), # Match all IPv4
|
204
179
|
("255.255.255.255", ["0.0.0.0/0"], True),
|
@@ -1,181 +0,0 @@
|
|
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
|
-
# Temporarily add testserver to ALLOWED_HOSTS so the test client can make requests
|
50
|
-
original_allowed_hosts = settings.ALLOWED_HOSTS
|
51
|
-
settings.ALLOWED_HOSTS = ["*"]
|
52
|
-
|
53
|
-
try:
|
54
|
-
# Create test client
|
55
|
-
client = Client()
|
56
|
-
|
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()
|
86
|
-
|
87
|
-
# Prepare request data
|
88
|
-
if data and content_type and "json" in content_type.lower():
|
89
|
-
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)
|
94
|
-
return
|
95
|
-
|
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)
|
128
|
-
return
|
129
|
-
|
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:
|
170
|
-
click.secho("Response Body:", fg="yellow", bold=True)
|
171
|
-
content = response.content.decode("utf-8", errors="replace")
|
172
|
-
click.echo(content)
|
173
|
-
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
|
179
|
-
|
180
|
-
except Exception as e:
|
181
|
-
click.secho(f"Request failed: {e}", fg="red", err=True)
|
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
|