plain 0.66.0__py3-none-any.whl → 0.101.2__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 +684 -0
- plain/README.md +1 -1
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -53
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +236 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +112 -28
- plain/cli/docs.py +52 -11
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +175 -102
- plain/cli/print.py +4 -4
- plain/cli/registry.py +95 -26
- plain/cli/request.py +206 -0
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -13
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +40 -15
- plain/paginator.py +31 -21
- plain/preflight/README.md +208 -23
- plain/preflight/__init__.py +5 -24
- plain/preflight/checks.py +12 -0
- plain/preflight/files.py +19 -13
- plain/preflight/registry.py +80 -58
- plain/preflight/results.py +37 -0
- plain/preflight/security.py +65 -71
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +10 -48
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +43 -33
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/skills/README.md +36 -0
- plain/skills/plain-docs/SKILL.md +25 -0
- plain/skills/plain-install/SKILL.md +26 -0
- plain/skills/plain-request/SKILL.md +39 -0
- plain/skills/plain-shell/SKILL.md +24 -0
- plain/skills/plain-upgrade/SKILL.md +35 -0
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +14 -27
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +56 -40
- plain/urls/resolvers.py +38 -28
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
- plain-0.101.2.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/cli/agent/request.py +0 -181
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/preflight/messages.py +0 -81
- plain/templates/AGENTS.md +0 -3
- plain-0.66.0.dist-info/RECORD +0 -168
- plain-0.66.0.dist-info/entry_points.txt +0 -4
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/cli/upgrade.py
CHANGED
|
@@ -5,68 +5,42 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
import click
|
|
7
7
|
|
|
8
|
-
from .
|
|
8
|
+
from .runtime import without_runtime_setup
|
|
9
9
|
|
|
10
10
|
LOCK_FILE = Path("uv.lock")
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
@without_runtime_setup
|
|
13
14
|
@click.command()
|
|
14
15
|
@click.argument("packages", nargs=-1)
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
)
|
|
18
|
-
@click.option(
|
|
19
|
-
"--agent-command",
|
|
20
|
-
envvar="PLAIN_AGENT_COMMAND",
|
|
21
|
-
help="Run command with generated prompt",
|
|
22
|
-
)
|
|
23
|
-
@click.option(
|
|
24
|
-
"--print",
|
|
25
|
-
"print_only",
|
|
26
|
-
is_flag=True,
|
|
27
|
-
help="Print the prompt without running the agent",
|
|
28
|
-
)
|
|
29
|
-
def upgrade(
|
|
30
|
-
packages: tuple[str, ...],
|
|
31
|
-
diff: bool,
|
|
32
|
-
agent_command: str | None = None,
|
|
33
|
-
print_only: bool = False,
|
|
34
|
-
) -> None:
|
|
35
|
-
"""Upgrade Plain packages with the help of an agent."""
|
|
16
|
+
def upgrade(packages: tuple[str, ...]) -> None:
|
|
17
|
+
"""Upgrade Plain packages"""
|
|
36
18
|
if not packages:
|
|
37
|
-
click.secho("Getting installed packages...", bold=True
|
|
19
|
+
click.secho("Getting installed packages...", bold=True)
|
|
38
20
|
packages = tuple(sorted(get_installed_plain_packages()))
|
|
39
21
|
for pkg in packages:
|
|
40
|
-
click.secho(f"- {click.style(pkg, fg='yellow')}"
|
|
41
|
-
click.echo(
|
|
22
|
+
click.secho(f"- {click.style(pkg, fg='yellow')}")
|
|
23
|
+
click.echo()
|
|
42
24
|
|
|
43
25
|
if not packages:
|
|
44
26
|
raise click.UsageError("No plain packages found or specified.")
|
|
45
27
|
|
|
46
|
-
|
|
47
|
-
before_after = versions_from_diff(packages)
|
|
48
|
-
else:
|
|
49
|
-
before_after = upgrade_packages(packages)
|
|
28
|
+
before_after = upgrade_packages(packages)
|
|
50
29
|
|
|
51
|
-
#
|
|
52
|
-
|
|
30
|
+
# Show what was upgraded
|
|
31
|
+
upgraded = {
|
|
53
32
|
pkg: versions
|
|
54
33
|
for pkg, versions in before_after.items()
|
|
55
34
|
if versions[0] != versions[1]
|
|
56
35
|
}
|
|
57
36
|
|
|
58
|
-
if not
|
|
59
|
-
click.secho(
|
|
60
|
-
"No packages were upgraded. If uv.lock has already been updated, use --diff instead.",
|
|
61
|
-
fg="green",
|
|
62
|
-
err=True,
|
|
63
|
-
)
|
|
37
|
+
if not upgraded:
|
|
38
|
+
click.secho("All packages already at latest version.", fg="green")
|
|
64
39
|
return
|
|
65
40
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
raise click.Abort()
|
|
41
|
+
click.secho("Upgraded packages:", bold=True)
|
|
42
|
+
for pkg, (before, after) in upgraded.items():
|
|
43
|
+
click.echo(f" {pkg}: {before} -> {after}")
|
|
70
44
|
|
|
71
45
|
|
|
72
46
|
def get_installed_plain_packages() -> list[str]:
|
|
@@ -90,29 +64,6 @@ def parse_lock_versions(lock_text: str, packages: set[str]) -> dict[str, str]:
|
|
|
90
64
|
return versions
|
|
91
65
|
|
|
92
66
|
|
|
93
|
-
def versions_from_diff(
|
|
94
|
-
packages: tuple[str, ...],
|
|
95
|
-
) -> dict[str, tuple[str | None, str | None]]:
|
|
96
|
-
result = subprocess.run(
|
|
97
|
-
["git", "status", "--porcelain", str(LOCK_FILE)], capture_output=True, text=True
|
|
98
|
-
)
|
|
99
|
-
if not result.stdout.strip():
|
|
100
|
-
raise click.UsageError(
|
|
101
|
-
"--diff specified but uv.lock has no uncommitted changes"
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
prev_text = subprocess.run(
|
|
105
|
-
["git", "show", f"HEAD:{LOCK_FILE}"], capture_output=True, text=True, check=True
|
|
106
|
-
).stdout
|
|
107
|
-
current_text = LOCK_FILE.read_text()
|
|
108
|
-
|
|
109
|
-
packages_set = set(packages)
|
|
110
|
-
before = parse_lock_versions(prev_text, packages_set)
|
|
111
|
-
after = parse_lock_versions(current_text, packages_set)
|
|
112
|
-
|
|
113
|
-
return {pkg: (before.get(pkg), after.get(pkg)) for pkg in packages}
|
|
114
|
-
|
|
115
|
-
|
|
116
67
|
def upgrade_packages(
|
|
117
68
|
packages: tuple[str, ...],
|
|
118
69
|
) -> dict[str, tuple[str | None, str | None]]:
|
|
@@ -122,47 +73,9 @@ def upgrade_packages(
|
|
|
122
73
|
for pkg in packages:
|
|
123
74
|
upgrade_args.extend(["--upgrade-package", pkg])
|
|
124
75
|
|
|
125
|
-
click.secho("Upgrading with uv sync...", bold=True
|
|
76
|
+
click.secho("Upgrading with uv sync...", bold=True)
|
|
126
77
|
subprocess.run(upgrade_args, check=True, stdout=sys.stderr)
|
|
127
|
-
click.echo(
|
|
78
|
+
click.echo()
|
|
128
79
|
|
|
129
80
|
after = parse_lock_versions(LOCK_FILE.read_text(), set(packages))
|
|
130
81
|
return {pkg: (before.get(pkg), after.get(pkg)) for pkg in packages}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def build_prompt(before_after: dict[str, tuple[str | None, str | None]]) -> str:
|
|
134
|
-
lines = [
|
|
135
|
-
"These packages have been updated and may require additional changes to the code:",
|
|
136
|
-
"",
|
|
137
|
-
]
|
|
138
|
-
for pkg, (before, after) in before_after.items():
|
|
139
|
-
lines.append(f"- {pkg}: {before} -> {after}")
|
|
140
|
-
|
|
141
|
-
lines.extend(
|
|
142
|
-
[
|
|
143
|
-
"",
|
|
144
|
-
"## Instructions",
|
|
145
|
-
"",
|
|
146
|
-
"1. **Process each package systematically:**",
|
|
147
|
-
" - For each package, run: `uv run plain-changelog {package} --from {before} --to {after}`",
|
|
148
|
-
" - Read the 'Upgrade instructions' section carefully",
|
|
149
|
-
" - If it says 'No changes required', skip to the next package",
|
|
150
|
-
" - Apply any required code changes as specified",
|
|
151
|
-
"",
|
|
152
|
-
"2. **Important guidelines:**",
|
|
153
|
-
" - Process ALL packages before testing or validation",
|
|
154
|
-
" - After all packages are updated, run `uv run plain fix --unsafe-fixes` and `uv run plain pre-commit` to check results",
|
|
155
|
-
" - DO NOT commit any changes",
|
|
156
|
-
" - Keep code changes minimal and focused - avoid unnecessary comments",
|
|
157
|
-
"",
|
|
158
|
-
"3. **Available tools:**",
|
|
159
|
-
" - Python shell: `uv run python`",
|
|
160
|
-
" - If you have a subagents feature and there are more than three packages here, use subagents",
|
|
161
|
-
"",
|
|
162
|
-
"4. **Workflow:**",
|
|
163
|
-
" - Review changelog for each package → Apply changes → Move to next package",
|
|
164
|
-
" - Only after all packages: run pre-commit checks",
|
|
165
|
-
" - Report any issues or conflicts encountered",
|
|
166
|
-
]
|
|
167
|
-
)
|
|
168
|
-
return "\n".join(lines)
|
plain/cli/urls.py
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
|
|
1
5
|
import click
|
|
2
6
|
|
|
3
7
|
|
|
4
8
|
@click.group()
|
|
5
|
-
def urls():
|
|
6
|
-
"""URL
|
|
7
|
-
pass
|
|
9
|
+
def urls() -> None:
|
|
10
|
+
"""URL configuration commands"""
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
@urls.command("list")
|
|
11
14
|
@click.option("--flat", is_flag=True, help="List all URLs in a flat list")
|
|
12
|
-
def list_urls(flat):
|
|
13
|
-
"""
|
|
15
|
+
def list_urls(flat: bool) -> None:
|
|
16
|
+
"""List all URL patterns"""
|
|
14
17
|
from plain.runtime import settings
|
|
15
18
|
from plain.urls import URLResolver, get_resolver
|
|
16
19
|
|
|
@@ -20,7 +23,9 @@ def list_urls(flat):
|
|
|
20
23
|
resolver = get_resolver(settings.URLS_ROUTER)
|
|
21
24
|
if flat:
|
|
22
25
|
|
|
23
|
-
def flat_list(
|
|
26
|
+
def flat_list(
|
|
27
|
+
patterns: list, prefix: str = "", curr_ns: str = ""
|
|
28
|
+
) -> Iterator[str]:
|
|
24
29
|
for pattern in patterns:
|
|
25
30
|
full_pattern = f"{prefix}{pattern.pattern}"
|
|
26
31
|
if isinstance(pattern, URLResolver):
|
|
@@ -50,7 +55,7 @@ def list_urls(flat):
|
|
|
50
55
|
click.echo(p)
|
|
51
56
|
else:
|
|
52
57
|
|
|
53
|
-
def print_tree(patterns, prefix="", curr_ns=""):
|
|
58
|
+
def print_tree(patterns: list, prefix: str = "", curr_ns: str = "") -> None:
|
|
54
59
|
count = len(patterns)
|
|
55
60
|
for idx, pattern in enumerate(patterns):
|
|
56
61
|
is_last = idx == (count - 1)
|
plain/cli/utils.py
CHANGED
|
@@ -4,12 +4,12 @@ from plain.utils.crypto import get_random_string
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
@click.group()
|
|
7
|
-
def utils():
|
|
8
|
-
|
|
7
|
+
def utils() -> None:
|
|
8
|
+
"""Utility commands"""
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@utils.command()
|
|
12
|
-
def generate_secret_key():
|
|
12
|
+
def generate_secret_key() -> None:
|
|
13
13
|
"""Generate a new secret key"""
|
|
14
14
|
new_secret_key = get_random_string(50)
|
|
15
15
|
click.echo(new_secret_key)
|
plain/csrf/README.md
CHANGED
|
@@ -1,26 +1,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plain.csrf
|
|
2
2
|
|
|
3
3
|
**Cross-Site Request Forgery (CSRF) protection using modern request headers.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
|
-
- [
|
|
7
|
-
- [
|
|
8
|
-
- [Trusted
|
|
6
|
+
- [How it works](#how-it-works)
|
|
7
|
+
- [Exempt paths](#exempt-paths)
|
|
8
|
+
- [Trusted origins](#trusted-origins)
|
|
9
|
+
- [FAQs](#faqs)
|
|
10
|
+
- [Installation](#installation)
|
|
9
11
|
|
|
10
12
|
## Overview
|
|
11
13
|
|
|
12
|
-
Plain provides modern CSRF protection based on [Filippo Valsorda's 2025 research](https://words.filippo.io/csrf/) using `Sec-Fetch-Site` headers and origin validation.
|
|
14
|
+
Plain provides modern CSRF protection based on [Filippo Valsorda's 2025 research](https://words.filippo.io/csrf/) using `Sec-Fetch-Site` headers and origin validation. The protection is automatic and requires no changes to your forms or templates.
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
The [`CsrfViewMiddleware`](./middleware.py#CsrfViewMiddleware) runs on every request and blocks cross-origin `POST`, `PUT`, `PATCH`, and `DELETE` requests. Safe methods like `GET`, `HEAD`, and `OPTIONS` are always allowed.
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
## How it works
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
The middleware uses a layered approach to validate requests:
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
1. **Safe methods pass through** - `GET`, `HEAD`, and `OPTIONS` requests are always allowed since they should not modify server state.
|
|
23
|
+
|
|
24
|
+
2. **Exempt paths skip validation** - Paths matching patterns in `CSRF_EXEMPT_PATHS` bypass all CSRF checks.
|
|
25
|
+
|
|
26
|
+
3. **Trusted origins are allowed** - Requests from origins in `CSRF_TRUSTED_ORIGINS` pass through.
|
|
27
|
+
|
|
28
|
+
4. **Sec-Fetch-Site header check** - Modern browsers send this header indicating the request origin:
|
|
29
|
+
- `same-origin` or `none`: Allowed (request came from your site or was user-initiated)
|
|
30
|
+
- `cross-site` or `same-site`: Blocked (request came from another domain or subdomain)
|
|
31
|
+
|
|
32
|
+
5. **Origin header fallback** - For older browsers without `Sec-Fetch-Site`, the middleware compares the `Origin` header against the request's `Host`.
|
|
33
|
+
|
|
34
|
+
6. **Non-browser requests pass** - Requests without either header (like curl or API clients) are allowed since they are not subject to browser CSRF attacks.
|
|
35
|
+
|
|
36
|
+
## Exempt paths
|
|
37
|
+
|
|
38
|
+
You can disable CSRF protection for specific paths using regex patterns. This is useful for API endpoints, webhooks, or health checks that receive requests from external services.
|
|
21
39
|
|
|
22
40
|
```python
|
|
23
|
-
# settings.py
|
|
41
|
+
# app/settings.py
|
|
24
42
|
CSRF_EXEMPT_PATHS = [
|
|
25
43
|
r"^/api/", # All API endpoints
|
|
26
44
|
r"^/api/v\d+/", # Versioned APIs: /api/v1/, /api/v2/, etc.
|
|
@@ -30,41 +48,22 @@ CSRF_EXEMPT_PATHS = [
|
|
|
30
48
|
]
|
|
31
49
|
```
|
|
32
50
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
**Examples:**
|
|
51
|
+
Patterns use Python regex with `re.search()` against the full URL path including the leading slash.
|
|
36
52
|
|
|
37
|
-
|
|
38
|
-
- `r"/webhooks/.*"` - matches `/webhooks/github/push`, `/webhooks/stripe/payment`
|
|
39
|
-
- `r"/health$"` - matches `/health` but not `/health-check`
|
|
40
|
-
- `r"^/api/v\d+/"` - matches `/api/v1/users/`, `/api/v2/posts/`
|
|
53
|
+
**Pattern examples:**
|
|
41
54
|
|
|
42
|
-
|
|
55
|
+
| Pattern | Matches | Does not match |
|
|
56
|
+
| ----------------- | ---------------------------- | --------------- |
|
|
57
|
+
| `r"^/api/"` | `/api/users/`, `/api/posts/` | `/v2/api/` |
|
|
58
|
+
| `r"/webhooks/.*"` | `/webhooks/github/push` | `/webhook/` |
|
|
59
|
+
| `r"/health$"` | `/health` | `/health-check` |
|
|
43
60
|
|
|
44
|
-
|
|
45
|
-
CSRF_EXEMPT_PATHS = [
|
|
46
|
-
# API endpoints (often consumed by JavaScript/mobile apps)
|
|
47
|
-
r"^/api/",
|
|
48
|
-
|
|
49
|
-
# Webhooks (external services posting data)
|
|
50
|
-
r"/webhooks/.*",
|
|
51
|
-
|
|
52
|
-
# Health checks and monitoring
|
|
53
|
-
r"/health$",
|
|
54
|
-
r"/status$",
|
|
55
|
-
r"/metrics$",
|
|
56
|
-
|
|
57
|
-
# File uploads (if using direct POST)
|
|
58
|
-
r"/upload/",
|
|
59
|
-
]
|
|
60
|
-
```
|
|
61
|
+
## Trusted origins
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
In some cases, you may need to allow requests from specific external origins (like API clients or mobile apps). You can configure trusted origins in your settings:
|
|
63
|
+
You can allow requests from specific external origins that you trust completely.
|
|
65
64
|
|
|
66
65
|
```python
|
|
67
|
-
# settings.py
|
|
66
|
+
# app/settings.py
|
|
68
67
|
CSRF_TRUSTED_ORIGINS = [
|
|
69
68
|
"https://api.example.com",
|
|
70
69
|
"https://mobile.example.com:8443",
|
|
@@ -72,4 +71,30 @@ CSRF_TRUSTED_ORIGINS = [
|
|
|
72
71
|
]
|
|
73
72
|
```
|
|
74
73
|
|
|
75
|
-
|
|
74
|
+
Each origin should be a full URL with scheme (e.g., `https://example.com`). Include the port if it's non-standard.
|
|
75
|
+
|
|
76
|
+
**Warning**: Trusted origins bypass all CSRF protection. Only add origins you completely control or trust, as they can make requests that appear to come from your users.
|
|
77
|
+
|
|
78
|
+
## FAQs
|
|
79
|
+
|
|
80
|
+
#### Why does Plain use Sec-Fetch-Site instead of CSRF tokens?
|
|
81
|
+
|
|
82
|
+
Token-based CSRF protection requires embedding tokens in forms and validating them on the server. This adds complexity to your templates and requires careful handling of token rotation. Modern browsers provide the `Sec-Fetch-Site` header which tells the server whether a request is same-origin, making tokens unnecessary. The header approach is simpler, more reliable, and cannot be leaked through XSS vulnerabilities like tokens can.
|
|
83
|
+
|
|
84
|
+
#### What about HTTP sites during development?
|
|
85
|
+
|
|
86
|
+
The `Sec-Fetch-Site` header is only sent by browsers to HTTPS and localhost origins. For development on localhost, CSRF protection works normally. For HTTP origins on other hosts, the middleware falls back to `Origin` header validation.
|
|
87
|
+
|
|
88
|
+
#### Why are same-site requests (like subdomains) blocked?
|
|
89
|
+
|
|
90
|
+
Plain uses same-origin protection rather than same-site protection. Subdomains can have different trust levels than your main domain. For example, `user-content.example.com` should not be able to make authenticated requests to `app.example.com`. If you need to allow requests from a subdomain, add it to `CSRF_TRUSTED_ORIGINS`.
|
|
91
|
+
|
|
92
|
+
#### How do I debug CSRF rejections?
|
|
93
|
+
|
|
94
|
+
When a request is rejected, the middleware raises a `SuspiciousOperationError400` with a detailed message explaining why. Check your server logs for messages like "CSRF rejected: Cross-origin request from Sec-Fetch-Site: cross-site" to understand the cause.
|
|
95
|
+
|
|
96
|
+
## Installation
|
|
97
|
+
|
|
98
|
+
This module is included with the `plain` package and enabled by default. No additional installation or configuration is required.
|
|
99
|
+
|
|
100
|
+
The middleware is automatically added to the request handling pipeline through [`BUILTIN_BEFORE_MIDDLEWARE`](../internal/handlers/base.py#BUILTIN_BEFORE_MIDDLEWARE).
|
plain/csrf/middleware.py
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
3
6
|
from urllib.parse import urlparse
|
|
4
7
|
|
|
5
|
-
from plain.
|
|
8
|
+
from plain.http import HttpMiddleware, SuspiciousOperationError400
|
|
6
9
|
from plain.runtime import settings
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
logger = logging.getLogger("plain.security.csrf")
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from plain.http import Request, Response
|
|
11
13
|
|
|
12
14
|
|
|
13
|
-
class CsrfViewMiddleware:
|
|
15
|
+
class CsrfViewMiddleware(HttpMiddleware):
|
|
14
16
|
"""
|
|
15
17
|
Modern CSRF protection middleware using Sec-Fetch-Site headers and origin validation.
|
|
16
18
|
Based on Filippo Valsorda's 2025 research (https://words.filippo.io/csrf/).
|
|
@@ -19,31 +21,33 @@ class CsrfViewMiddleware:
|
|
|
19
21
|
like subdomains can have different trust levels and are rejected.
|
|
20
22
|
"""
|
|
21
23
|
|
|
22
|
-
def __init__(self, get_response):
|
|
23
|
-
|
|
24
|
+
def __init__(self, get_response: Callable[[Request], Response]):
|
|
25
|
+
super().__init__(get_response)
|
|
24
26
|
|
|
25
27
|
# Compile CSRF exempt patterns once for performance
|
|
26
|
-
self.csrf_exempt_patterns
|
|
28
|
+
self.csrf_exempt_patterns: list[re.Pattern[str]] = [
|
|
29
|
+
re.compile(r) for r in settings.CSRF_EXEMPT_PATHS
|
|
30
|
+
]
|
|
27
31
|
|
|
28
|
-
def
|
|
32
|
+
def process_request(self, request: Request) -> Response:
|
|
29
33
|
allowed, reason = self.should_allow_request(request)
|
|
30
34
|
|
|
31
|
-
if allowed:
|
|
32
|
-
|
|
33
|
-
else:
|
|
34
|
-
return self.reject(request, reason)
|
|
35
|
+
if not allowed:
|
|
36
|
+
raise SuspiciousOperationError400(reason)
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
return self.get_response(request)
|
|
39
|
+
|
|
40
|
+
def should_allow_request(self, request: Request) -> tuple[bool, str]:
|
|
37
41
|
# 1. Allow safe methods (GET, HEAD, OPTIONS)
|
|
38
42
|
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
39
|
-
return True, f"Safe HTTP method: {request.method}"
|
|
43
|
+
return True, f"CSRF allowed: Safe HTTP method: {request.method}"
|
|
40
44
|
|
|
41
45
|
# 2. Path-based exemption (regex patterns)
|
|
42
46
|
for pattern in self.csrf_exempt_patterns:
|
|
43
47
|
if pattern.search(request.path_info):
|
|
44
48
|
return (
|
|
45
49
|
True,
|
|
46
|
-
f"Path {request.path_info} matches exempt pattern {pattern.pattern}",
|
|
50
|
+
f"CSRF allowed: Path {request.path_info} matches exempt pattern {pattern.pattern}",
|
|
47
51
|
)
|
|
48
52
|
|
|
49
53
|
origin = request.headers.get("Origin")
|
|
@@ -52,25 +56,25 @@ class CsrfViewMiddleware:
|
|
|
52
56
|
# 3. Check trusted origins allow-list
|
|
53
57
|
|
|
54
58
|
if origin and origin in settings.CSRF_TRUSTED_ORIGINS:
|
|
55
|
-
return True, f"Trusted origin: {origin}"
|
|
59
|
+
return True, f"CSRF allowed: Trusted origin: {origin}"
|
|
56
60
|
|
|
57
61
|
# 4. Primary protection: Check Sec-Fetch-Site header
|
|
58
62
|
if sec_fetch_site in ("same-origin", "none"):
|
|
59
63
|
return (
|
|
60
64
|
True,
|
|
61
|
-
f"Same-origin request from Sec-Fetch-Site: {sec_fetch_site}",
|
|
65
|
+
f"CSRF allowed: Same-origin request from Sec-Fetch-Site: {sec_fetch_site}",
|
|
62
66
|
)
|
|
63
67
|
elif sec_fetch_site in ("cross-site", "same-site"):
|
|
64
68
|
return (
|
|
65
69
|
False,
|
|
66
|
-
f"Cross-origin request
|
|
70
|
+
f"CSRF rejected: Cross-origin request from Sec-Fetch-Site: {sec_fetch_site}",
|
|
67
71
|
)
|
|
68
72
|
|
|
69
73
|
# 5. No fetch metadata or Origin headers - allow (non-browser requests)
|
|
70
74
|
if not origin and not sec_fetch_site:
|
|
71
75
|
return (
|
|
72
76
|
True,
|
|
73
|
-
"No Origin or Sec-Fetch-Site header - likely non-browser or old browser",
|
|
77
|
+
"CSRF allowed: No Origin or Sec-Fetch-Site header - likely non-browser or old browser",
|
|
74
78
|
)
|
|
75
79
|
|
|
76
80
|
# 6. Fallback: Origin vs Host comparison for older browsers
|
|
@@ -78,7 +82,7 @@ class CsrfViewMiddleware:
|
|
|
78
82
|
# (Origin shows :443, request sees :80 if TLS terminated upstream).
|
|
79
83
|
# HSTS helps here; otherwise add external origins to CSRF_TRUSTED_ORIGINS.
|
|
80
84
|
if origin == "null":
|
|
81
|
-
return False, "
|
|
85
|
+
return False, "CSRF rejected: Null Origin header"
|
|
82
86
|
|
|
83
87
|
if (parsed_origin := urlparse(origin)) and (host := request.host):
|
|
84
88
|
try:
|
|
@@ -101,33 +105,39 @@ class CsrfViewMiddleware:
|
|
|
101
105
|
# Compare hostname and port (scheme-agnostic)
|
|
102
106
|
# Both origin_host and request_host are normalized by urlparse (IPv6 brackets stripped)
|
|
103
107
|
if origin_host and origin_port and request_host and request_port:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
host_match = origin_host.lower() == request_host.lower()
|
|
109
|
+
port_match = origin_port == int(request_port)
|
|
110
|
+
|
|
111
|
+
if host_match and port_match:
|
|
108
112
|
return (
|
|
109
113
|
True,
|
|
110
|
-
f"Same-origin request - Origin {origin} matches Host {host}",
|
|
114
|
+
f"CSRF allowed: Same-origin request - Origin {origin} matches Host {host}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Build detailed error message based on what mismatched
|
|
118
|
+
if host_match:
|
|
119
|
+
# Port mismatch only - show ports since they're relevant
|
|
120
|
+
return (
|
|
121
|
+
False,
|
|
122
|
+
f"CSRF rejected: Origin {origin_host}:{origin_port} does not match Host {request_host}:{request_port} (port mismatch)",
|
|
123
|
+
)
|
|
124
|
+
elif port_match:
|
|
125
|
+
# Host mismatch only - no need to show ports
|
|
126
|
+
return (
|
|
127
|
+
False,
|
|
128
|
+
f"CSRF rejected: Origin {origin_host} does not match Host {request_host}",
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
# Both mismatch - show full details
|
|
132
|
+
return (
|
|
133
|
+
False,
|
|
134
|
+
f"CSRF rejected: Origin {origin_host}:{origin_port} does not match Host {request_host}:{request_port}",
|
|
111
135
|
)
|
|
112
136
|
except ValueError:
|
|
113
137
|
pass
|
|
114
138
|
|
|
115
|
-
# Origin present but
|
|
139
|
+
# Origin present but couldn't parse/compare properly
|
|
116
140
|
return (
|
|
117
141
|
False,
|
|
118
|
-
f"
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
def reject(self, request, reason):
|
|
122
|
-
"""Reject a request with a 403 Forbidden response."""
|
|
123
|
-
|
|
124
|
-
response = CsrfFailureView.as_view()(request, reason=reason)
|
|
125
|
-
log_response(
|
|
126
|
-
"Forbidden (%s): %s",
|
|
127
|
-
reason,
|
|
128
|
-
request.path,
|
|
129
|
-
response=response,
|
|
130
|
-
request=request,
|
|
131
|
-
logger=logger,
|
|
142
|
+
f"CSRF rejected: Origin {origin} could not be validated against Host {request.host}",
|
|
132
143
|
)
|
|
133
|
-
return response
|
plain/debug.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from pprint import pformat
|
|
4
|
+
from typing import Any, NoReturn
|
|
2
5
|
|
|
3
6
|
from markupsafe import Markup, escape
|
|
4
7
|
|
|
@@ -6,7 +9,7 @@ from plain.http import Response
|
|
|
6
9
|
from plain.views.exceptions import ResponseException
|
|
7
10
|
|
|
8
11
|
|
|
9
|
-
def dd(*objs):
|
|
12
|
+
def dd(*objs: Any) -> NoReturn:
|
|
10
13
|
"""
|
|
11
14
|
Dump and die.
|
|
12
15
|
|
|
@@ -25,5 +28,5 @@ def dd(*objs):
|
|
|
25
28
|
response = Response()
|
|
26
29
|
response.status_code = 500
|
|
27
30
|
response.content = combined_dump_str
|
|
28
|
-
response.
|
|
31
|
+
response.headers["Content-Type"] = "text/html"
|
|
29
32
|
raise ResponseException(response)
|