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 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
- file_path = Path('public/css') / filename
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
- file_path = Path('public/js') / filename
132
- if not file_path.exists():
133
- return Response(status_code=404)
134
- return FileResponse(file_path, media_type='application/javascript')
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
- file_path = Path('public/assets') / filename
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
- file_path = Path('public/uploads') / filename
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
- print(traceback.format_exc())
578
- error_message = str(exc)
579
- error_trace = traceback.format_exc() if not IS_PRODUCTION else None
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=os.getenv('AUTH_SECRET', 'change-me'),
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))