create-caspian-app 0.2.0-beta.91 → 0.2.0-beta.92

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
@@ -3,7 +3,6 @@ from casp.scripts_type import transform_scripts
3
3
  import inspect
4
4
  import os
5
5
  import importlib.util
6
- import mimetypes
7
6
  import secrets
8
7
  import traceback
9
8
  import json
@@ -39,6 +38,12 @@ from casp.streaming import SSE
39
38
  from typing import Any, Optional, get_args, get_origin, Union
40
39
  from urllib.parse import urlparse
41
40
  from src.lib.auth.auth_config import build_auth_settings
41
+ from src.lib.security.runtime_security import (
42
+ build_security_headers,
43
+ client_error_message,
44
+ get_session_secret,
45
+ public_file_response,
46
+ )
42
47
 
43
48
  load_dotenv()
44
49
  cfg = get_config()
@@ -76,89 +81,18 @@ MAX_CONTENT_LENGTH_MB = int(os.getenv('MAX_CONTENT_LENGTH_MB', 16))
76
81
  IS_PRODUCTION = os.getenv('APP_ENV') == 'production'
77
82
  CACHE_ENABLED = os.getenv('CACHE_ENABLED', 'false').lower() == 'true'
78
83
  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
84
 
115
85
 
116
86
  def _client_error_message(exc: Exception) -> str:
117
- return str(exc) if not IS_PRODUCTION else 'An unexpected error occurred.'
87
+ return client_error_message(exc, is_production=IS_PRODUCTION)
118
88
 
119
89
 
120
90
  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)
91
+ return get_session_secret(is_production=IS_PRODUCTION)
149
92
 
150
93
 
151
94
  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
95
+ return build_security_headers(is_production=IS_PRODUCTION)
162
96
 
163
97
 
164
98
  def _dev_cookie_scope() -> str:
