diff --git a/CLAUDE.md b/CLAUDE.md index 54db095f..e5155df3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ cascade layer is inert, while unlayered CSS beats every Tailwind utility — hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling. **Lifecycle hooks** (in `framework/core/simple_module_core/module.py`) — all no-op by default; subclasses override as needed: -`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_health_checks` / `register_public_routes` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md). +`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_health_checks` / `register_public_routes` / `register_csp_sources` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_csp_sources(registry)` lets a module whitelist external asset origins (`registry.add("style-src", "https://rsms.me")`) — fetch directives only, validated at boot. `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md). **Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request: `(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → → Tenant (opt-in) → Locale → InertiaLayoutData → app`. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs and Inertia's absolute page url). When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names. @@ -87,7 +87,7 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b **Inertia**. `inertia.render("/", ...)` maps to `modules///pages/.tsx`, where `` is the PascalCase of the module directory (`blog_posts` → `BlogPosts`). Host-level pages under `host/client_app/pages/` use a bare ``. `InertiaLayoutDataMiddleware` populates shared props (`auth`, `menus`, `i18n`); use `InertiaDep` from `simple_module_hosting.inertia_deps`. Mismatched keys fire `SM003` (orphan page) / `SM004` (phantom render). -**CSRF defence**. There is no explicit CSRF token middleware. Protection comes from `SameSite=Lax` on the session cookie (Starlette default): browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form-submit from another origin is unauthenticated. Raw `fetch()` calls in page code don't need a token header. +**CSRF defence**. Baseline protection comes from `SameSite=Lax` on the session cookie (Starlette default): browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form-submit from another origin is unauthenticated. Raw `fetch()` calls in page code don't need a token header by default. Modules wanting defence in depth opt into `simple_module_hosting.csrf` — `RequiresCsrf` as a router dependency plus `get_csrf_token(request)` exposed as a view prop; callers echo it as `X-CSRF-Token` on unsafe methods. ## Conventions to follow diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index f7ea307c..082fefc5 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -300,6 +300,61 @@ which `AuthMiddleware` consults on every request. See [`docs/framework/public-routes.md`](framework/public-routes.md) for match kinds and resolution order. +### CSP sources (external assets) + +The host ships a strict Content-Security-Policy. A module whose frontend loads +an asset from another origin — a font CDN, a tile server — declares it via +`register_csp_sources`, and the host folds the origins into both the dev +(Vite-widened) and production policies: + +```python +def register_csp_sources(self, registry): + registry.add("style-src", "https://rsms.me") + registry.add("font-src", "https://rsms.me") +``` + +Only fetch directives are extendable (`style-src`, `font-src`, `img-src`, +`connect-src`, …) — never `default-src`, `base-uri`, `form-action`, or +`frame-ancestors`, which belong to the host operator. Sources are validated +single tokens; an invalid declaration raises at boot. + +### CSRF (opt-in token check) + +The framework baseline is `SameSite=Lax` on the session cookie. Modules that +want defence in depth opt into a session-bound token check per router: + +```python +from simple_module_hosting.csrf import RequiresCsrf, get_csrf_token + +router = APIRouter(dependencies=[Depends(RequiresCsrf())]) +# expose the token to the frontend as a view prop: +{"csrf_token": get_csrf_token(request)} +``` + +Callers echo the token back as `X-CSRF-Token` on `POST`/`PUT`/`PATCH`/`DELETE`; +safe methods are never checked, and apps without `SessionMiddleware` (bare +test apps) are exempt. + +### Error responses (HTML vs JSON) + +403/404/422/500 are content-negotiated. Requests under `/api/*` — the +documented prefix for every module's JSON surface — or with an explicit +`Accept: application/json` get a JSON body: + +```json +{ "detail": "Permission required: pagebuilder.edit" } +``` + +Browser-shaped requests get the rendered Inertia error page, which carries +the layout, i18n copy, and the request's correlation id. "Browser-shaped" +means the request lists `text/html` in its Accept header without preferring +JSON over it (q-values decide: `application/json, text/html;q=0.5` still +gets JSON, and `text/html;q=0` rules html out) — navigations and Inertia +visits qualify, and that wins even under `/api/*` (OAuth login links and +file-download `` hrefs are real navigations to API paths); a bare +`fetch()` sends `Accept: */*` and gets JSON there. Module endpoint code doesn't opt in or +out — raise `HTTPException` as usual and the handler picks the right shape. + ### Design packs (site-wide look) A *design pack* is a stylesheet a module ships that restyles the public site by diff --git a/docs/framework/lifecycle.md b/docs/framework/lifecycle.md index cbed6fac..4589fad3 100644 --- a/docs/framework/lifecycle.md +++ b/docs/framework/lifecycle.md @@ -12,6 +12,7 @@ register_feature_flags register_event_handlers register_health_checks register_public_routes +register_csp_sources register_design_packs register_exception_handlers register_middleware @@ -128,6 +129,24 @@ async def _check_db(self) -> HealthCheckResult: ... Each check returns a `HealthCheckResult(status=HealthStatus.HEALTHY | DEGRADED | UNHEALTHY, detail=...)`. The `/health/ready` endpoint runs all checks concurrently and reports the worst status (a raising check counts as `UNHEALTHY`). +## `register_csp_sources(registry)` + +Whitelist external origins your frontend loads assets from — a font CDN, a +tile server, an analytics endpoint. The host ships a strict +Content-Security-Policy; without a declaration the browser blocks the request. + +```python +def register_csp_sources(self, registry) -> None: + registry.add("style-src", "https://rsms.me") + registry.add("font-src", "https://rsms.me") +``` + +Only fetch directives (`style-src`, `font-src`, `img-src`, `connect-src`, …) +can be extended — never `default-src`, `base-uri`, `form-action`, or +`frame-ancestors`, which belong to the host operator. Each source must be a +single origin/scheme token; invalid declarations raise at boot. The origins +land in both the development (Vite-widened) and production policies. + ## `register_exception_handlers(app)` Register FastAPI exception handlers scoped to your module's exceptions: @@ -214,6 +233,7 @@ class OrdersModule(ModuleBase): def register_event_handlers(self, bus, app=None): ... def register_health_checks(self, registry): ... def register_public_routes(self, registry): ... + def register_csp_sources(self, registry): ... def register_exception_handlers(self, app): ... def register_middleware(self, app): ... def register_routes(self, api_router, view_router): ... diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index 27a102bd..1773b899 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -93,11 +93,11 @@ Emits a structured log line per request with method, path, status, duration, and ### `SecurityHeadersMiddleware` -Sets conservative defaults: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection: 0` (the legacy auditor is disabled in favour of CSP), plus a default CSP and — outside development — HSTS. In development the CSP is widened for the Vite dev origin and HSTS is suppressed. Override on a per-route basis with your own response headers. +Sets conservative defaults: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection: 0` (the legacy auditor is disabled in favour of CSP), plus a default CSP and — outside development — HSTS. In development the CSP is widened for the Vite dev origin and HSTS is suppressed. Modules that load assets from an external origin extend the policy through the [`register_csp_sources`](lifecycle.md#register_csp_sourcesregistry) hook; both the dev and production variants honor those origins. Override on a per-route basis with your own response headers. ### `SessionMiddleware` -Starlette's built-in signed-cookie sessions. Cookie name is `session`; attributes are `HttpOnly`, `SameSite=Lax`. `SameSite=Lax` is the CSRF defence: browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form submission from another origin is unauthenticated. +Starlette's built-in signed-cookie sessions. Cookie name is `session`; attributes are `HttpOnly`, `SameSite=Lax`. `SameSite=Lax` is the baseline CSRF defence: browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form submission from another origin is unauthenticated. Modules wanting defence in depth opt into the session-bound token check in `simple_module_hosting.csrf` — `RequiresCsrf` as a router dependency, `get_csrf_token(request)` exposed as a view prop, and callers echoing it as `X-CSRF-Token` on unsafe methods. ### `TenantMiddleware` *(opt-in)* diff --git a/docs/framework/overview.md b/docs/framework/overview.md index 62169f11..e2e487b9 100644 --- a/docs/framework/overview.md +++ b/docs/framework/overview.md @@ -20,7 +20,7 @@ What actually happens when you run `uvicorn main:app`: `Settings`, `DatabaseState` (engines per provider), `EventBus`, `MenuRegistry`, `PermissionRegistry`, `FeatureFlagRegistry`, `HealthRegistry`, `I18nRegistry`. They are bundled into a frozen `Services` dataclass and attached to `app.state.sm`. 3. **Discovery** — `discover_modules()` reads Python entry points under the `simple_module` group, imports each one, validates it's a `ModuleBase` subclass with a non-null `meta`, and topologically sorts by `ModuleMeta.depends_on`. 4. **Lifecycle hooks run in sorted order**. For each module, in this order: - `register_settings` → `register_menu_items` → `register_permissions` → `register_feature_flags` → `register_event_handlers` → `register_health_checks` → `register_public_routes` → `register_design_packs` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)`. + `register_settings` → `register_menu_items` → `register_permissions` → `register_feature_flags` → `register_event_handlers` → `register_health_checks` → `register_public_routes` → `register_csp_sources` → `register_design_packs` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)`. 5. **Middleware is installed** — framework middleware first, then whatever modules registered. See [Middleware pipeline](/framework/middleware). 6. **Routers mount** — `api_router` at `/api`, `view_router` at `/`. Each module's sub-routers were attached via `register_routes`. 7. **Lifespan `on_startup`** — each module's async `on_startup` runs in dependency order. This is where background workers, warm caches, or remote-service health probes start. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 9802e1f1..d88db68f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -13,10 +13,10 @@ Prefix is always `SM_`. These are the pre-DB knobs read by `simple_module_hostin | Variable | Default | Notes | |---|---|---| -| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | **Required in production.** Async URL. Postgres: `postgresql+asyncpg://…` | +| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | **Required in production.** Async URL. Postgres: `postgresql+asyncpg://…` Relative sqlite paths resolve against the project root (the `.env` location), not the cwd. | | `SM_ENVIRONMENT` | `development` | Any value other than `development`, `test`, `testing` triggers strict discovery + placeholder-secret checks. | | `SM_SECRET_KEY` | `change-me-in-production` | **Must** be overridden in production — session cookie signing key. | -| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — where the Vite HMR client connects. | +| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — where the Vite HMR client connects. Scaffolded apps derive the Vite dev server's port and origin from this same value, so it's the single knob for moving off 5050. | | `SM_DEBUG` | `false` | Enables debug mode (shows tracebacks in HTTP responses). | | `SM_LOG_LEVEL` | `INFO` | `DEBUG`/`INFO`/`WARNING`/`ERROR` | | `SM_LOG_FORMAT` | `json` | `json` (structured) or `text`. | diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 1a427695..9a893524 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -406,6 +406,43 @@ package dir (a `force-include` maps it in), and `static/dist` is gitignored (an `artifacts` entry ships it when present without failing the build when it isn't). +## External asset origins (CSP) + +The host ships a strict Content-Security-Policy. If your pages load anything +from another origin — a font CDN, a tile server, a third-party API — declare +it, or the browser blocks the request and your feature silently breaks in +every host: + +```python +def register_csp_sources(self, registry): + registry.add("style-src", "https://rsms.me") + registry.add("font-src", "https://rsms.me") +``` + +Only fetch directives can be extended; each source must be a single +origin/scheme token, validated at boot. See +[docs/framework/lifecycle.md](framework/lifecycle.md#register_csp_sourcesregistry). + +## CSRF on mutation endpoints + +The framework's baseline CSRF defence is `SameSite=Lax` on the session +cookie — plain Inertia forms need nothing extra. For defence in depth on a +module's JSON mutation surface, opt into the framework's session-bound token +check instead of rolling your own: + +```python +from simple_module_hosting.csrf import RequiresCsrf, get_csrf_token + +router = APIRouter(dependencies=[Depends(RequiresCsrf())]) + +# expose the token to your pages as a view prop: +await inertia.render("MyModule/Page", {"csrf_token": get_csrf_token(request)}) +``` + +Frontend callers echo the token back as `X-CSRF-Token` on +`POST`/`PUT`/`PATCH`/`DELETE`. Safe methods are never checked, and bare test +apps without `SessionMiddleware` are exempt, so unit tests need no ceremony. + ## Developing out-of-tree A module in its own repo has no host around it — these are the three diff --git a/docs/reference/env-vars.md b/docs/reference/env-vars.md index b1e02e43..7e1ad499 100644 --- a/docs/reference/env-vars.md +++ b/docs/reference/env-vars.md @@ -8,15 +8,16 @@ This is the full reference. See [Configuration](/guide/configuration) for a narr | Variable | Default | Notes | |---|---|---| -| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | **Required in production.** Async URL: `postgresql+asyncpg://user:pw@host:5432/db` or `sqlite+aiosqlite:///./app.db`. | +| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | **Required in production.** Async URL: `postgresql+asyncpg://user:pw@host:5432/db` or `sqlite+aiosqlite:///./app.db`. Relative sqlite paths resolve against the project root (the `.env` location), not the process cwd — CLI tools run from `host/` hit the same file as the app. | | `SM_ENVIRONMENT` | `development` | `development` and `testing` are the only non-prod values (placeholder-secret check is skipped for both). Any value other than `development` triggers strict module discovery. | | `SM_SECRET_KEY` | `change-me-in-production` | **Must** be overridden in production — session cookie signing key. | | `SM_DEBUG` | `false` | Enables debug mode (tracebacks in HTTP responses). | | `SM_LOG_LEVEL` | `INFO` | `DEBUG`/`INFO`/`WARNING`/`ERROR`. | | `SM_LOG_FORMAT` | `json` | `text` for readable dev logs, `json` for structured logs in prod. | | `SM_MODULES_ENABLED` | unset | Comma-separated allow-list to disable modules without uninstalling them. | -| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — where the Vite HMR client connects. | -| `SM_VITE_PORT` | `5050` | Dev only — port the Vite dev server binds to (read by `vite.config.ts`). If you change it, set `SM_VITE_DEV_URL` to match so the backend points the HMR client at the right origin. | +| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — where the Vite HMR client connects. Freshly scaffolded apps also derive the Vite dev server's port and origin from this one value (via `client_app/vite.dev-url.ts`), so changing it here is the whole move. | +| `SM_VITE_PORT` | `5050` | Dev only — port this repo's own host `vite.config.ts` binds to. If you change it, set `SM_VITE_DEV_URL` to match so the backend points the HMR client at the right origin. New scaffolds don't need it — they read `SM_VITE_DEV_URL` directly. | +| `SM_PROJECT_ROOT` | unset | Overrides project-root discovery: where the `.env` is looked up and what relative sqlite paths resolve against. Normally unnecessary — settings walk up from the cwd (stopping at repo boundaries and `$HOME`) to find the `.env` on their own. | | `SM_AUTH_PUBLIC_PATHS` | `[]` | JSON array of host-level anonymous-access path prefixes. Escape hatch for exposing a route without a session when no module owns it; modules should prefer the method-aware `register_public_routes` hook. | ## DB connection pool diff --git a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts index d3bcb058..7a2fabb4 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts +++ b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { type Plugin, defineConfig } from 'vite'; +import { viteDevServer } from './vite.dev-url'; // Force every importer (host, workspace module, wheel-installed module) // to resolve to one React copy + a single Inertia hook context. Without @@ -29,6 +30,10 @@ function findNodeModulesRoot(start: string): string { } const fsRoot = findNodeModulesRoot(__dirname); +// Walks up from client_app to the directory holding the project .env itself — +// fsRoot tracks node_modules, which (in flat mode) is NOT where .env lives. +const { origin: devUrl, port: devPort } = viteDevServer(__dirname); + // Load the module pages manifest written by the Python host at boot. // Each entry points at an absolute pages/ directory — typically inside a // pip-installed module wheel. Vite needs these in server.fs.allow so the @@ -281,9 +286,9 @@ export default defineConfig({ }, }, server: { - port: 5050, + port: devPort, strictPort: true, - origin: 'http://localhost:5050', + origin: devUrl, fs: { allow: [fsRoot, ...moduleFsAllow], }, diff --git a/framework/cli/simple_module_cli/templates/host/client_app/vite.dev-url.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.dev-url.ts new file mode 100644 index 00000000..c15a54d6 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/host/client_app/vite.dev-url.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadEnv } from 'vite'; + +// SM_VITE_DEV_URL (process env, then the project .env) drives the dev +// server's port and origin, so Vite and the backend read the same value and +// can never drift apart. The documented default stays http://localhost:5050. + +// The project .env sits at the project root (next to .env.example), which may +// be one or more levels above client_app — mirror the backend's resolution +// (SM_PROJECT_ROOT override, then a bounded walk-up stopping at a repo +// boundary) so both sides read the same file, whatever directory +// node_modules landed in. +// Shared scratch dirs like /tmp are world-writable: a `.env` sitting there +// could belong to another local user. Mirrors the backend's +// `_is_world_writable_dir` guard. The starting dir is exempt — running +// *from* such a directory keeps the pre-existing "load the cwd's .env" +// fallback behavior. +function isWorldWritableDir(dir: string): boolean { + try { + return (fs.statSync(dir).mode & 0o002) !== 0; + } catch { + return false; + } +} + +export function findEnvDir(start: string): string { + const explicitRoot = process.env.SM_PROJECT_ROOT; + if (explicitRoot) return explicitRoot; + let dir = start; + // os.homedir() throws when $HOME is unset and the UID has no passwd entry + // (rootless containers, some CI sandboxes) — mirror the backend's + // find_env_file(), which catches the equivalent Path.home() failure and + // just skips the home-boundary check rather than crashing Vite's config load. + let home: string | null; + try { + home = os.homedir(); + } catch { + home = null; + } + for (let i = 0; i < 5; i++) { + // Never treat $HOME (or anything above it) as the project — a stray + // ~/.env must not steer the dev server (same rule as the backend). + if (home !== null && dir === home) break; + if (dir !== start && isWorldWritableDir(dir)) break; + if (fs.existsSync(path.join(dir, '.env')) || fs.existsSync(path.join(dir, '.env.example'))) { + return dir; + } + // A `.git` marks a project root — never ascend past one, or a nested + // checkout would read the outer project's .env (same rule as the + // backend). The boundary directory IS the project root, so return it + // directly rather than falling through to `start` — matching the + // Python twin (`find_env_file`), which anchors at the boundary too. + if (fs.existsSync(path.join(dir, '.git'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return start; +} + +export function viteDevServer(startDir: string): { origin: string; port: number } { + // loadEnv parses .env with the same dotenv semantics the backend uses + // (quotes, inline comments, `export` prefixes), and a real process env var + // wins over the file — the same precedence as pydantic-settings. + const env = loadEnv(process.env.NODE_ENV ?? 'development', findEnvDir(startDir), 'SM_'); + // `??` alone only falls back on null/undefined: a blank `SM_VITE_DEV_URL=` + // in `.env` parses as an empty string, which is neither, and would reach + // `new URL('')` below. Treat a blank value the same as an unset one. + const raw = env.SM_VITE_DEV_URL || 'http://localhost:5050'; + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error(`SM_VITE_DEV_URL must be a full URL like http://localhost:5050, got: ${raw}`); + } + // A scheme-less value ("localhost:5310") parses as protocol "localhost:" + // with origin "null" — catch it here rather than shipping a broken origin. + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`SM_VITE_DEV_URL must be a full URL like http://localhost:5050, got: ${raw}`); + } + return { + // .origin normalizes away trailing slashes/paths that would otherwise + // produce double-slash asset URLs in server.origin. + origin: url.origin, + // No explicit port means the URL is fronted by a proxy (https://dev.example.com): + // keep binding the documented local default — binding 80/443 directly + // needs privileges and would crash under strictPort. + port: url.port ? Number(url.port) : 5050, + }; +} diff --git a/framework/cli/tests/test_cli_vite_port_env.py b/framework/cli/tests/test_cli_vite_port_env.py new file mode 100644 index 00000000..5592e035 --- /dev/null +++ b/framework/cli/tests/test_cli_vite_port_env.py @@ -0,0 +1,48 @@ +"""The scaffolded Vite config must derive its port from SM_VITE_DEV_URL. + +Field finding: `vite.config.ts` hardcoded `port: 5050, strictPort: true` +and a literal origin while the backend read `SM_VITE_DEV_URL` from `.env` — +running on any other port meant editing the generated file *and* the env +var in sync. The scaffold now derives both from the single env value. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from simple_module_cli.cli import app +from typer.testing import CliRunner + + +def _scaffold(tmp_path: Path) -> Path: + runner = CliRunner() + result = runner.invoke( + app, + [ + "new", + "viteportapp", + "--dest", + str(tmp_path / "viteportapp"), + "--preset", + "minimal", + "--yes", + "--no-install", + ], + ) + assert result.exit_code == 0, result.output + return tmp_path / "viteportapp" / "host" / "client_app" / "vite.config.ts" + + +def test_vite_config_derives_port_from_env_url(tmp_path: Path) -> None: + config = _scaffold(tmp_path) + text = config.read_text(encoding="utf-8") + # port and origin both come from the derived URL — no literal pin left + assert re.search(r"port:\s*5050\b", text) is None + assert re.search(r"origin:\s*'http://localhost:5050'", text) is None + assert "viteDevServer" in text + assert "strictPort: true" in text # still fail fast on a taken port + + helper = (config.parent / "vite.dev-url.ts").read_text(encoding="utf-8") + assert "SM_VITE_DEV_URL" in helper + assert "http://localhost:5050" in helper # the documented default remains diff --git a/framework/core/simple_module_core/__init__.py b/framework/core/simple_module_core/__init__.py index 65ba4730..a9d116b8 100644 --- a/framework/core/simple_module_core/__init__.py +++ b/framework/core/simple_module_core/__init__.py @@ -1,6 +1,7 @@ """SimpleModule Core - Module system, menu, permissions, events, and diagnostics.""" from simple_module_core.audit_links import AuditLink, AuditLinkRegistry +from simple_module_core.csp import CspSourceError, CspSourceRegistry from simple_module_core.design_packs import DesignPack, DesignPackRegistry from simple_module_core.diagnostics import ( DiagnosticLevel, @@ -48,6 +49,8 @@ "AuditLink", "AuditLinkRegistry", "CircularDependencyError", + "CspSourceError", + "CspSourceRegistry", "DesignPack", "DesignPackRegistry", "DiagnosticLevel", diff --git a/framework/core/simple_module_core/__main__.py b/framework/core/simple_module_core/__main__.py index 0f122ab2..020d178e 100644 --- a/framework/core/simple_module_core/__main__.py +++ b/framework/core/simple_module_core/__main__.py @@ -9,7 +9,8 @@ i18n checks are included when ``SM_I18N_SUPPORTED_LOCALES`` is set in env (or ``.env``). Host-level ``host/locales/`` and shared ``packages/ui/locales/`` -are picked up relative to ``SM_PROJECT_ROOT`` (or the current working dir). +are picked up relative to the project root (``SM_PROJECT_ROOT``, else the +directory of the discovered ``.env``, else the current working dir). """ from __future__ import annotations @@ -31,7 +32,7 @@ select_auth_provider, topological_sort, ) -from simple_module_core.dotenv import parse_dotenv +from simple_module_core.dotenv import find_env_file, parse_dotenv from simple_module_core.exceptions import InvalidModuleError @@ -64,7 +65,11 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]: def _discover_extra_locale_sources() -> list[tuple[str, str, Path]]: """Return ``[(reporter, namespace, path), ...]`` for host + ui locale dirs.""" - root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd()) + # Anchor on the same project root the `.env` was loaded from + # (`parse_dotenv` walks up from the cwd) — resolving against the bare cwd + # here would look for `host/locales` in the wrong directory whenever + # doctor runs from a subdirectory. + root = find_env_file().parent out: list[tuple[str, str, Path]] = [] host_locales = root / "host" / "locales" if host_locales.is_dir(): diff --git a/framework/core/simple_module_core/csp.py b/framework/core/simple_module_core/csp.py new file mode 100644 index 00000000..f7b95eac --- /dev/null +++ b/framework/core/simple_module_core/csp.py @@ -0,0 +1,140 @@ +"""Registry for module-contributed Content-Security-Policy sources. + +Modules whose frontend loads assets from an external origin (a font CDN, a +tile server, an analytics endpoint) declare those origins through +``ModuleBase.register_csp_sources``. The host folds them into the CSP it +already ships — the module never rewrites the whole policy, and a typo'd +origin fails loudly at boot instead of silently weakening the header. +""" + +from __future__ import annotations + +import re + +__all__ = ["CspSourceError", "CspSourceRegistry"] + +# Fetch directives a module may extend. Deliberately excludes the policy's +# structural directives (default-src, base-uri, form-action, frame-ancestors, +# sandbox): widening those changes the security posture of the whole app and +# belongs to the host operator, not a module. +_EXTENDABLE_DIRECTIVES = frozenset( + { + "script-src", + "script-src-elem", + "style-src", + "style-src-elem", + "img-src", + "font-src", + "connect-src", + "media-src", + "frame-src", + "worker-src", + "child-src", + } +) + +# A source is a scheme, an origin (optionally with scheme/wildcard/port), or +# a data-ish scheme keyword. One token — anything that could smuggle a second +# token or terminate/extend the clause (whitespace, ";", ",", quotes) is +# rejected, and a wildcard prefix must be followed by a real host (`*.` alone +# is not a source). +_SOURCE_RE = re.compile(r"^(?:[A-Za-z][A-Za-z0-9+.-]*:(?://)?)?(?:(?:\*\.)?[^\s;,'\"*]+)?$") + +# CSP3 fallback chains: when a directive is absent from a policy, the browser +# consults these directives in order (ending at ``default-src``). A clause we +# append for a previously-absent directive must be seeded from the nearest +# clause the policy already has along this chain — seeding with a bare +# ``'self'`` would *narrow* the policy (e.g. a fresh ``style-src-elem`` clause +# cuts off the fallback to ``style-src`` and silently drops its +# ``'unsafe-inline'`` and font origins). +_FALLBACK_CHAINS: dict[str, tuple[str, ...]] = { + "script-src-elem": ("script-src", "default-src"), + "style-src-elem": ("style-src", "default-src"), + "frame-src": ("child-src", "default-src"), + "worker-src": ("child-src", "script-src", "default-src"), +} + + +class CspSourceError(ValueError): + """Invalid CSP directive or source declared by a module.""" + + +class CspSourceRegistry: + """Collects per-directive extra CSP sources from modules.""" + + def __init__(self) -> None: + self._sources: dict[str, list[str]] = {} + + def add(self, directive: str, source: str) -> None: + """Allow ``source`` in ``directive``, e.g. ``add("style-src", "https://rsms.me")``.""" + if directive not in _EXTENDABLE_DIRECTIVES: + raise CspSourceError( + f"CSP directive {directive!r} is not extendable; " + f"choose one of {sorted(_EXTENDABLE_DIRECTIVES)}" + ) + token = source.strip() + if not token or not _SOURCE_RE.match(token): + raise CspSourceError( + f"invalid CSP source {source!r} for {directive}: must be a single " + "origin or scheme token (no spaces, quotes, wildcards-only, or ';')" + ) + bucket = self._sources.setdefault(directive, []) + if token not in bucket: + bucket.append(token) + + def __bool__(self) -> bool: + return bool(self._sources) + + def extend_policy(self, policy: str) -> str: + """Fold the registered sources into an existing policy string. + + Existing clauses keep their order and gain only sources they don't + already list. A directive absent from the policy is appended as a new + clause seeded from the clause the browser would otherwise have fallen + back to (per ``_FALLBACK_CHAINS``, ending at ``default-src``) — + without that seed the new clause would *narrow* the policy, since the + browser stops consulting the fallback once the directive exists. + """ + if not self._sources: + return policy + directives: dict[str, list[str]] = {} + order: list[str] = [] + for clause in policy.split(";"): + clause = clause.strip() + if not clause: + continue + directive, _, rest = clause.partition(" ") + directives[directive] = rest.split() + order.append(directive) + for directive, extras in self._sources.items(): + if directive not in directives: + directives[directive] = list(self._seed_sources(directive, directives)) + order.append(directive) + bucket = directives[directive] + bucket.extend(e for e in extras if e not in bucket) + # A more-specific directive *shadows* its fallback chain once present: + # with `script-src-elem` already in the policy, a module's + # `script-src` addition would never reach