plain 0.82.0__py3-none-any.whl → 0.84.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of plain might be problematic. Click here for more details.

plain/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.84.0](https://github.com/dropseed/plain/releases/plain@0.84.0) (2025-10-29)
4
+
5
+ ### What's changed
6
+
7
+ - The `DEFAULT_RESPONSE_HEADERS` setting now supports format string placeholders (e.g., `{request.csp_nonce}`) for dynamic header values instead of requiring a callable function ([5199383128](https://github.com/dropseed/plain/commit/5199383128))
8
+ - Views can now set headers to `None` to explicitly remove default response headers ([5199383128](https://github.com/dropseed/plain/commit/5199383128))
9
+ - Added comprehensive documentation for customizing default response headers including override, remove, and extend patterns ([5199383128](https://github.com/dropseed/plain/commit/5199383128))
10
+
11
+ ### Upgrade instructions
12
+
13
+ - If you have `DEFAULT_RESPONSE_HEADERS` configured as a callable function, convert it to a dictionary with format string placeholders:
14
+
15
+ ```python
16
+ # Before:
17
+ def DEFAULT_RESPONSE_HEADERS(request):
18
+ nonce = request.csp_nonce
19
+ return {
20
+ "Content-Security-Policy": f"script-src 'self' 'nonce-{nonce}'",
21
+ }
22
+
23
+ # After:
24
+ DEFAULT_RESPONSE_HEADERS = {
25
+ "Content-Security-Policy": "script-src 'self' 'nonce-{request.csp_nonce}'",
26
+ }
27
+ ```
28
+
29
+ - If you were overriding default headers to empty strings (`""`) to remove them, change those to `None` instead
30
+
31
+ ## [0.83.0](https://github.com/dropseed/plain/releases/plain@0.83.0) (2025-10-29)
32
+
33
+ ### What's changed
34
+
35
+ - Added comprehensive Content Security Policy (CSP) documentation explaining how to use nonces with inline scripts and styles ([784f3dd972](https://github.com/dropseed/plain/commit/784f3dd972))
36
+ - The `json_script` utility function now accepts an optional `nonce` parameter for CSP-compliant inline JSON scripts ([784f3dd972](https://github.com/dropseed/plain/commit/784f3dd972))
37
+
38
+ ### Upgrade instructions
39
+
40
+ - Any `|json_script` usages need to make sure the second argument is a nonce, not a custom encoder (which is now third)
41
+
3
42
  ## [0.82.0](https://github.com/dropseed/plain/releases/plain@0.82.0) (2025-10-29)
4
43
 
5
44
  ### What's changed
plain/http/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  **HTTP request and response handling.**
4
4
 
5
5
  - [Overview](#overview)
6
+ - [Content Security Policy (CSP)](#content-security-policy-csp)
7
+ - [Customizing Default Response Headers](#customizing-default-response-headers)
6
8
 
7
9
  ## Overview
8
10
 
@@ -28,3 +30,113 @@ class ExampleView(View):
28
30
 
29
31
  return response
30
32
  ```
33
+
34
+ ## Content Security Policy (CSP)
35
+
36
+ Plain includes built-in support for Content Security Policy (CSP) through nonces, allowing you to use strict CSP policies without `'unsafe-inline'`.
37
+
38
+ Each request generates a unique cryptographically secure nonce available via [`request.csp_nonce`](request.py#Request.csp_nonce):
39
+
40
+ ### Configuring CSP Headers
41
+
42
+ Include CSP in `DEFAULT_RESPONSE_HEADERS` using `{request.csp_nonce}` placeholders for dynamic nonces:
43
+
44
+ ```python
45
+ # app/settings.py
46
+ DEFAULT_RESPONSE_HEADERS = {
47
+ "Content-Security-Policy": (
48
+ "default-src 'self'; "
49
+ "script-src 'self' 'nonce-{request.csp_nonce}'; "
50
+ "style-src 'self' 'nonce-{request.csp_nonce}'; "
51
+ "img-src 'self' data:; "
52
+ "font-src 'self'; "
53
+ "connect-src 'self'; "
54
+ "frame-ancestors 'self'; "
55
+ "base-uri 'self'; "
56
+ "form-action 'self'"
57
+ ),
58
+ # Other default headers...
59
+ "X-Frame-Options": "DENY",
60
+ }
61
+ ```
62
+
63
+ The `{request.csp_nonce}` placeholder will be replaced with a unique nonce for each request.
64
+
65
+ Use tools like [Google's CSP Evaluator](https://csp-evaluator.withgoogle.com/) to analyze your CSP policy and identify potential security issues or misconfigurations.
66
+
67
+ ### Using Nonces in Templates
68
+
69
+ Add the nonce attribute to inline scripts and styles in your templates:
70
+
71
+ ```html
72
+ <!-- Inline script with nonce -->
73
+ <script nonce="{{ request.csp_nonce }}">
74
+ console.log("This script is allowed by CSP");
75
+ </script>
76
+
77
+ <!-- Inline style with nonce -->
78
+ <style nonce="{{ request.csp_nonce }}">
79
+ .example { color: red; }
80
+ </style>
81
+ ```
82
+
83
+ External scripts and stylesheets loaded from `'self'` don't need nonces:
84
+
85
+ ```html
86
+ <!-- External scripts/styles work with 'self' directive -->
87
+ <script src="/assets/app.js"></script>
88
+ <link rel="stylesheet" href="/assets/app.css">
89
+ ```
90
+
91
+ ## Customizing Default Response Headers
92
+
93
+ Plain applies default response headers to all responses via `DEFAULT_RESPONSE_HEADERS` in settings. Views can customize these headers in several ways:
94
+
95
+ ### Override Default Headers
96
+
97
+ Set the header to a different value in your view:
98
+
99
+ ```python
100
+ class MyView(View):
101
+ def get(self):
102
+ response = Response("content")
103
+ # Override the default X-Frame-Options: DENY
104
+ response.headers["X-Frame-Options"] = "SAMEORIGIN"
105
+ return response
106
+ ```
107
+
108
+ ### Remove Default Headers
109
+
110
+ Set the header to `None` to prevent it from being applied:
111
+
112
+ ```python
113
+ class EmbeddableView(View):
114
+ def get(self):
115
+ response = Response("content")
116
+ # Remove X-Frame-Options entirely to allow embedding
117
+ response.headers["X-Frame-Options"] = None
118
+ return response
119
+ ```
120
+
121
+ ### Extend Default Headers
122
+
123
+ Read the default value from settings, modify it, then set it in your view:
124
+
125
+ ```python
126
+ from plain.runtime import settings
127
+
128
+ class MyView(View):
129
+ def get(self):
130
+ response = Response("content")
131
+
132
+ # Get the default CSP policy
133
+ if csp := settings.DEFAULT_RESPONSE_HEADERS.get("Content-Security-Policy"):
134
+ # Format it with the current request to resolve placeholders
135
+ csp = csp.format(request=self.request)
136
+ # Extend with additional sources
137
+ response.headers["Content-Security-Policy"] = (
138
+ f"{csp}; script-src https://analytics.example.com"
139
+ )
140
+
141
+ return response
142
+ ```
@@ -10,24 +10,46 @@ if TYPE_CHECKING:
10
10
 
11
11
 
12
12
  class DefaultHeadersMiddleware(HttpMiddleware):
13
+ """
14
+ Applies default response headers from settings.DEFAULT_RESPONSE_HEADERS.
15
+
16
+ This middleware runs after the view executes and applies default headers
17
+ to the response using setdefault(), which means:
18
+ - Headers already set by the view won't be overridden
19
+ - Headers not set by the view will use the default value
20
+
21
+ View Customization Patterns:
22
+ - Use default: Don't set the header (middleware applies it)
23
+ - Override: Set the header to a different value
24
+ - Remove: Set the header to None (middleware will delete it)
25
+ - Extend: Read from settings.DEFAULT_RESPONSE_HEADERS, modify, then set
26
+
27
+ Format Strings:
28
+ Header values can include {request.attribute} placeholders for dynamic
29
+ content. Example: 'nonce-{request.csp_nonce}' will be formatted with
30
+ the request's csp_nonce value. Headers without placeholders are used as-is.
31
+
32
+ None Removal:
33
+ Views can set a header to None to opt-out of that default header entirely.
34
+ The middleware will delete any header set to None, preventing the default
35
+ from being applied.
36
+ """
37
+
13
38
  def process_request(self, request: Request) -> Response:
39
+ # Get the response from the view (and any inner middleware)
14
40
  response = self.get_response(request)
15
41
 
16
- # Support callable DEFAULT_RESPONSE_HEADERS for dynamic header generation
17
- # (e.g., CSP nonces that change per request)
18
- if callable(settings.DEFAULT_RESPONSE_HEADERS):
19
- default_headers = settings.DEFAULT_RESPONSE_HEADERS(request)
20
- else:
21
- default_headers = settings.DEFAULT_RESPONSE_HEADERS
22
-
23
- for header, value in default_headers.items():
24
- # Since we don't have a good way to *remove* default response headers,
25
- # use allow users to set them to an empty string to indicate they should be removed.
26
- if header in response.headers and response.headers[header] == "":
42
+ # Apply default headers to the response
43
+ for header, value in settings.DEFAULT_RESPONSE_HEADERS.items():
44
+ if header not in response.headers:
45
+ # Header not set - apply default
46
+ if "{" in value:
47
+ response.headers[header] = value.format(request=request)
48
+ else:
49
+ response.headers[header] = value
50
+ elif response.headers[header] is None:
51
+ # Header explicitly set to None by view - remove it
27
52
  del response.headers[header]
28
- continue
29
-
30
- response.headers.setdefault(header, value)
31
53
 
32
54
  # Add the Content-Length header to non-streaming responses if not
33
55
  # already set.
@@ -27,8 +27,12 @@ URLS_ROUTER: str
27
27
  ALLOWED_HOSTS: list[str] = []
28
28
 
29
29
  # Default headers for all responses.
30
- DEFAULT_RESPONSE_HEADERS = {
31
- # "Content-Security-Policy": "default-src 'self'",
30
+ # Header values can include {request.attribute} placeholders for dynamic content.
31
+ # Example: "script-src 'nonce-{request.csp_nonce}'" will use the request's nonce.
32
+ # Views can override, remove, or extend these headers - see plain/http/README.md
33
+ # for customization patterns.
34
+ DEFAULT_RESPONSE_HEADERS: dict = {
35
+ # "Content-Security-Policy": "default-src 'self'; script-src 'self' 'nonce-{request.csp_nonce}'",
32
36
  # https://hstspreload.org/
33
37
  # "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
34
38
  "Cross-Origin-Opener-Policy": "same-origin",
plain/utils/html.py CHANGED
@@ -34,25 +34,30 @@ _json_script_escapes = {
34
34
  def json_script(
35
35
  value: Any,
36
36
  element_id: str | None = None,
37
+ nonce: str = "",
37
38
  encoder: type[json.JSONEncoder] | None = None,
38
39
  ) -> SafeString:
39
40
  """
40
41
  Escape all the HTML/XML special characters with their unicode escapes, so
41
42
  value is safe to be output anywhere except for inside a tag attribute. Wrap
42
43
  the escaped JSON in a script tag.
44
+
45
+ Args:
46
+ value: The data to encode as JSON
47
+ element_id: Optional ID attribute for the script tag
48
+ nonce: Optional CSP nonce for inline script tags
49
+ encoder: Optional custom JSON encoder class
43
50
  """
44
51
  from plain.json import PlainJSONEncoder
45
52
 
46
53
  json_str = json.dumps(value, cls=encoder or PlainJSONEncoder).translate(
47
54
  _json_script_escapes
48
55
  )
49
- if element_id:
50
- template = '<script id="{}" type="application/json">{}</script>'
51
- args = (element_id, mark_safe(json_str))
52
- else:
53
- template = '<script type="application/json">{}</script>'
54
- args = (mark_safe(json_str),)
55
- return format_html(template, *args)
56
+ id_attr = f' id="{element_id}"' if element_id else ""
57
+ nonce_attr = f' nonce="{nonce}"' if nonce else ""
58
+ return mark_safe(
59
+ f'<script{id_attr}{nonce_attr} type="application/json">{json_str}</script>'
60
+ )
56
61
 
57
62
 
58
63
  def conditional_escape(text: Any) -> SafeString | str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.82.0
3
+ Version: 0.84.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,5 +1,5 @@
1
1
  plain/AGENTS.md,sha256=As6EFSWWHJ9lYIxb2LMRqNRteH45SRs7a_VFslzF53M,1046
2
- plain/CHANGELOG.md,sha256=LafG-4y2ZZGMobjIsHK5Iob3e47rA_umHo__6KUA1rI,33085
2
+ plain/CHANGELOG.md,sha256=wZ16yVxfhEgl-6T5SGrjiEqMtie_jZqTPd6bc7smcAI,35041
3
3
  plain/README.md,sha256=VvzhXNvf4I6ddmjBP9AExxxFXxs7RwyoxdgFm-W5dsg,4072
4
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
5
5
  plain/debug.py,sha256=C2OnFHtRGMrpCiHSt-P2r58JypgQZ62qzDBpV4mhtFM,855
@@ -56,7 +56,7 @@ plain/forms/boundfield.py,sha256=PEquPRn1BoVd4ZkIin8tjkBzRDMv-41wO8qHV0S1shg,196
56
56
  plain/forms/exceptions.py,sha256=MuMUF-33Qsc_HWk5zI3rcWzdvXzQbEOKAZvJKkbrL58,320
57
57
  plain/forms/fields.py,sha256=GQSTI6-eaHivIXj3TQSw2LpyWMbN0NZ-SHjUO_oxqeA,37132
58
58
  plain/forms/forms.py,sha256=GnJWzjIXOPByfrdiqjjo4xdKdkBw1gBD6Hq4mg73gHQ,11259
59
- plain/http/README.md,sha256=32uuWbarAcG_qmP-Fltk-_26HFKfY3dBdlrO2FDb7D0,756
59
+ plain/http/README.md,sha256=HP0m45CKhtA5Jmc65VhMZCYEH9ATK7BfBvkHwUASnwA,4221
60
60
  plain/http/__init__.py,sha256=gUTIGh-GbSIlh3SP-Db-XAOX4P_j2hh2445w8Cm-zKQ,1032
61
61
  plain/http/cookie.py,sha256=x13G3LIr0jxnPK1NQRptmi0DrAq9PsivQnQTm4LKaW0,2191
62
62
  plain/http/middleware.py,sha256=TPs585IIFjgp-5uUAJtIoigH6uwTS3FJqwFSsQdayd4,960
@@ -78,7 +78,7 @@ plain/internal/handlers/base.py,sha256=ZlpOYqd45X0wPYlmxHwXR5kyCkWBB6ZJeSGZka5VA
78
78
  plain/internal/handlers/exception.py,sha256=P7oTqVtKoIHBOxCml1BSgNBV_rQWyCHlO5hS87NVjXo,4872
79
79
  plain/internal/handlers/wsgi.py,sha256=d2Hcs4fzTSir6OvtpVromTw0RmmFAqTL4_EaBjoHtNU,8919
80
80
  plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
81
- plain/internal/middleware/headers.py,sha256=z3rmjIGGYV5JcAfAUm3tcdG34OFoEfYukJrwZsq7JIo,1425
81
+ plain/internal/middleware/headers.py,sha256=fX8iMOeecqSyI7y-IIQ_w4OUFRJpgMvUr_xtUzI7IUQ,2361
82
82
  plain/internal/middleware/hosts.py,sha256=UXMts7cKA9ocOK-J8DAcZPbKYQtfVyLSLH2QQX3mKjM,5895
83
83
  plain/internal/middleware/https.py,sha256=8n990O0Ej5cRrBlfAnLW2nZ2mx0wn0002C4HuRhVpsM,1117
84
84
  plain/internal/middleware/slash.py,sha256=V_rTb9v8uMUR_N2TLETBwlJFItoL4WD9JnRyFaFs878,3073
@@ -102,7 +102,7 @@ plain/preflight/security.py,sha256=nMbflO2LN49oKAAaa-tYvuweNB0RWv1z3w9niU037n0,3
102
102
  plain/preflight/urls.py,sha256=Asw_vq-70NRqr15yuBAYL0JCZ04liumORYT3I3KmF_k,437
103
103
  plain/runtime/README.md,sha256=ZZ3NPTjtxwyfw1V827YNwkWc8MiH5rWiy27HrN13qZ8,4819
104
104
  plain/runtime/__init__.py,sha256=dvF5ipRckVf6LQgY4kdxE_dTlCdncuawQf3o5EqLq9k,2524
105
- plain/runtime/global_settings.py,sha256=3nYptasBjcodSR2Nq8Pm0KViPpvIS4SDOTShr5294Kk,5413
105
+ plain/runtime/global_settings.py,sha256=BVkLDWA2i4kbtjQabFdJ4cYhVrt-mU8UqZdBRz7cbwU,5741
106
106
  plain/runtime/user_settings.py,sha256=Tbd0J6bxp18tKmFsQcdlxeFhUQU68PYtsswzQ2IcfNc,11467
107
107
  plain/runtime/utils.py,sha256=sHOv9SWCalBtg32GtZofimM2XaQtf_jAyyf6RQuOlGc,851
108
108
  plain/server/LICENSE,sha256=Xt_dw4qYwQI9qSi2u8yMZeb4HuMRp5tESRKhtvvJBgA,1707
@@ -167,7 +167,7 @@ plain/utils/duration.py,sha256=QBFh-iLvRXuw0N0UUdFqabeJvZVqkgFME_c2gRSLwhI,1340
167
167
  plain/utils/encoding.py,sha256=40siECldXUFyZ_IkyQCeOkyQuXb7i4Rpys3nFWFHnuc,4482
168
168
  plain/utils/functional.py,sha256=HqRXyM6R3Sl9IbhGl6t-DdFwtfr05IYmq7rgFCWV_2U,14650
169
169
  plain/utils/hashable.py,sha256=1Kh_SFxsaR2d3xQMNEtHb4eKSHugtNt66iZ3ZvKjt50,811
170
- plain/utils/html.py,sha256=x9kruRAzs9mIJ3XlSmVcxIXOy-lSupZ4J3J0KlWr1Rc,4028
170
+ plain/utils/html.py,sha256=ctgzowSi_NkytPOw3FvyqEIXgOE-52trkTFPjiGvGVg,4202
171
171
  plain/utils/http.py,sha256=P7dkgWpC28Bm-_VNuj57VN8j8AdbaDu1CKxoggwBpKo,5847
172
172
  plain/utils/inspect.py,sha256=wbWDAiVa4MW4lwJZz-mvU8Db-I0Nq5DhHh9ew5w81EE,1480
173
173
  plain/utils/ipv6.py,sha256=TLOQVN0aqKGM4eS_HwarTgO-bGIql-k5AipDPgtQdCA,1352
@@ -188,8 +188,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
188
188
  plain/views/objects.py,sha256=5y0PoPPo07dQTTcJ_9kZcx0iI1O7regsooAIK4VqXQ0,5579
189
189
  plain/views/redirect.py,sha256=mIpSAFcaEyeLDyv4Fr6g_ektduG4Wfa6s6L-rkdazmM,2154
190
190
  plain/views/templates.py,sha256=9LgDMCv4C7JzLiyw8jHF-i4350ukwgixC_9y4faEGu0,1885
191
- plain-0.82.0.dist-info/METADATA,sha256=Az4gbY6JEIaJb3xC12DeYjEl2fezGU6GXHx64Ku1WLs,4516
192
- plain-0.82.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
193
- plain-0.82.0.dist-info/entry_points.txt,sha256=1Ys2lsSeMepD1vz8RSrJopna0RQfUd951vYvCRsvl6A,45
194
- plain-0.82.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
195
- plain-0.82.0.dist-info/RECORD,,
191
+ plain-0.84.0.dist-info/METADATA,sha256=UQ9z1_Z0yZvZ2Gj5xnSE75cfoPSH4Ia05Rc3NKaVFng,4516
192
+ plain-0.84.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
193
+ plain-0.84.0.dist-info/entry_points.txt,sha256=1Ys2lsSeMepD1vz8RSrJopna0RQfUd951vYvCRsvl6A,45
194
+ plain-0.84.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
195
+ plain-0.84.0.dist-info/RECORD,,
File without changes