create-caspian-app 0.2.0-beta.91 → 0.2.0-beta.93
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.
|
@@ -69,8 +69,9 @@
|
|
|
69
69
|
|
|
70
70
|
### `main.py`
|
|
71
71
|
|
|
72
|
-
- Treat `main.py` as the repo source of truth for FastAPI setup,
|
|
73
|
-
-
|
|
72
|
+
- Treat `main.py` as the repo source of truth for FastAPI setup, auth bootstrap, middleware wiring, route registration, cache defaults, and error handlers.
|
|
73
|
+
- When the app factors browser hardening or safe static-file behavior into app-owned helpers, treat `main.py` plus those imported helpers as the runtime source of truth together.
|
|
74
|
+
- Preserve the effective middleware execution order unless the task explicitly changes request semantics: `SecurityHeadersMiddleware -> SessionMiddleware -> CSRFMiddleware -> AuthMiddleware -> RPCMiddleware`.
|
|
74
75
|
- Do not move normal file upload or file-manager behavior into `main.py`; keep those actions in the owning route `index.py` and shared helpers in `src/lib/**`.
|
|
75
76
|
- Document route param behavior exactly as implemented here.
|
|
76
77
|
- Do not use `main.py` alone to infer whether optional features are enabled; confirm that in `caspian.config.json` first.
|
|
@@ -83,6 +84,7 @@
|
|
|
83
84
|
- For file managers, keep shared storage, normalization, and Prisma-backed persistence helpers here while route-owned upload and delete `@rpc()` actions stay in `src/app/**/index.py`.
|
|
84
85
|
- When `caspian.config.json` has `mcp: true`, keep app-owned MCP tools in `src/lib/mcp/mcp_server.py` and keep the default FastMCP config in `src/lib/mcp/fastmcp.json`. If those locations change, update `settings/restart-mcp.ts` and the MCP docs together.
|
|
85
86
|
- Keep auth policy in `src/lib/auth/auth_config.py`. Keep auth bootstrap and middleware order changes in `main.py`.
|
|
87
|
+
- Keep app-owned browser hardening, CSP defaults, safe static-file helpers, and production session-secret enforcement in `src/lib/security/runtime_security.py` when that module exists.
|
|
86
88
|
|
|
87
89
|
### `src/components/**/*.py`
|
|
88
90
|
|
package/dist/AGENTS.md
CHANGED
|
@@ -62,11 +62,11 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
|
|
|
62
62
|
|
|
63
63
|
- Local Caspian docs live under `node_modules/caspian-utils/dist/docs/`.
|
|
64
64
|
- Workspace file instructions live under `.github/instructions/**/*.instructions.md` when the repo needs task- or library-specific AI guidance that should not be always-on.
|
|
65
|
-
- Use `node_modules/caspian-utils/dist/docs/core-runtime-map.md` when a behavior is controlled by `main.py` or `.venv/Lib/site-packages/casp/**` and the owning file is not obvious yet.
|
|
65
|
+
- Use `node_modules/caspian-utils/dist/docs/core-runtime-map.md` when a behavior is controlled by `main.py`, app-owned runtime helpers such as `src/lib/security/runtime_security.py`, or `.venv/Lib/site-packages/casp/**` and the owning file is not obvious yet.
|
|
66
66
|
- Use `node_modules/caspian-utils/dist/docs/pulsepoint-runtime-map.md` when a behavior is controlled by the shipped PulsePoint browser runtime and the task names state, effects, refs, context, portals, directives, `pp.rpc`, uploads, streaming, SPA navigation, or scroll restoration.
|
|
67
67
|
- Use `node_modules/caspian-utils/dist/docs/file-conventions.md` when the task asks what belongs in `index.html`, `index.py`, `layout.html`, `layout.py`, `loading.html`, `not-found.html`, or `error.html`.
|
|
68
68
|
- For grouped-subtree SPA navigation UX, the current browser runtime keeps unmarked shell scrollers stable and uses `pp-reset-scroll="true"` on the content pane that should reset. Check `pulsepoint.md`, `routing.md`, and `public/js/pp-reactive-v2.js` before changing that behavior.
|
|
69
|
-
- Before updating docs, verify runtime-specific claims such as middleware order, route param injection, `layout()` behavior,
|
|
69
|
+
- Before updating docs, verify runtime-specific claims such as middleware order, route param injection, `layout()` behavior, `StateManager` persistence, and browser security header or session-secret behavior against the current `main.py`, `src/lib/security/runtime_security.py`, and installed `casp` package rather than copying older notes.
|
|
70
70
|
- When generating or reviewing `src/app/**/index.html`, `src/app/**/layout.html`, or component HTML templates, treat the single-root rule as a hard requirement: exactly one authored top-level parent element or one imported `x-*` root, with any owned `<script>` kept inside that same root. Do not allow sibling top-level tags, sibling scripts, or stray top-level text, because Caspian injects `pp-component` on that final root and errors if it cannot.
|
|
71
71
|
|
|
72
72
|
## Task Routing
|
|
@@ -78,13 +78,13 @@ If the task generates or edits route, layout, or component HTML templates, check
|
|
|
78
78
|
- Project layout and file placement: read `node_modules/caspian-utils/dist/docs/index.md` and `node_modules/caspian-utils/dist/docs/project-structure.md`. Verify against the current workspace tree.
|
|
79
79
|
- File conventions and special route files: read `node_modules/caspian-utils/dist/docs/file-conventions.md` and `node_modules/caspian-utils/dist/docs/routing.md`. Verify against `main.py`, `.venv/Lib/site-packages/casp/layout.py`, `.venv/Lib/site-packages/casp/loading.py`, and `.venv/Lib/site-packages/casp/caspian_config.py`.
|
|
80
80
|
- Feature availability and tooling switches: read `caspian.config.json`. Verify against the current workspace tree, `main.py`, `prisma/**`, and `public/js/**`.
|
|
81
|
-
- Framework internals and core-file lookup: read `node_modules/caspian-utils/dist/docs/core-runtime-map.md`. Verify against `main.py`, `.venv/Lib/site-packages/casp/**`, and the matching feature docs.
|
|
81
|
+
- Framework internals and core-file lookup: read `node_modules/caspian-utils/dist/docs/core-runtime-map.md`. Verify against `main.py`, `src/lib/security/runtime_security.py`, `.venv/Lib/site-packages/casp/**`, and the matching feature docs.
|
|
82
82
|
- PulsePoint browser runtime lookup: read `node_modules/caspian-utils/dist/docs/pulsepoint-runtime-map.md` and `node_modules/caspian-utils/dist/docs/pulsepoint.md`. Verify against `public/js/pp-reactive-v2.js`, `main.py`, `.venv/Lib/site-packages/casp/scripts_type.py`, and `.venv/Lib/site-packages/casp/components_compiler.py`.
|
|
83
83
|
- Library-specific and task-specific rules: read the matching `.github/instructions/**/*.instructions.md` file. Verify against `caspian.config.json`, the current workspace tree, and the owning app and lib files.
|
|
84
84
|
- MCP server layout and launch flow: read `node_modules/caspian-utils/dist/docs/mcp.md`. Verify against `settings/restart-mcp.ts`, `package.json`, and `src/lib/mcp/**`.
|
|
85
85
|
- Routing, layouts, metadata: read `node_modules/caspian-utils/dist/docs/routing.md`. Verify against `main.py` and `.venv/Lib/site-packages/casp/layout.py`.
|
|
86
86
|
- SPA navigation and scroll restoration: read `pulsepoint.md`, `routing.md`, and `core-runtime-map.md`. Verify against `public/js/pp-reactive-v2.js`, `src/app/**/layout.html`, and `main.py`.
|
|
87
|
-
- Auth, sessions, RBAC, providers: read `node_modules/caspian-utils/dist/docs/auth.md`. Verify against `src/lib/auth/auth_config.py`, `main.py`, and `.venv/Lib/site-packages/casp/auth.py`.
|
|
87
|
+
- Auth, sessions, RBAC, providers: read `node_modules/caspian-utils/dist/docs/auth.md`. Verify against `src/lib/auth/auth_config.py`, `main.py`, `src/lib/security/runtime_security.py`, and `.venv/Lib/site-packages/casp/auth.py`.
|
|
88
88
|
- RPC, data loading, streaming, uploads: read `node_modules/caspian-utils/dist/docs/fetch-data.md` and `node_modules/caspian-utils/dist/docs/pulsepoint.md`. Verify against `.venv/Lib/site-packages/casp/rpc.py`, `public/js/pp-reactive-v2.js`, and `main.py`.
|
|
89
89
|
- File uploads and managers: read `node_modules/caspian-utils/dist/docs/file-uploads.md` and `node_modules/caspian-utils/dist/docs/fetch-data.md`. Verify against `src/app/**`, `src/lib/**`, `prisma/**`, and `settings/bs-config.ts`.
|
|
90
90
|
- Server state: read `node_modules/caspian-utils/dist/docs/state.md`. Verify against `.venv/Lib/site-packages/casp/state_manager.py` and `main.py`.
|
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
|
|
87
|
+
return client_error_message(exc, is_production=IS_PRODUCTION)
|
|
118
88
|
|
|
119
89
|
|
|
120
90
|
def _get_session_secret() -> str:
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|