create-caspian-app 1.3.19 → 1.3.20

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.
package/dist/main.py CHANGED
@@ -27,6 +27,7 @@ from fastapi.responses import (
27
27
  RedirectResponse,
28
28
  HTMLResponse,
29
29
  JSONResponse,
30
+ PlainTextResponse,
30
31
  )
31
32
  from starlette.datastructures import MutableHeaders
32
33
  from starlette.middleware import Middleware
@@ -472,6 +473,54 @@ class SecurityHeadersMiddleware:
472
473
  await self.app(scope, receive, send_wrapper)
473
474
 
474
475
 
476
+ # Top-level directories under `public/` are asset namespaces, not page routes:
477
+ # `public/js/**` owns `/js/**` and nothing in `src/app/` can answer there. Built
478
+ # once, like SECURITY_HEADERS -- adding a new asset *directory* is a restart-level
479
+ # change, unlike adding a file to an existing one, which stays live.
480
+ PUBLIC_ASSET_NAMESPACES: frozenset[str] = (
481
+ frozenset(entry.name.casefold() for entry in Path("public").iterdir() if entry.is_dir())
482
+ if Path("public").is_dir()
483
+ else frozenset()
484
+ )
485
+
486
+
487
+ class MissingPublicAssetMiddleware:
488
+ """404 a missing file in a public asset namespace instead of falling through.
489
+
490
+ `PublicFilesMiddleware` deliberately falls through when no file matches, so
491
+ normal routing keeps working. With `is_all_routes_private=True` that means a
492
+ missing asset reaches `AuthMiddleware` and answers `303 -> /signin`, which is
493
+ the wrong answer twice over: a `<script src="/js/typo.js">` then receives the
494
+ sign-in *page* as `200 text/html` and fails with a parse error that names the
495
+ wrong file, and every bogus asset path returns a full HTML page to anonymous
496
+ traffic. A path whose first segment is a real `public/` directory can only be
497
+ an asset request, so a miss there is a genuine 404.
498
+
499
+ Runs inside the rate limiter -- a 404 flood is still a flood -- but outside
500
+ sessions, CSRF, and auth, so a missing asset costs no session decryption.
501
+ """
502
+
503
+ def __init__(self, app: ASGIApp):
504
+ self.app = app
505
+
506
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
507
+ if scope["type"] != "http" or scope.get("method", "GET").upper() not in {
508
+ "GET",
509
+ "HEAD",
510
+ }:
511
+ await self.app(scope, receive, send)
512
+ return
513
+
514
+ segment = str(scope.get("path", "")).lstrip("/").split("/", 1)[0].casefold()
515
+ if segment not in PUBLIC_ASSET_NAMESPACES:
516
+ await self.app(scope, receive, send)
517
+ return
518
+
519
+ # PublicFilesMiddleware sits outside this one, so reaching here means it
520
+ # already declined: the file does not exist or escapes the public root.
521
+ await PlainTextResponse("Not Found", status_code=404)(scope, receive, send)
522
+
523
+
475
524
  class BodySizeLimitMiddleware:
476
525
  """Reject oversized HTTP request bodies before route or RPC parsing."""
477
526
 
@@ -1534,6 +1583,9 @@ app.add_middleware(
1534
1583
  path="/",
1535
1584
  )
1536
1585
  app.add_middleware(BodySizeLimitMiddleware)
1586
+ # Sits between the limiter and the session/auth layers: a miss under a public
1587
+ # asset namespace is a 404, not a sign-in redirect, and costs no session work.
1588
+ app.add_middleware(MissingPublicAssetMiddleware)
1537
1589
  # Outermost of the security layers: reject flooding before any session
1538
1590
  # decryption, template rendering, or database work is spent on the request.
1539
1591
  app.add_middleware(RateLimitMiddleware)
@@ -90,7 +90,17 @@ RULES: list[Rule] = [
90
90
  Rule(
91
91
  "unquoted-brace-attr",
92
92
  # `class={...}` / `selected={...}` -- invalid HTML, silently blanks the page.
93
- re.compile(r"\s[\w:.\-]+=\{"),
93
+ #
94
+ # An attribute only exists *inside an opening tag*, and the rule must say
95
+ # so. A bare `\s[\w:.\-]+=\{` also matches `DIR={path}` in a shell script
96
+ # and `ENV PORT={port}` in a Dockerfile -- and this repo embeds both in
97
+ # triple-quoted strings under `src/lib/aws/`, which the Python scan keeps
98
+ # because it cannot tell a heredoc from a template. Requiring the opening
99
+ # tag is not a loosening: a real violation is always inside one.
100
+ #
101
+ # `[^<>]*?` bounds the attribute run to a single tag; it matches newlines
102
+ # (negated classes do), so an attribute on its own line is still caught.
103
+ re.compile(r"<[a-zA-Z][\w:.\-]*(?:[^<>]*?)?\s[\w:.\-]+=\{"),
94
104
  "Unquoted brace attribute. This is invalid HTML: the parser splits the "
95
105
  "value on spaces, the component root never compiles, and the page "
96
106
  'renders blank with no console error. Quote it: attr="{expr}".',
@@ -116,7 +126,12 @@ RULES: list[Rule] = [
116
126
  ),
117
127
  Rule(
118
128
  "jsx-fragment",
119
- re.compile(r"<>|</>"),
129
+ # `</>` is unambiguous, and a well-formed fragment always has one. A bare
130
+ # `<>` is not: it is SQL's not-equals operator, and this repo runs
131
+ # `WHERE pid <> pg_backend_pid()` from a triple-quoted string. So the
132
+ # open tag counts only when an element follows it, which is what a
133
+ # fragment looks like and what `<> value` in SQL never does.
134
+ re.compile(r"</>|<>\s*<"),
120
135
  "JSX fragment. A template needs exactly one real root element.",
121
136
  ),
122
137
  Rule(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.3.19",
3
+ "version": "1.3.20",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",