@@ -204,12 +138,12 @@ SESSION_COOKIE_NAME = _scoped_cookie_name(
204
138
 
205
139
  @app.get('/css/{filename:path}')
206
140
  async def serve_css(filename: str):
207
- return _public_file_response('public/css', filename, media_type='text/css')
141
+ return public_file_response('public/css', filename, media_type='text/css')
208
142
 
209
143
 
210
144
  @app.get('/js/{filename:path}')
211
145
  async def serve_js(filename: str):
212
- return _public_file_response(
146
+ return public_file_response(
213
147
  'public/js',
214
148
  filename,
215
149
  media_type='application/javascript',
@@ -218,12 +152,12 @@ async def serve_js(filename: str):
218
152
 
219
153
  @app.get('/assets/{filename:path}')
220
154
  async def serve_assets(filename: str):
221
- return _public_file_response('public/assets', filename)
155
+ return public_file_response('public/assets', filename)
222
156
 
223
157
 
224
158
  @app.get('/uploads/{filename:path}')
225
159
  async def serve_uploads(filename: str):
226
- return _public_file_response('public/uploads', filename)
160
+ return public_file_response('public/uploads', filename)
227
161
 
228
162
 
229
163
  @app.get('/favicon.ico')
@@ -0,0 +1,164 @@
1
+ import mimetypes
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Any, Optional
5
+
6
+ from fastapi import Response
7
+ from fastapi.responses import FileResponse
8
+
9
+ INSECURE_SESSION_SECRET_VALUES = {"", "change-me", "changeme"}
10
+ GOOGLE_FONTS_STYLES_ORIGIN = "https://fonts.googleapis.com"
11
+ GOOGLE_FONTS_ASSETS_ORIGIN = "https://fonts.gstatic.com"
12
+ GOOGLE_ACCOUNTS_ORIGIN = "https://accounts.google.com"
13
+ GOOGLE_OAUTH_API_ORIGIN = "https://oauth2.googleapis.com"
14
+
15
+ DEFAULT_CSP_DIRECTIVES: tuple[tuple[str, list[str]], ...] = (
16
+ ("default-src", ["'self'"]),
17
+ ("base-uri", ["'self'"]),
18
+ ("object-src", ["'none'"]),
19
+ ("frame-ancestors", ["'self'"]),
20
+ ("form-action", ["'self'"]),
21
+ ("img-src", ["'self'", "data:", "blob:", GOOGLE_ACCOUNTS_ORIGIN]),
22
+ ("font-src", ["'self'", "data:", GOOGLE_FONTS_ASSETS_ORIGIN]),
23
+ ("connect-src", ["'self'", GOOGLE_ACCOUNTS_ORIGIN,
24
+ GOOGLE_OAUTH_API_ORIGIN]),
25
+ ("frame-src", ["'self'", GOOGLE_ACCOUNTS_ORIGIN]),
26
+ ("script-src", ["'self'", "'unsafe-inline'", "'unsafe-eval'"]),
27
+ ("script-src-elem", ["'self'", "'unsafe-inline'", GOOGLE_ACCOUNTS_ORIGIN]),
28
+ ("style-src", ["'self'", "'unsafe-inline'", GOOGLE_FONTS_STYLES_ORIGIN]),
29
+ ("style-src-elem", ["'self'", "'unsafe-inline'",
30
+ GOOGLE_FONTS_STYLES_ORIGIN]),
31
+ )
32
+
33
+ # Add project-specific browser-side origins here only when the browser must
34
+ # load a third-party resource that is not already covered by the defaults.
35
+ # Example:
36
+ # PROJECT_CSP_EXTRA_SOURCES = {
37
+ # "script-src-elem": ["https://cdn.jsdelivr.net"],
38
+ # "connect-src": ["https://api.example.com"],
39
+ # }
40
+ PROJECT_CSP_EXTRA_SOURCES: dict[str, Any] = {}
41
+
42
+
43
+ def resolve_safe_public_path(base_dir: str | Path, relative_path: str) -> Optional[Path]:
44
+ base_path = Path(base_dir).resolve()
45
+ try:
46
+ candidate = (base_path / relative_path).resolve()
47
+ candidate.relative_to(base_path)
48
+ except (OSError, RuntimeError, ValueError):
49
+ return None
50
+
51
+ if not candidate.is_file():
52
+ return None
53
+
54
+ return candidate
55
+
56
+
57
+ def public_file_response(
58
+ base_dir: str | Path,
59
+ relative_path: str,
60
+ *,
61
+ media_type: Optional[str] = None,
62
+ ) -> Response:
63
+ file_path = resolve_safe_public_path(base_dir, relative_path)
64
+ if file_path is None:
65
+ return Response(status_code=404)
66
+
67
+ resolved_media_type = media_type
68
+ if resolved_media_type is None:
69
+ resolved_media_type, _ = mimetypes.guess_type(str(file_path))
70
+
71
+ return FileResponse(
72
+ file_path,
73
+ media_type=resolved_media_type or "application/octet-stream",
74
+ )
75
+
76
+
77
+ def client_error_message(exc: Exception, *, is_production: bool) -> str:
78
+ return str(exc) if not is_production else "An unexpected error occurred."
79
+
80
+
81
+ def get_session_secret(*, is_production: bool) -> str:
82
+ session_secret = (os.getenv("AUTH_SECRET") or "").strip()
83
+ if session_secret and session_secret.lower() not in INSECURE_SESSION_SECRET_VALUES:
84
+ return session_secret
85
+
86
+ if not is_production:
87
+ return "change-me"
88
+
89
+ raise RuntimeError(
90
+ "AUTH_SECRET must be set to a non-default value when APP_ENV=production."
91
+ )
92
+
93
+
94
+ def _normalize_csp_sources(value: Any) -> list[str]:
95
+ if isinstance(value, str):
96
+ return [token.strip() for token in value.replace(",", " ").split() if token.strip()]
97
+
98
+ if isinstance(value, (list, tuple, set)):
99
+ normalized: list[str] = []
100
+ for item in value:
101
+ normalized.extend(_normalize_csp_sources(item))
102
+ return normalized
103
+
104
+ return []
105
+
106
+
107
+ def normalize_csp_extra_sources(parsed: Optional[dict[str, Any]]) -> dict[str, list[str]]:
108
+ if not isinstance(parsed, dict):
109
+ return {}
110
+
111
+ normalized: dict[str, list[str]] = {}
112
+ for directive, sources in parsed.items():
113
+ if not isinstance(directive, str):
114
+ continue
115
+
116
+ directive_sources = _normalize_csp_sources(sources)
117
+ if directive_sources:
118
+ normalized[directive.strip()] = directive_sources
119
+
120
+ return normalized
121
+
122
+
123
+ def _merge_sources(*groups: list[str]) -> list[str]:
124
+ merged: list[str] = []
125
+ seen: set[str] = set()
126
+
127
+ for group in groups:
128
+ for source in group:
129
+ normalized = source.strip()
130
+ if not normalized or normalized in seen:
131
+ continue
132
+ merged.append(normalized)
133
+ seen.add(normalized)
134
+
135
+ return merged
136
+
137
+
138
+ def build_content_security_policy() -> str:
139
+ # PulsePoint currently relies on inline scripts/styles and runtime codegen,
140
+ # so this policy narrows source scope without breaking the app runtime.
141
+ extra_sources = normalize_csp_extra_sources(PROJECT_CSP_EXTRA_SOURCES)
142
+ directives: list[str] = []
143
+
144
+ for name, defaults in DEFAULT_CSP_DIRECTIVES:
145
+ sources = _merge_sources(
146
+ defaults,
147
+ extra_sources.get(name, []),
148
+ )
149
+ directives.append(f"{name} {' '.join(sources)}")
150
+
151
+ return "; ".join(directives)
152
+
153
+
154
+ def build_security_headers(*, is_production: bool) -> dict[str, str]:
155
+ headers = {
156
+ "content-security-policy": build_content_security_policy(),
157
+ "permissions-policy": "camera=(), geolocation=(), microphone=(), payment=(), usb=()",
158
+ "referrer-policy": "strict-origin-when-cross-origin",
159
+ "x-content-type-options": "nosniff",
160
+ "x-frame-options": "SAMEORIGIN",
161
+ }
162
+ if is_production:
163
+ headers["strict-transport-security"] = "max-age=31536000; includeSubDomains"
164
+ return headers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "0.2.0-beta.91",
3
+ "version": "0.2.0-beta.92",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",