create-caspian-app 0.2.0-beta.90 → 0.2.0-beta.91
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 +121 -22
- package/dist/public/js/pp-reactive-v2.js +1 -1
- package/package.json +1 -1
package/dist/main.py
CHANGED
|
@@ -10,6 +10,7 @@ import json
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from fastapi import FastAPI, Request, Response
|
|
12
12
|
from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
|
|
13
|
+
from starlette.datastructures import MutableHeaders
|
|
13
14
|
from starlette.middleware.sessions import SessionMiddleware
|
|
14
15
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
15
16
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
@@ -75,6 +76,89 @@ MAX_CONTENT_LENGTH_MB = int(os.getenv('MAX_CONTENT_LENGTH_MB', 16))
|
|
|
75
76
|
IS_PRODUCTION = os.getenv('APP_ENV') == 'production'
|
|
76
77
|
CACHE_ENABLED = os.getenv('CACHE_ENABLED', 'false').lower() == 'true'
|
|
77
78
|
DEFAULT_TTL = int(os.getenv('CACHE_TTL', 600))
|
|
79
|
+
INSECURE_SESSION_SECRET_VALUES = {"", "change-me", "changeme"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_safe_public_path(base_dir: str | Path, relative_path: str) -> Optional[Path]:
|
|
83
|
+
base_path = Path(base_dir).resolve()
|
|
84
|
+
try:
|
|
85
|
+
candidate = (base_path / relative_path).resolve()
|
|
86
|
+
candidate.relative_to(base_path)
|
|
87
|
+
except (OSError, RuntimeError, ValueError):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
if not candidate.is_file():
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
return candidate
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _public_file_response(
|
|
97
|
+
base_dir: str | Path,
|
|
98
|
+
relative_path: str,
|
|
99
|
+
*,
|
|
100
|
+
media_type: Optional[str] = None,
|
|
101
|
+
) -> Response:
|
|
102
|
+
file_path = _resolve_safe_public_path(base_dir, relative_path)
|
|
103
|
+
if file_path is None:
|
|
104
|
+
return Response(status_code=404)
|
|
105
|
+
|
|
106
|
+
resolved_media_type = media_type
|
|
107
|
+
if resolved_media_type is None:
|
|
108
|
+
resolved_media_type, _ = mimetypes.guess_type(str(file_path))
|
|
109
|
+
|
|
110
|
+
return FileResponse(
|
|
111
|
+
file_path,
|
|
112
|
+
media_type=resolved_media_type or 'application/octet-stream',
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _client_error_message(exc: Exception) -> str:
|
|
117
|
+
return str(exc) if not IS_PRODUCTION else 'An unexpected error occurred.'
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _get_session_secret() -> str:
|
|
121
|
+
session_secret = (os.getenv("AUTH_SECRET") or "").strip()
|
|
122
|
+
if session_secret and session_secret.lower() not in INSECURE_SESSION_SECRET_VALUES:
|
|
123
|
+
return session_secret
|
|
124
|
+
|
|
125
|
+
if not IS_PRODUCTION:
|
|
126
|
+
return "change-me"
|
|
127
|
+
|
|
128
|
+
raise RuntimeError(
|
|
129
|
+
"AUTH_SECRET must be set to a non-default value when APP_ENV=production."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_content_security_policy() -> str:
|
|
134
|
+
# PulsePoint currently relies on inline scripts/styles and runtime codegen,
|
|
135
|
+
# so this policy tightens source scope without breaking the app runtime.
|
|
136
|
+
directives = [
|
|
137
|
+
"default-src 'self'",
|
|
138
|
+
"base-uri 'self'",
|
|
139
|
+
"object-src 'none'",
|
|
140
|
+
"frame-ancestors 'self'",
|
|
141
|
+
"form-action 'self'",
|
|
142
|
+
"img-src 'self' data: blob:",
|
|
143
|
+
"font-src 'self' data:",
|
|
144
|
+
"connect-src 'self'",
|
|
145
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
146
|
+
"style-src 'self' 'unsafe-inline'",
|
|
147
|
+
]
|
|
148
|
+
return "; ".join(directives)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _build_security_headers() -> dict[str, str]:
|
|
152
|
+
headers = {
|
|
153
|
+
"content-security-policy": _build_content_security_policy(),
|
|
154
|
+
"permissions-policy": "camera=(), geolocation=(), microphone=(), payment=(), usb=()",
|
|
155
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
156
|
+
"x-content-type-options": "nosniff",
|
|
157
|
+
"x-frame-options": "SAMEORIGIN",
|
|
158
|
+
}
|
|
159
|
+
if IS_PRODUCTION:
|
|
160
|
+
headers["strict-transport-security"] = "max-age=31536000; includeSubDomains"
|
|
161
|
+
return headers
|
|
78
162
|
|
|
79
163
|
|
|
80
164
|
def _dev_cookie_scope() -> str:
|
|
@@ -120,36 +204,26 @@ SESSION_COOKIE_NAME = _scoped_cookie_name(
|
|
|
120
204
|
|
|
121
205
|
@app.get('/css/{filename:path}')
|
|
122
206
|
async def serve_css(filename: str):
|
|
123
|
-
|
|
124
|
-
if not file_path.exists():
|
|
125
|
-
return Response(status_code=404)
|
|
126
|
-
return FileResponse(file_path, media_type='text/css')
|
|
207
|
+
return _public_file_response('public/css', filename, media_type='text/css')
|
|
127
208
|
|
|
128
209
|
|
|
129
210
|
@app.get('/js/{filename:path}')
|
|
130
211
|
async def serve_js(filename: str):
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
212
|
+
return _public_file_response(
|
|
213
|
+
'public/js',
|
|
214
|
+
filename,
|
|
215
|
+
media_type='application/javascript',
|
|
216
|
+
)
|
|
135
217
|
|
|
136
218
|
|
|
137
219
|
@app.get('/assets/{filename:path}')
|
|
138
220
|
async def serve_assets(filename: str):
|
|
139
|
-
|
|
140
|
-
if not file_path.exists():
|
|
141
|
-
return Response(status_code=404)
|
|
142
|
-
mime_type, _ = mimetypes.guess_type(str(file_path))
|
|
143
|
-
return FileResponse(file_path, media_type=mime_type or 'application/octet-stream')
|
|
221
|
+
return _public_file_response('public/assets', filename)
|
|
144
222
|
|
|
145
223
|
|
|
146
224
|
@app.get('/uploads/{filename:path}')
|
|
147
225
|
async def serve_uploads(filename: str):
|
|
148
|
-
|
|
149
|
-
if not file_path.exists():
|
|
150
|
-
return Response(status_code=404)
|
|
151
|
-
mime_type, _ = mimetypes.guess_type(str(file_path))
|
|
152
|
-
return FileResponse(file_path, media_type=mime_type or 'application/octet-stream')
|
|
226
|
+
return _public_file_response('public/uploads', filename)
|
|
153
227
|
|
|
154
228
|
|
|
155
229
|
@app.get('/favicon.ico')
|
|
@@ -191,6 +265,29 @@ class CSRFMiddleware:
|
|
|
191
265
|
await self.app(scope, receive, send_wrapper)
|
|
192
266
|
|
|
193
267
|
|
|
268
|
+
class SecurityHeadersMiddleware:
|
|
269
|
+
"""Attach baseline browser security headers to HTTP responses."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, app: ASGIApp): self.app = app
|
|
272
|
+
|
|
273
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
274
|
+
if scope["type"] != "http":
|
|
275
|
+
await self.app(scope, receive, send)
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
async def send_wrapper(message):
|
|
279
|
+
if message["type"] == "http.response.start":
|
|
280
|
+
raw_headers = list(message.get("headers", []))
|
|
281
|
+
headers = MutableHeaders(raw=raw_headers)
|
|
282
|
+
for name, value in _build_security_headers().items():
|
|
283
|
+
if headers.get(name) is None:
|
|
284
|
+
headers[name] = value
|
|
285
|
+
message = {**message, "headers": raw_headers}
|
|
286
|
+
await send(message)
|
|
287
|
+
|
|
288
|
+
await self.app(scope, receive, send_wrapper)
|
|
289
|
+
|
|
290
|
+
|
|
194
291
|
class AuthMiddleware:
|
|
195
292
|
"""Auth middleware using pure ASGI pattern for proper session handling."""
|
|
196
293
|
|
|
@@ -574,9 +671,10 @@ async def custom_404_handler(request: Request, exc: StarletteHTTPException):
|
|
|
574
671
|
|
|
575
672
|
@app.exception_handler(Exception)
|
|
576
673
|
async def custom_general_exception_handler(request: Request, exc: Exception):
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
674
|
+
full_trace = traceback.format_exc()
|
|
675
|
+
print(full_trace)
|
|
676
|
+
error_message = _client_error_message(exc)
|
|
677
|
+
error_trace = full_trace if not IS_PRODUCTION else None
|
|
580
678
|
|
|
581
679
|
error_page_path = os.path.join('src', 'app', 'error.html')
|
|
582
680
|
if os.path.exists(error_page_path):
|
|
@@ -619,13 +717,14 @@ app.add_middleware(CSRFMiddleware)
|
|
|
619
717
|
|
|
620
718
|
app.add_middleware(
|
|
621
719
|
SessionMiddleware,
|
|
622
|
-
secret_key=
|
|
720
|
+
secret_key=_get_session_secret(),
|
|
623
721
|
session_cookie=SESSION_COOKIE_NAME,
|
|
624
722
|
max_age=SESSION_LIFETIME_HOURS * 3600,
|
|
625
723
|
same_site='lax',
|
|
626
724
|
https_only=IS_PRODUCTION,
|
|
627
725
|
path='/',
|
|
628
726
|
)
|
|
727
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
629
728
|
|
|
630
729
|
if __name__ == '__main__':
|
|
631
730
|
port = int(os.getenv('PORT', 5091))
|