From ae2ca8f149951285128364449cc7ca3e5f1dd2b9 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Thu, 19 Feb 2026 15:43:20 +0100 Subject: [PATCH] feat: production-harden app and finalize merge-ready UX/theme baseline - complete tokenized theme preset system and admin style guide visibility - refine dashboard layout/scroll behavior and shared async/request UI states - add user nav-visibility settings across API, migration, and frontend stores - harden security headers/CSP handling and related test coverage - enforce CI repository hygiene + frontend contract/theme/build quality gates - update docs/env references and make migration smoke test head-aware --- .env.example | 15 + .github/workflows/ci.yml | 4 + README.md | 25 +- ...19_0008_user_settings_nav_visible_pages.py | 51 ++ app/api/routers/settings.py | 17 +- app/api/schemas.py | 2 + app/db/models.py | 7 + app/http/middleware.py | 120 ++++ app/main.py | 26 +- app/services/user_settings.py | 66 +- app/settings.py | 67 +- frontend/package.json | 1 + frontend/scripts/check_theme_tokens.mjs | 84 +++ frontend/src/app/AppShell.vue | 14 +- frontend/src/app/providers.ts | 8 + frontend/src/components/layout/AppHeader.vue | 28 +- frontend/src/components/layout/AppNav.vue | 35 +- frontend/src/components/layout/AppPage.vue | 31 +- .../components/patterns/AsyncStateGate.vue | 34 + .../components/patterns/QueueHealthBadge.vue | 15 +- .../patterns/RequestStateAlerts.vue | 44 ++ .../components/patterns/RunStatusBadge.vue | 10 +- frontend/src/components/ui/AppAlert.vue | 8 +- frontend/src/components/ui/AppBadge.vue | 10 +- frontend/src/components/ui/AppButton.vue | 10 +- frontend/src/components/ui/AppCard.vue | 2 +- frontend/src/components/ui/AppCheckbox.vue | 4 +- frontend/src/components/ui/AppEmptyState.vue | 6 +- frontend/src/components/ui/AppHelpHint.vue | 209 ++++++ frontend/src/components/ui/AppInput.vue | 2 +- frontend/src/components/ui/AppModal.vue | 6 +- frontend/src/components/ui/AppSelect.vue | 2 +- frontend/src/components/ui/AppSkeleton.vue | 2 +- frontend/src/components/ui/AppTable.vue | 10 +- frontend/src/env.d.ts | 5 + .../scholars/components/ScholarAvatar.vue | 67 ++ frontend/src/features/settings/index.ts | 1 + frontend/src/pages/AdminUsersPage.vue | 225 +++++-- frontend/src/pages/DashboardPage.vue | 331 ++++++---- frontend/src/pages/LoginPage.vue | 32 +- frontend/src/pages/PublicationsPage.vue | 349 +++++++--- frontend/src/pages/RunDetailPage.vue | 76 ++- frontend/src/pages/RunsPage.vue | 66 +- frontend/src/pages/ScholarsPage.vue | 595 ++++++++---------- frontend/src/pages/SettingsPage.vue | 301 +++++++-- frontend/src/pages/StyleGuidePage.vue | 292 ++++++++- frontend/src/stores/auth.ts | 5 + frontend/src/stores/theme.ts | 72 ++- frontend/src/stores/user_settings.ts | 87 +++ frontend/src/styles.css | 147 ++++- frontend/src/theme/THEME_PHASE0_INVENTORY.md | 89 +++ frontend/src/theme/presets.test.ts | 69 ++ frontend/src/theme/presets.ts | 274 ++++++++ frontend/src/theme/presets/dune.js | 314 +++++++++ frontend/src/theme/presets/graphite.json | 313 +++++++++ frontend/src/theme/presets/lilac.js | 313 +++++++++ frontend/src/theme/presets/oatmeal.js | 314 +++++++++ frontend/src/theme/presets/parchment.js | 313 +++++++++ frontend/src/theme/presets/scholarly.json | 313 +++++++++ frontend/src/theme/presets/tide.json | 313 +++++++++ frontend/tailwind.config.js | 112 +++- tests/integration/test_api_v1.py | 9 + tests/integration/test_migrations.py | 19 +- tests/smoke/test_migration_schema.py | 11 +- tests/unit/test_security_headers.py | 36 ++ tests/unit/test_user_settings.py | 70 +++ 66 files changed, 5655 insertions(+), 853 deletions(-) create mode 100644 alembic/versions/20260219_0008_user_settings_nav_visible_pages.py create mode 100644 frontend/scripts/check_theme_tokens.mjs create mode 100644 frontend/src/components/patterns/AsyncStateGate.vue create mode 100644 frontend/src/components/patterns/RequestStateAlerts.vue create mode 100644 frontend/src/components/ui/AppHelpHint.vue create mode 100644 frontend/src/features/scholars/components/ScholarAvatar.vue create mode 100644 frontend/src/stores/user_settings.ts create mode 100644 frontend/src/theme/THEME_PHASE0_INVENTORY.md create mode 100644 frontend/src/theme/presets.test.ts create mode 100644 frontend/src/theme/presets.ts create mode 100644 frontend/src/theme/presets/dune.js create mode 100644 frontend/src/theme/presets/graphite.json create mode 100644 frontend/src/theme/presets/lilac.js create mode 100644 frontend/src/theme/presets/oatmeal.js create mode 100644 frontend/src/theme/presets/parchment.js create mode 100644 frontend/src/theme/presets/scholarly.json create mode 100644 frontend/src/theme/presets/tide.json create mode 100644 tests/unit/test_security_headers.py create mode 100644 tests/unit/test_user_settings.py diff --git a/.env.example b/.env.example index 83a3a3e..5107a14 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,21 @@ MIGRATE_ON_START=1 SESSION_SECRET_KEY=replace-with-a-long-random-secret-at-least-32-characters SESSION_COOKIE_SECURE=1 +SECURITY_HEADERS_ENABLED=1 +SECURITY_X_CONTENT_TYPE_OPTIONS=nosniff +SECURITY_X_FRAME_OPTIONS=DENY +SECURITY_REFERRER_POLICY=strict-origin-when-cross-origin +SECURITY_PERMISSIONS_POLICY=accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=() +SECURITY_CROSS_ORIGIN_OPENER_POLICY=same-origin +SECURITY_CROSS_ORIGIN_RESOURCE_POLICY=same-origin +SECURITY_CSP_ENABLED=1 +SECURITY_CSP_POLICY=default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; object-src 'none' +SECURITY_CSP_DOCS_POLICY=default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' https:; object-src 'none'; frame-ancestors 'none' +SECURITY_CSP_REPORT_ONLY=0 +SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED=0 +SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE=31536000 +SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS=1 +SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD=0 LOGIN_RATE_LIMIT_ATTEMPTS=5 LOGIN_RATE_LIMIT_WINDOW_SECONDS=60 LOG_LEVEL=INFO diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 758e5dd..4ff6965 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,10 @@ jobs: working-directory: frontend run: npm install + - name: Frontend theme token policy + working-directory: frontend + run: npm run check:theme-tokens + - name: Frontend typecheck working-directory: frontend run: npm run typecheck diff --git a/README.md b/README.md index 75dd587..9ee3ccb 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml down Notes: - Boolean envs accept: `1/0`, `true/false`, `yes/no`, `on/off`. -- Values shown are defaults from `.env.example`. +- Values shown are deployment defaults from `.env.example` and `docker-compose.yml`. +- Some internal app fallbacks may differ for local test/dev safety when env vars are omitted. - `deploy` means used in regular deployment. - `dev` means used in local dev workflow. @@ -121,6 +122,26 @@ Notes: | `LOGIN_RATE_LIMIT_ATTEMPTS` | `5` | integer >= 1 | deploy, dev | Login attempts allowed per window. | | `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | `60` | integer >= 1 | deploy, dev | Login rate limit window in seconds. | +### HTTP Security Headers and CSP + +| Variable | Default | Options | Scope | Description | +| --- | --- | --- | --- | --- | +| `SECURITY_HEADERS_ENABLED` | `1` | boolean | deploy, dev | Global switch for response security headers middleware. | +| `SECURITY_X_CONTENT_TYPE_OPTIONS` | `nosniff` | header value | deploy, dev | `X-Content-Type-Options` response header value. | +| `SECURITY_X_FRAME_OPTIONS` | `DENY` | header value | deploy, dev | `X-Frame-Options` response header value. | +| `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` | header value | deploy, dev | `Referrer-Policy` response header value. | +| `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()` | permissions policy string | deploy, dev | `Permissions-Policy` response header value. | +| `SECURITY_CROSS_ORIGIN_OPENER_POLICY` | `same-origin` | header value | deploy, dev | `Cross-Origin-Opener-Policy` response header value. | +| `SECURITY_CROSS_ORIGIN_RESOURCE_POLICY` | `same-origin` | header value | deploy, dev | `Cross-Origin-Resource-Policy` response header value. | +| `SECURITY_CSP_ENABLED` | `1` | boolean | deploy, dev | Enable Content Security Policy headers. | +| `SECURITY_CSP_POLICY` | strict SPA/API default | CSP policy string | deploy, dev | CSP applied to app/API routes (excluding docs paths). | +| `SECURITY_CSP_DOCS_POLICY` | relaxed docs default | CSP policy string | deploy, dev | CSP override for `/docs` and `/redoc` to keep Swagger/ReDoc usable. | +| `SECURITY_CSP_REPORT_ONLY` | `0` | boolean | deploy, dev | Emit CSP in report-only mode instead of enforcement mode. | +| `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED` | `0` | boolean | deploy, dev | Enable `Strict-Transport-Security` header. | +| `SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE` | `31536000` | integer >= 0 | deploy, dev | `max-age` for HSTS header. | +| `SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS` | `1` | boolean | deploy, dev | Add `includeSubDomains` directive to HSTS header. | +| `SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD` | `0` | boolean | deploy, dev | Add `preload` directive to HSTS header. | + ### Logging | Variable | Default | Options | Scope | Description | @@ -155,7 +176,7 @@ Notes: | Variable | Default | Options | Scope | Description | | --- | --- | --- | --- | --- | -| `SCHOLAR_IMAGE_UPLOAD_DIR` | `/var/lib/scholarr/uploads` | writable absolute path | deploy, dev | Storage path for uploaded scholar images. Use a persistent mounted path in production. | +| `SCHOLAR_IMAGE_UPLOAD_DIR` | `/var/lib/scholarr/uploads` | writable absolute path | deploy, dev | Storage path for uploaded scholar images in compose deployments. App fallback without env is `/tmp/scholarr_uploads/scholar_images`. | | `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES` | `2000000` | integer >= 1 | deploy, dev | Max uploaded image size in bytes. | | `SCHOLAR_NAME_SEARCH_ENABLED` | `1` | boolean | deploy, dev | Enable name-search helper endpoint. | | `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS` | `21600` | integer >= 1 | deploy, dev | Cache TTL for successful name-search responses. | diff --git a/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py b/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py new file mode 100644 index 0000000..df481fd --- /dev/null +++ b/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py @@ -0,0 +1,51 @@ +"""Add per-user nav visibility settings. + +Revision ID: 20260219_0008 +Revises: 20260217_0007 +Create Date: 2026-02-19 14:58:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "20260219_0008" +down_revision: str | Sequence[str] | None = "20260217_0007" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +TABLE_NAME = "user_settings" +DEFAULT_NAV_VISIBLE_PAGES_SQL = ( + "'[\"dashboard\",\"scholars\",\"publications\",\"settings\",\"style-guide\",\"runs\",\"users\"]'::jsonb" +) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + if "nav_visible_pages" not in columns: + op.add_column( + TABLE_NAME, + sa.Column( + "nav_visible_pages", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text(DEFAULT_NAV_VISIBLE_PAGES_SQL), + ), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + if "nav_visible_pages" in columns: + op.drop_column(TABLE_NAME, "nav_visible_pages") diff --git a/app/api/routers/settings.py b/app/api/routers/settings.py index 7659591..082cfe1 100644 --- a/app/api/routers/settings.py +++ b/app/api/routers/settings.py @@ -23,6 +23,7 @@ def _serialize_settings(settings) -> dict[str, object]: "auto_run_enabled": bool(settings.auto_run_enabled), "run_interval_minutes": int(settings.run_interval_minutes), "request_delay_seconds": int(settings.request_delay_seconds), + "nav_visible_pages": list(settings.nav_visible_pages or []), } @@ -55,6 +56,11 @@ async def update_settings( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): + settings = await user_settings_service.get_or_create_settings( + db_session, + user_id=current_user.id, + ) + try: parsed_interval = user_settings_service.parse_run_interval_minutes( str(payload.run_interval_minutes) @@ -62,6 +68,11 @@ async def update_settings( parsed_delay = user_settings_service.parse_request_delay_seconds( str(payload.request_delay_seconds) ) + parsed_nav_visible_pages = user_settings_service.parse_nav_visible_pages( + payload.nav_visible_pages + if payload.nav_visible_pages is not None + else list(settings.nav_visible_pages or user_settings_service.DEFAULT_NAV_VISIBLE_PAGES) + ) except user_settings_service.UserSettingsServiceError as exc: raise ApiException( status_code=400, @@ -69,16 +80,13 @@ async def update_settings( message=str(exc), ) from exc - settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=current_user.id, - ) updated = await user_settings_service.update_settings( db_session, settings=settings, auto_run_enabled=bool(payload.auto_run_enabled), run_interval_minutes=parsed_interval, request_delay_seconds=parsed_delay, + nav_visible_pages=parsed_nav_visible_pages, ) logger.info( "api.settings.updated", @@ -88,6 +96,7 @@ async def update_settings( "auto_run_enabled": updated.auto_run_enabled, "run_interval_minutes": updated.run_interval_minutes, "request_delay_seconds": updated.request_delay_seconds, + "nav_visible_pages": updated.nav_visible_pages, }, ) return success_payload( diff --git a/app/api/schemas.py b/app/api/schemas.py index 13c093a..73a3452 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -433,6 +433,7 @@ class SettingsData(BaseModel): auto_run_enabled: bool run_interval_minutes: int request_delay_seconds: int + nav_visible_pages: list[str] model_config = ConfigDict(extra="forbid") @@ -448,6 +449,7 @@ class SettingsUpdateRequest(BaseModel): auto_run_enabled: bool run_interval_minutes: int request_delay_seconds: int + nav_visible_pages: list[str] | None = None model_config = ConfigDict(extra="forbid") diff --git a/app/db/models.py b/app/db/models.py index 68c3e72..d98cfe9 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -98,6 +98,13 @@ class UserSetting(Base): request_delay_seconds: Mapped[int] = mapped_column( Integer, nullable=False, server_default=text("10") ) + nav_visible_pages: Mapped[list[str]] = mapped_column( + JSONB, + nullable=False, + server_default=text( + '\'["dashboard","scholars","publications","settings","style-guide","runs","users"]\'::jsonb' + ), + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) diff --git a/app/http/middleware.py b/app/http/middleware.py index 9d85458..49a40a4 100644 --- a/app/http/middleware.py +++ b/app/http/middleware.py @@ -11,6 +11,32 @@ from starlette.responses import Response from app.logging_context import set_request_id REQUEST_ID_HEADER = "X-Request-ID" +DEFAULT_PERMISSIONS_POLICY = ( + "accelerometer=(), autoplay=(), camera=(), display-capture=(), " + "geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()" +) +DEFAULT_CSP_POLICY = ( + "default-src 'self'; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "img-src 'self' data: https:; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self' data:; " + "connect-src 'self'; " + "object-src 'none'" +) +DEFAULT_CSP_DOCS_POLICY = ( + "default-src 'self'; " + "img-src 'self' data: https:; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self' data:; " + "connect-src 'self' https:; " + "object-src 'none'; " + "frame-ancestors 'none'" +) logger = logging.getLogger(__name__) @@ -80,6 +106,100 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): return any(path.startswith(prefix) for prefix in self._skip_paths) +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + def __init__( + self, + app, + *, + enabled: bool = True, + x_content_type_options: str = "nosniff", + x_frame_options: str = "DENY", + referrer_policy: str = "strict-origin-when-cross-origin", + permissions_policy: str = DEFAULT_PERMISSIONS_POLICY, + cross_origin_opener_policy: str = "same-origin", + cross_origin_resource_policy: str = "same-origin", + content_security_policy_enabled: bool = True, + content_security_policy: str = DEFAULT_CSP_POLICY, + content_security_policy_docs: str = DEFAULT_CSP_DOCS_POLICY, + content_security_policy_report_only: bool = False, + strict_transport_security_enabled: bool = False, + strict_transport_security_max_age: int = 31_536_000, + strict_transport_security_include_subdomains: bool = True, + strict_transport_security_preload: bool = False, + ) -> None: + super().__init__(app) + self._enabled = enabled + self._x_content_type_options = x_content_type_options.strip() + self._x_frame_options = x_frame_options.strip() + self._referrer_policy = referrer_policy.strip() + self._permissions_policy = permissions_policy.strip() + self._cross_origin_opener_policy = cross_origin_opener_policy.strip() + self._cross_origin_resource_policy = cross_origin_resource_policy.strip() + self._csp_enabled = content_security_policy_enabled + self._csp_policy = content_security_policy.strip() + self._csp_docs_policy = content_security_policy_docs.strip() + self._csp_report_only = content_security_policy_report_only + self._hsts_enabled = strict_transport_security_enabled + self._hsts_max_age = max(0, strict_transport_security_max_age) + self._hsts_include_subdomains = strict_transport_security_include_subdomains + self._hsts_preload = strict_transport_security_preload + + async def dispatch(self, request: Request, call_next) -> Response: + response = await call_next(request) + if not self._enabled: + return response + + if self._x_content_type_options: + response.headers.setdefault("X-Content-Type-Options", self._x_content_type_options) + if self._x_frame_options: + response.headers.setdefault("X-Frame-Options", self._x_frame_options) + if self._referrer_policy: + response.headers.setdefault("Referrer-Policy", self._referrer_policy) + if self._permissions_policy: + response.headers.setdefault("Permissions-Policy", self._permissions_policy) + if self._cross_origin_opener_policy: + response.headers.setdefault( + "Cross-Origin-Opener-Policy", + self._cross_origin_opener_policy, + ) + if self._cross_origin_resource_policy: + response.headers.setdefault( + "Cross-Origin-Resource-Policy", + self._cross_origin_resource_policy, + ) + + csp_policy = self._csp_policy_for_path(request.url.path) + if self._csp_enabled and csp_policy: + csp_header = ( + "Content-Security-Policy-Report-Only" + if self._csp_report_only + else "Content-Security-Policy" + ) + response.headers.setdefault(csp_header, csp_policy) + + hsts = self._strict_transport_security_value() + if hsts: + response.headers.setdefault("Strict-Transport-Security", hsts) + + return response + + def _csp_policy_for_path(self, path: str) -> str: + if path.startswith("/docs") or path.startswith("/redoc"): + return self._csp_docs_policy or self._csp_policy + return self._csp_policy + + def _strict_transport_security_value(self) -> str: + if not self._hsts_enabled: + return "" + + directives = [f"max-age={self._hsts_max_age}"] + if self._hsts_include_subdomains: + directives.append("includeSubDomains") + if self._hsts_preload: + directives.append("preload") + return "; ".join(directives) + + def parse_skip_paths(raw_value: str) -> tuple[str, ...]: parts = [part.strip() for part in raw_value.split(",")] return tuple(part for part in parts if part) diff --git a/app/main.py b/app/main.py index 44d5cc7..7fbef93 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,11 @@ from app.api.router import router as api_router from app.api.runtime_deps import get_ingestion_service, get_scholar_source from app.db.session import check_database from app.db.session import close_engine -from app.http.middleware import RequestLoggingMiddleware, parse_skip_paths +from app.http.middleware import ( + RequestLoggingMiddleware, + SecurityHeadersMiddleware, + parse_skip_paths, +) from app.logging_config import configure_logging, parse_redact_fields from app.security.csrf import CSRFMiddleware from app.services.scheduler import SchedulerService @@ -63,6 +67,26 @@ app.add_middleware( log_requests=settings.log_requests, skip_paths=parse_skip_paths(settings.log_request_skip_paths), ) +app.add_middleware( + SecurityHeadersMiddleware, + enabled=settings.security_headers_enabled, + x_content_type_options=settings.security_x_content_type_options, + x_frame_options=settings.security_x_frame_options, + referrer_policy=settings.security_referrer_policy, + permissions_policy=settings.security_permissions_policy, + cross_origin_opener_policy=settings.security_cross_origin_opener_policy, + cross_origin_resource_policy=settings.security_cross_origin_resource_policy, + content_security_policy_enabled=settings.security_csp_enabled, + content_security_policy=settings.security_csp_policy, + content_security_policy_docs=settings.security_csp_docs_policy, + content_security_policy_report_only=settings.security_csp_report_only, + strict_transport_security_enabled=settings.security_strict_transport_security_enabled, + strict_transport_security_max_age=settings.security_strict_transport_security_max_age, + strict_transport_security_include_subdomains=( + settings.security_strict_transport_security_include_subdomains + ), + strict_transport_security_preload=settings.security_strict_transport_security_preload, +) app.include_router(api_router) diff --git a/app/services/user_settings.py b/app/services/user_settings.py index 92aff32..d3314e7 100644 --- a/app/services/user_settings.py +++ b/app/services/user_settings.py @@ -10,13 +10,38 @@ class UserSettingsServiceError(ValueError): """Raised for expected settings-validation failures.""" +NAV_PAGE_DASHBOARD = "dashboard" +NAV_PAGE_SCHOLARS = "scholars" +NAV_PAGE_PUBLICATIONS = "publications" +NAV_PAGE_SETTINGS = "settings" +NAV_PAGE_STYLE_GUIDE = "style-guide" +NAV_PAGE_RUNS = "runs" +NAV_PAGE_USERS = "users" + +ALLOWED_NAV_PAGES = ( + NAV_PAGE_DASHBOARD, + NAV_PAGE_SCHOLARS, + NAV_PAGE_PUBLICATIONS, + NAV_PAGE_SETTINGS, + NAV_PAGE_STYLE_GUIDE, + NAV_PAGE_RUNS, + NAV_PAGE_USERS, +) +REQUIRED_NAV_PAGES = ( + NAV_PAGE_DASHBOARD, + NAV_PAGE_SCHOLARS, + NAV_PAGE_SETTINGS, +) +DEFAULT_NAV_VISIBLE_PAGES = list(ALLOWED_NAV_PAGES) + + def parse_run_interval_minutes(value: str) -> int: try: parsed = int(value) except ValueError as exc: - raise UserSettingsServiceError("Run interval must be a whole number.") from exc + raise UserSettingsServiceError("Check interval must be a whole number.") from exc if parsed < 15: - raise UserSettingsServiceError("Run interval must be at least 15 minutes.") + raise UserSettingsServiceError("Check interval must be at least 15 minutes.") return parsed @@ -25,11 +50,41 @@ def parse_request_delay_seconds(value: str) -> int: parsed = int(value) except ValueError as exc: raise UserSettingsServiceError("Request delay must be a whole number.") from exc - if parsed < 1: - raise UserSettingsServiceError("Request delay must be at least 1 second.") + if parsed < 2: + raise UserSettingsServiceError("Request delay must be at least 2 seconds.") return parsed +def parse_nav_visible_pages(value: object) -> list[str]: + if not isinstance(value, list): + raise UserSettingsServiceError("Navigation visibility must be a list of page ids.") + + deduped: list[str] = [] + seen: set[str] = set() + + for raw_page in value: + if not isinstance(raw_page, str): + raise UserSettingsServiceError("Navigation visibility entries must be strings.") + + page_id = raw_page.strip() + if page_id not in ALLOWED_NAV_PAGES: + raise UserSettingsServiceError(f"Unsupported navigation page id: {page_id}") + + if page_id in seen: + continue + + seen.add(page_id) + deduped.append(page_id) + + missing_required = [page for page in REQUIRED_NAV_PAGES if page not in seen] + if missing_required: + raise UserSettingsServiceError( + "Dashboard, Scholars, and Settings must remain visible." + ) + + return deduped + + async def get_or_create_settings( db_session: AsyncSession, *, @@ -56,11 +111,12 @@ async def update_settings( auto_run_enabled: bool, run_interval_minutes: int, request_delay_seconds: int, + nav_visible_pages: list[str], ) -> UserSetting: settings.auto_run_enabled = auto_run_enabled settings.run_interval_minutes = run_interval_minutes settings.request_delay_seconds = request_delay_seconds + settings.nav_visible_pages = nav_visible_pages await db_session.commit() await db_session.refresh(settings) return settings - diff --git a/app/settings.py b/app/settings.py index 67931c3..48b875b 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,6 +1,33 @@ from dataclasses import dataclass import os +DEFAULT_SECURITY_PERMISSIONS_POLICY = ( + "accelerometer=(), autoplay=(), camera=(), display-capture=(), " + "geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()" +) +DEFAULT_SECURITY_CSP_POLICY = ( + "default-src 'self'; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "img-src 'self' data: https:; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self' data:; " + "connect-src 'self'; " + "object-src 'none'" +) +DEFAULT_SECURITY_CSP_DOCS_POLICY = ( + "default-src 'self'; " + "img-src 'self' data: https:; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self' data:; " + "connect-src 'self' https:; " + "object-src 'none'; " + "frame-ancestors 'none'" +) + def _env_bool(name: str, default: bool) -> bool: value = os.getenv(name) @@ -39,6 +66,42 @@ class Settings: ) session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-insecure-session-key") session_cookie_secure: bool = _env_bool("SESSION_COOKIE_SECURE", False) + security_headers_enabled: bool = _env_bool("SECURITY_HEADERS_ENABLED", True) + security_x_content_type_options: str = _env_str("SECURITY_X_CONTENT_TYPE_OPTIONS", "nosniff") + security_x_frame_options: str = _env_str("SECURITY_X_FRAME_OPTIONS", "DENY") + security_referrer_policy: str = _env_str("SECURITY_REFERRER_POLICY", "strict-origin-when-cross-origin") + security_permissions_policy: str = _env_str( + "SECURITY_PERMISSIONS_POLICY", + DEFAULT_SECURITY_PERMISSIONS_POLICY, + ) + security_cross_origin_opener_policy: str = _env_str( + "SECURITY_CROSS_ORIGIN_OPENER_POLICY", + "same-origin", + ) + security_cross_origin_resource_policy: str = _env_str( + "SECURITY_CROSS_ORIGIN_RESOURCE_POLICY", + "same-origin", + ) + security_csp_enabled: bool = _env_bool("SECURITY_CSP_ENABLED", True) + security_csp_policy: str = _env_str("SECURITY_CSP_POLICY", DEFAULT_SECURITY_CSP_POLICY) + security_csp_docs_policy: str = _env_str("SECURITY_CSP_DOCS_POLICY", DEFAULT_SECURITY_CSP_DOCS_POLICY) + security_csp_report_only: bool = _env_bool("SECURITY_CSP_REPORT_ONLY", False) + security_strict_transport_security_enabled: bool = _env_bool( + "SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED", + False, + ) + security_strict_transport_security_max_age: int = _env_int( + "SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE", + 31_536_000, + ) + security_strict_transport_security_include_subdomains: bool = _env_bool( + "SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS", + True, + ) + security_strict_transport_security_preload: bool = _env_bool( + "SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD", + False, + ) login_rate_limit_attempts: int = _env_int("LOGIN_RATE_LIMIT_ATTEMPTS", 5) login_rate_limit_window_seconds: int = _env_int( "LOGIN_RATE_LIMIT_WINDOW_SECONDS", @@ -116,11 +179,11 @@ class Settings: ) scholar_name_search_min_interval_seconds: float = _env_float( "SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS", - 3.0, + 8.0, ) scholar_name_search_interval_jitter_seconds: float = _env_float( "SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS", - 1.0, + 2.0, ) scholar_name_search_cooldown_block_threshold: int = _env_int( "SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD", diff --git a/frontend/package.json b/frontend/package.json index 2b5f7d3..f9c9a02 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "build": "vue-tsc --noEmit && vite build", "preview": "vite preview --host 0.0.0.0 --port 4173", "typecheck": "vue-tsc --noEmit", + "check:theme-tokens": "node ./scripts/check_theme_tokens.mjs", "test": "vitest", "test:run": "vitest run" }, diff --git a/frontend/scripts/check_theme_tokens.mjs b/frontend/scripts/check_theme_tokens.mjs new file mode 100644 index 0000000..7fb50a6 --- /dev/null +++ b/frontend/scripts/check_theme_tokens.mjs @@ -0,0 +1,84 @@ +import fs from "node:fs"; +import path from "node:path"; + +const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const sourceRoot = path.join(projectRoot, "src"); +const allowedExtensions = new Set([".vue", ".css"]); + +const checks = [ + { + name: "non-token palette utility", + regex: /\b(?:bg|text|border|ring|from|to|via)-(?:zinc|gray|slate|neutral|stone|white|black)(?:[-/][A-Za-z0-9_\-[\]./%]+)?\b/g, + }, + { + name: "dark mode utility variant", + regex: /\bdark:[^\s"'`<>}]*/g, + }, + { + name: "inline hex color", + regex: /#[0-9a-fA-F]{3,8}\b/g, + }, +]; + +function listFilesRecursively(dirPath) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const nextPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + files.push(...listFilesRecursively(nextPath)); + continue; + } + + if (allowedExtensions.has(path.extname(nextPath))) { + files.push(nextPath); + } + } + + return files; +} + +function relativePath(filePath) { + return path.relative(projectRoot, filePath).replaceAll(path.sep, "/"); +} + +function run() { + const violations = []; + const files = listFilesRecursively(sourceRoot); + + for (const filePath of files) { + const content = fs.readFileSync(filePath, "utf8"); + const lines = content.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + + for (const check of checks) { + check.regex.lastIndex = 0; + let match = check.regex.exec(line); + while (match) { + violations.push({ + filePath: relativePath(filePath), + line: index + 1, + check: check.name, + value: match[0], + }); + match = check.regex.exec(line); + } + } + } + } + + if (violations.length > 0) { + console.error("Theme token policy violations found:\n"); + for (const violation of violations) { + console.error(`${violation.filePath}:${violation.line} [${violation.check}] ${violation.value}`); + } + process.exit(1); + } + + console.log(`Theme token policy passed for ${files.length} files.`); +} + +run(); diff --git a/frontend/src/app/AppShell.vue b/frontend/src/app/AppShell.vue index 5bee674..4d5d104 100644 --- a/frontend/src/app/AppShell.vue +++ b/frontend/src/app/AppShell.vue @@ -12,23 +12,27 @@ const showChrome = computed(() => auth.isAuthenticated);