diff --git a/.github/dependabot.yml b/.github/dependabot.yml index df71f9a4..48855a13 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -33,13 +33,6 @@ updates: commit-message: prefix: "chore(deps)" include: "scope" - ignore: - # Ignore major version updates for Vue and Vuetify to avoid breaking changes - - dependency-name: "vue" - update-types: ["version-update:semver-major"] - - dependency-name: "vuetify" - update-types: ["version-update:semver-major"] - # Docker base images - package-ecosystem: "docker" directory: "/src/backend" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4862ab0..5a33e74f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,7 @@ jobs: run: >- npm test -- --run src/services/__tests__/passwordPolicy.spec.ts + src/services/__tests__/csrf.spec.ts src/stores/__tests__/auth.spec.ts src/views/__tests__/Login.spec.ts diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 00000000..bcf84349 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,95 @@ +name: Dependabot Auto-Merge + +on: + pull_request_target: + branches: [dev] + types: [opened, reopened, synchronize, ready_for_review, edited] + +permissions: {} + +concurrency: + group: dependabot-auto-merge-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + auto-merge: + name: Auto-merge minor and patch updates into dev + if: >- + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.base.ref == 'dev' && + !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 65 + permissions: + actions: read + contents: write + pull-requests: write + steps: + # This privileged workflow never checks out or executes PR code. + # Default metadata verification requires Dependabot-authored commits. + - name: Fetch verified Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + # update-type is the highest version change in the PR, including groups. + # Major and unclassified updates remain available for manual review. + - name: Wait for successful PR pipelines on the exact head commit + if: >- + steps.metadata.outputs.update-type == 'version-update:semver-minor' || + steps.metadata.outputs.update-type == 'version-update:semver-patch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REPOSITORY: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + deadline=$((SECONDS + 3600)) + + while (( SECONDS < deadline )); do + # Select the latest run of each required PR workflow. Push runs, + # earlier commits, and this automation's own run cannot satisfy it. + pipelines="$(gh api --paginate --slurp \ + "repos/$REPOSITORY/actions/runs?event=pull_request&head_sha=$PR_HEAD_SHA&per_page=100" \ + | jq --arg sha "$PR_HEAD_SHA" ' + [ .[].workflow_runs[] + | select(.event == "pull_request" and .head_sha == $sha) + | select(.path == ".github/workflows/ci.yml" or + .path == ".github/workflows/e2e.yml") ] + | group_by(.path) + | map(max_by(.id)) + ')" + + if jq -e 'any(.[]; .status == "completed" and .conclusion != "success")' \ + <<< "$pipelines" >/dev/null; then + echo "A PR pipeline failed, was cancelled, or was skipped; refusing auto-merge." + exit 1 + fi + + if jq -e 'length == 2 and all(.[]; .status == "completed" and .conclusion == "success")' \ + <<< "$pipelines" >/dev/null; then + echo "CI and E2E passed for $PR_HEAD_SHA." + exit 0 + fi + + echo "Waiting for both PR pipelines to succeed for $PR_HEAD_SHA." + sleep 20 + done + + echo "Timed out waiting for PR pipelines; refusing auto-merge." + exit 1 + + - name: Approve and enable auto-merge for minor and patch updates + if: >- + steps.metadata.outputs.update-type == 'version-update:semver-minor' || + steps.metadata.outputs.update-type == 'version-update:semver-patch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --squash --match-head-commit "$PR_HEAD_SHA" "$PR_URL" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ea9d8cef..f3c60e48 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -44,6 +44,13 @@ jobs: E2E_FRONTEND_SERVER: preview run: ./scripts/e2e/run-local.sh + - name: Run production CSRF auth smoke tests + env: + E2E_SKIP_CLEANUP: '1' + E2E_FRONTEND_SERVER: preview + E2E_ENV: production + run: ./scripts/e2e/run-local.sh --grep 'bootstraps the initial admin account|authenticates, updates the profile, and rotates the password' --output=test-results/production --reporter=line + - name: Upload Playwright artifacts if: always() uses: actions/upload-artifact@v7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 333ceb82..cf58a8df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,21 @@ release notes whenever they change operator-visible behavior. promotions or urgent hotfixes. - Keep each pull request focused on one logical change. +Dependabot targets `dev` for dependency updates. Verified minor and patch +updates are automatically approved and set to auto-merge using squash merges +only after both CI Pipeline and E2E Tests succeed for the PR's exact head +commit. Missing, pending, failed, cancelled, or skipped pipelines cannot +authorize a merge. Branch protection rules still apply. Major updates +(including Vue and Vuetify) and updates without a recognized version change +remain open for manual review. + +For this automation, enable **Allow auto-merge** and squash merging in the +repository settings, and **Allow GitHub Actions to create and approve pull +requests** under Actions → General → Workflow permissions. The explicit +pipeline gate applies even without required-check rules on `dev`; configuring +those rules also protects manual merges. The workflow does not bypass branch +protection or organization policies. + Create a branch from the latest `dev` branch: ```bash diff --git a/README.md b/README.md index 37d13036..6e6a6b93 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,12 @@ Run it locally with: npm --prefix src/frontend/app run test:e2e:local ``` +The fresh-stack runner explicitly uses `ENV=development`, independent of your local `.env`. To verify bootstrap, profile updates, and password rotation with production CSRF protection enabled: + +```bash +E2E_ENV=production bash scripts/e2e/run-local.sh --grep 'bootstraps the initial admin account|authenticates, updates the profile, and rotates the password' +``` + To watch the browser while the suite runs, use: ```bash diff --git a/deploy/release/docker-compose.release.yml.template b/deploy/release/docker-compose.release.yml.template index a4e9aaf6..002ff7f1 100644 --- a/deploy/release/docker-compose.release.yml.template +++ b/deploy/release/docker-compose.release.yml.template @@ -24,7 +24,9 @@ services: backend: image: ghcr.io/bwinfosec/vulnerabilityhub-backend:__VERSION__@__BACKEND_DIGEST__ ports: - - "${BACKEND_PUBLISHED_PORT:-8000}:8000" + # The browser reaches the API through the frontend's /backend proxy. + # Keep the diagnostic host port local so internet probes cannot bypass it. + - "127.0.0.1:${BACKEND_PUBLISHED_PORT:-8000}:8000" volumes: - report_storage:/app/reports - upload_storage:/app/upload @@ -151,4 +153,3 @@ networks: driver: bridge backend-network: driver: bridge - diff --git a/deploy/release/production.env.example b/deploy/release/production.env.example index 8a7c13ac..3008affe 100644 --- a/deploy/release/production.env.example +++ b/deploy/release/production.env.example @@ -2,6 +2,7 @@ # Public endpoints FRONTEND_PUBLISHED_PORT=80 +# The release Compose file binds this diagnostic port to 127.0.0.1 only. BACKEND_PUBLISHED_PORT=8000 FRONTEND_URL=https://vulnerabilityhub.example.com CORS_ORIGINS=https://vulnerabilityhub.example.com diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 2616fd84..4e00931c 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -2,6 +2,9 @@ services: backend: command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] environment: + # Keep the dev smoke suite independent of a local release .env. + # Set E2E_ENV=production to exercise production CSRF protection. + - ENV=${E2E_ENV:-development} - CORS_ORIGINS=http://127.0.0.1:${E2E_FRONTEND_PORT:-4173},http://localhost:${E2E_FRONTEND_PORT:-4173} - FRONTEND_URL=http://127.0.0.1:${E2E_FRONTEND_PORT:-4173} - SMTP_SERVER=mailhog diff --git a/scripts/e2e/run-local.sh b/scripts/e2e/run-local.sh index 4b1072e1..9d381b5d 100755 --- a/scripts/e2e/run-local.sh +++ b/scripts/e2e/run-local.sh @@ -63,7 +63,7 @@ npm --prefix src/frontend/app exec playwright install chromium echo "[e2e] Resetting e2e stack" compose --profile dev down -v --remove-orphans -echo "[e2e] Starting backend services in dev mode" +echo "[e2e] Starting backend services in ${E2E_ENV:-development} mode" compose --profile dev up -d --build db clamav ldap backend mailhog "$ROOT_DIR/scripts/e2e/wait-for-url.sh" "http://127.0.0.1:${E2E_BACKEND_PORT}/health" 180 diff --git a/scripts/release/smoke-release.sh b/scripts/release/smoke-release.sh index a6cf3013..d65e6b13 100755 --- a/scripts/release/smoke-release.sh +++ b/scripts/release/smoke-release.sh @@ -106,4 +106,34 @@ fi curl --fail --silent --show-error http://127.0.0.1:18000/health >/dev/null curl --fail --silent --show-error http://127.0.0.1:18080/ >/dev/null +# Exercise the production-only CSRF middleware through the same /backend proxy +# used by the browser. A valid token must reach routing (404 for this synthetic +# path), while an absent token must be a handled 403 rather than an ASGI 500. +csrf_headers="$smoke_dir/csrf-headers.txt" +csrf_cookies="$smoke_dir/csrf-cookies.txt" +curl --fail --silent --show-error \ + --dump-header "$csrf_headers" \ + --cookie-jar "$csrf_cookies" \ + http://127.0.0.1:18080/backend/users/has-admin >/dev/null +csrf_token="$(awk 'BEGIN { IGNORECASE=1 } /^X-CSRF-Token:/ { gsub("\\r", "", $2); print $2 }' "$csrf_headers")" + +if [ -z "$csrf_token" ]; then + echo "Release backend did not issue a CSRF token." >&2 + exit 1 +fi + +protected_status="$(curl --silent --output /dev/null --write-out '%{http_code}' \ + --request POST \ + --cookie "$csrf_cookies" \ + --header "X-CSRF-Token: $csrf_token" \ + http://127.0.0.1:18080/backend/__csrf-smoke)" +missing_status="$(curl --silent --output /dev/null --write-out '%{http_code}' \ + --request POST \ + http://127.0.0.1:18080/backend/__csrf-smoke)" + +if [ "$protected_status" != "404" ] || [ "$missing_status" != "403" ]; then + echo "Release CSRF smoke test failed (valid=$protected_status, missing=$missing_status)." >&2 + exit 1 +fi + echo "Release image smoke test passed." diff --git a/src/backend/main.py b/src/backend/main.py index 20089106..cc31e6f9 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -84,7 +84,10 @@ app.add_middleware( CSRFProtectionMiddleware, secret_key=settings.SECRET_KEY, - secure_cookie=True, # Use secure cookies in production + # Release deployments may run directly over HTTP or terminate TLS at + # the public frontend. Match the cookie flag to the configured public + # URL so browsers do not silently discard it on HTTP installations. + secure_cookie=settings.FRONTEND_URL.lower().startswith("https://"), ) # Rate Limiting Middleware @@ -115,6 +118,7 @@ async def target_managed_network_exception_handler( allow_headers=["Content-Type", "Authorization", "X-CSRF-Token"], # Explicit headers expose_headers=[ "Content-Disposition", + "X-CSRF-Token", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset", diff --git a/src/backend/middleware/csrf.py b/src/backend/middleware/csrf.py index 5eb4ffa2..a4a59a5e 100644 --- a/src/backend/middleware/csrf.py +++ b/src/backend/middleware/csrf.py @@ -6,17 +6,17 @@ HTTP requests (POST, PUT, PATCH, DELETE). The middleware generates a cryptographically secure token and stores it in an -HttpOnly cookie. Clients must include this token in the X-CSRF-Token header -for protected requests. +HttpOnly cookie. Safe responses expose the same token in the X-CSRF-Token +header so clients can echo it on protected requests. """ import logging import secrets from typing import Callable, Set -from fastapi import Request, HTTPException, status +from fastapi import Request, status from starlette.middleware.base import BaseHTTPMiddleware -from starlette.responses import Response +from starlette.responses import JSONResponse, Response logger = logging.getLogger(__name__) @@ -104,8 +104,6 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: Returns: Response, potentially with a new CSRF token cookie - Raises: - HTTPException: If CSRF validation fails (403 Forbidden) """ # Skip CSRF protection for exempt paths if self._is_path_exempt(request.url.path): @@ -128,7 +126,11 @@ def _is_path_exempt(self, path: str) -> bool: Returns: True if the path is exempt, False otherwise """ - return any(path.startswith(exempt_path) for exempt_path in self.exempt_paths) + return any( + path == exempt_path + or path.startswith(f"{exempt_path.rstrip('/')}/") + for exempt_path in self.exempt_paths + ) async def _handle_safe_method( self, @@ -181,8 +183,6 @@ async def _handle_protected_method( Returns: Response if validation succeeds - Raises: - HTTPException: If CSRF token is missing or invalid (403 Forbidden) """ csrf_cookie = request.cookies.get(self.CSRF_COOKIE_NAME) csrf_header = request.headers.get(self.CSRF_HEADER_NAME) @@ -195,9 +195,14 @@ async def _handle_protected_method( request.url.path, request.client.host if request.client else "unknown" ) - raise HTTPException( + return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Include X-CSRF-Token header with your request." + content={ + "detail": ( + "CSRF token missing. Include X-CSRF-Token header " + "with your request." + ) + }, ) # Validate token using constant-time comparison @@ -208,9 +213,13 @@ async def _handle_protected_method( request.url.path, request.client.host if request.client else "unknown" ) - raise HTTPException( + return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token invalid. The token has expired or is incorrect." + content={ + "detail": ( + "CSRF token invalid. The token has expired or is incorrect." + ) + }, ) logger.debug("CSRF token validated successfully for %s", request.url.path) diff --git a/src/backend/requirements-test.txt b/src/backend/requirements-test.txt index f5ae6417..e0df6621 100644 --- a/src/backend/requirements-test.txt +++ b/src/backend/requirements-test.txt @@ -2,4 +2,4 @@ pytest>=9.1.1 pytest-cov>=7.1.0 pytest-asyncio>=1.4.0 httpx>=0.28.1 -faker>=40.37.0 +faker>=40.39.0 diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 9880f440..88fe3466 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -2,14 +2,14 @@ ## Last updated: 2025-11-26 # Core Framework fastapi==0.141.1 -uvicorn[standard]==0.52.4 +uvicorn[standard]==0.54.0 pydantic==2.13.5 pydantic-settings==2.15.0 pydantic[email]==2.13.5 # Database -SQLAlchemy==2.0.52 -PyMySQL==1.2.0 +SQLAlchemy==2.1.1 +PyMySQL==1.2.3 # Security & Authentication bcrypt==5.0.0 python-jose[cryptography]==3.5.0 @@ -31,14 +31,14 @@ python-dotenv==1.2.3 ldap3==2.9.1 # Greenbone Management Protocol client -python-gvm>=27.7.0,<28.0 +python-gvm>=27.10.0,<28.0 # Antivirus clamd==1.0.2 # Data Processing -pandas==3.0.5 -numpy==2.5.2 +pandas==3.0.6 +numpy==2.5.3 # Rate Limiting slowapi==0.1.10 diff --git a/src/backend/tests/middleware/test_csrf.py b/src/backend/tests/middleware/test_csrf.py index b886b7ec..8c82bba4 100644 --- a/src/backend/tests/middleware/test_csrf.py +++ b/src/backend/tests/middleware/test_csrf.py @@ -35,3 +35,52 @@ def protected(): assert protected_response.status_code == 200 assert protected_response.json() == {"ok": True} + + +def test_missing_token_returns_403_instead_of_unhandled_500(): + app = FastAPI() + + @app.post("/protected") + def protected(): + return {"ok": True} + + app.add_middleware( + CSRFProtectionMiddleware, + secret_key="test-secret", + exempt_paths=set(), + secure_cookie=False, + ) + + with TestClient(app) as client: + response = client.post("/protected") + + assert response.status_code == 403 + assert response.json() == { + "detail": "CSRF token missing. Include X-CSRF-Token header with your request." + } + + +def test_exempt_paths_match_route_boundaries(): + app = FastAPI() + + @app.post("/metrics") + @app.post("/metrics/child") + @app.post("/metrics-other") + def endpoint(): + return {"ok": True} + + app.add_middleware( + CSRFProtectionMiddleware, + secret_key="test-secret", + exempt_paths={"/metrics"}, + secure_cookie=False, + ) + + with TestClient(app) as client: + exact_response = client.post("/metrics") + child_response = client.post("/metrics/child") + sibling_response = client.post("/metrics-other") + + assert exact_response.status_code == 200 + assert child_response.status_code == 200 + assert sibling_response.status_code == 403 diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile index d79740ba..edc137fd 100644 --- a/src/frontend/Dockerfile +++ b/src/frontend/Dockerfile @@ -1,6 +1,6 @@ # Multi-stage build for frontend # Stage 1: Build stage -FROM node:26.8-alpine AS builder +FROM node:26.10-alpine AS builder ARG BACKEND_URL ENV VITE_BACKEND_URL=$BACKEND_URL diff --git a/src/frontend/app/package-lock.json b/src/frontend/app/package-lock.json index 2a06af90..8a694de6 100644 --- a/src/frontend/app/package-lock.json +++ b/src/frontend/app/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@mdi/font": "^7.4.47", - "@primeuix/themes": "^3.0.0", + "@primeuix/themes": "^3.0.1", "axios": "^1.20.0", "bootstrap": "^5.3.8", "bootstrap-vue": "^2.23.1", @@ -19,53 +19,53 @@ "primevue": "^5.0.1", "vue": "^3.5.43", "vue-router": "^5.3.1", - "vuetify": "^3.13.3" + "vuetify": "^3.13.5" }, "devDependencies": { - "@playwright/test": "^1.62.1", - "@vitejs/plugin-vue": "^6.0.8", - "@vue/test-utils": "^2.5.0", - "jsdom": "^30.0.1", - "sass": "^1.104.0", + "@playwright/test": "^1.63.0", + "@vitejs/plugin-vue": "^6.0.9", + "@vue/test-utils": "^2.5.1", + "jsdom": "^30.1.1", + "sass": "^1.105.0", "vite-plugin-vue-devtools": "^8.2.1", "vite-plugin-vuetify": "^2.1.3", - "vitest": "^4.1.11" + "vitest": "^5.0.2" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@asamuzakjp/css-color": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", - "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz", + "integrity": "sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.2.1", - "@csstools/css-color-parser": "^4.1.9", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.5.2" + "@csstools/css-calc": "^3.4.1", + "@csstools/css-color-parser": "^4.2.4", + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" }, "engines": { - "node": "^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", - "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.2.tgz", + "integrity": "sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==", "dev": true, "license": "MIT", "dependencies": { - "bidi-js": "^1.0.3", + "bidi-js": "^1.1.0", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2" + "lru-cache": "^11.5.3" }, "engines": { - "node": "^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@babel/code-frame": { @@ -577,9 +577,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", - "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", "dev": true, "funding": [ { @@ -597,9 +597,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", - "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", + "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", "dev": true, "funding": [ { @@ -616,14 +616,14 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", - "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", + "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", "dev": true, "funding": [ { @@ -637,21 +637,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.3.0" + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.1" }, "engines": { "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", + "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", "dev": true, "funding": [ { @@ -668,13 +668,13 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", - "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", "dev": true, "funding": [ { @@ -697,9 +697,9 @@ } }, "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", "dev": true, "funding": [ { @@ -722,6 +722,7 @@ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -733,6 +734,7 @@ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -743,6 +745,7 @@ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -789,7 +792,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -810,6 +815,7 @@ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, @@ -874,6 +880,7 @@ "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "devOptional": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" } @@ -1175,13 +1182,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.1" + "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" @@ -1271,9 +1278,9 @@ } }, "node_modules/@primeuix/themes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@primeuix/themes/-/themes-3.0.0.tgz", - "integrity": "sha512-lNMOhorzu4vHRKbTDIU2/raS7/Z6Oyq2WXGfFDVhOwAIVDF4e7hCX75jlgfC7bo4D0Bl5f+oW5HPGZpG52r6lw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@primeuix/themes/-/themes-3.0.1.tgz", + "integrity": "sha512-UmHzvA16DKb3dcE+2J8NbgK6eI5zFJQbCaNBW1YyY+ADZEVId4H275TPTcwlZ/8ywt0AFpunEPiyLQE1oub9pA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@primeuix/styled": "^1.0.0" @@ -1324,6 +1331,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1340,6 +1348,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1356,6 +1365,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1372,6 +1382,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1388,6 +1399,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1407,6 +1419,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1426,6 +1439,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1445,6 +1459,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1464,6 +1479,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1483,6 +1499,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1502,6 +1519,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1518,6 +1536,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1531,6 +1550,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", @@ -1552,6 +1572,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1568,6 +1589,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1579,19 +1601,13 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1622,9 +1638,9 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", - "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", + "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==", "dev": true, "license": "MIT", "dependencies": { @@ -1638,34 +1654,17 @@ "vue": "^3.2.25" } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.2", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1693,70 +1692,22 @@ "@types/estree": "^1.0.0" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, "funding": { "url": "https://opencollective.com/vitest" } @@ -1987,9 +1938,9 @@ "license": "MIT" }, "node_modules/@vue/test-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.0.tgz", - "integrity": "sha512-6Clu5EKR/r6cDPYrKsu+8wenciWJJ3rhS9OEGsfDlZeZIhlJeEPGIZQHxE4lHRJCzPSq3EWMsFxQUqCvrbHQuQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.1.tgz", + "integrity": "sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==", "dev": true, "license": "MIT", "dependencies": { @@ -2163,9 +2114,9 @@ } }, "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", "dev": true, "license": "MIT", "dependencies": { @@ -2701,9 +2652,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -2749,9 +2700,9 @@ "license": "MIT" }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2825,6 +2776,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -2968,16 +2920,16 @@ "license": "MIT" }, "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", "dev": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.6.0" + "@exodus/bytes": "^1.15.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, "node_modules/https-proxy-agent": { @@ -3145,32 +3097,31 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", - "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^6.0.5", - "@asamuzakjp/dom-selector": "^8.3.0", + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", + "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", - "undici": "^8.9.0", - "w3c-xmlserializer": "^5.0.0", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.0", + "whatwg-url": "^17.1.1", "xml-name-validator": "^5.0.0" }, "engines": { @@ -3209,9 +3160,9 @@ } }, "node_modules/jsdom/node_modules/whatwg-url": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", - "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3265,6 +3216,7 @@ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "devOptional": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -3301,6 +3253,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3321,6 +3274,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3341,6 +3295,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3361,6 +3316,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3381,6 +3337,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3404,6 +3361,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3427,6 +3385,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3450,6 +3409,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3473,6 +3433,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3493,6 +3454,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3513,6 +3475,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3543,9 +3506,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3762,15 +3725,18 @@ "license": "MIT" }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/ohash": { "version": "2.0.11", @@ -3850,9 +3816,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -3898,28 +3864,25 @@ } }, "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.1" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3929,21 +3892,6 @@ "node": ">=20" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", @@ -4100,6 +4048,7 @@ "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" @@ -4142,9 +4091,9 @@ } }, "node_modules/sass": { - "version": "1.104.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.104.0.tgz", - "integrity": "sha512-btHMApW2bgolvClhRW8AlQJzgI9lUB3pPSofBQQT+E46GWvf9o0TVQ13SYv5riWZVFyPN+JNz3TKW9XhBlc10w==", + "version": "1.105.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.105.0.tgz", + "integrity": "sha512-4EjSuRTyG8M4yRXXY1PT9OUV5zz23xBxJkbIAprohKHqf6HOU4MCpE3U3vAC+iO1gxobBjESNbcj8rYAQZWnyA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4192,11 +4141,6 @@ "node": ">=10" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "dev": true, - "license": "ISC" - }, "node_modules/sirv": { "version": "3.0.2", "dev": true, @@ -4227,15 +4171,10 @@ "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -4251,20 +4190,20 @@ "node": ">=8" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "dev": true, - "license": "MIT" - }, "node_modules/tinybench": { - "version": "2.9.0", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -4287,16 +4226,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tldts": { "version": "7.4.10", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", @@ -4349,7 +4278,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD", - "optional": true + "optional": true, + "peer": true }, "node_modules/ufo": { "version": "1.6.4", @@ -4485,6 +4415,7 @@ "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -4699,38 +4630,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.2", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4738,16 +4662,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -4788,6 +4712,16 @@ } } }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/vue": { "version": "3.5.43", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", @@ -4871,9 +4805,9 @@ } }, "node_modules/vuetify": { - "version": "3.13.3", - "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.13.3.tgz", - "integrity": "sha512-IFNGRqvL+UQOsAToiKwPjJ0FmMv06Kr6nFTsWkvCNm0mLTeld9EQSAUGOXf50R0OxPtocY5KwajNVdzp3tg2lg==", + "version": "3.13.5", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.13.5.tgz", + "integrity": "sha512-1PKESHKgyIj6BfjVbpF1bLWvKIBOvyDqIcP6C0R173fNZDr4jyXH7ssigAZvfqV5nRmVS/N9sGtY/IekGQ9TSQ==", "license": "MIT", "funding": { "type": "github", @@ -4898,14 +4832,16 @@ } }, "node_modules/w3c-xmlserializer": { - "version": "5.0.0", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/webidl-conversions": { @@ -4941,18 +4877,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/wsl-utils": { @@ -4974,6 +4908,8 @@ }, "node_modules/xml-name-validator": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { diff --git a/src/frontend/app/package.json b/src/frontend/app/package.json index 19a61dbe..194487a7 100644 --- a/src/frontend/app/package.json +++ b/src/frontend/app/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@mdi/font": "^7.4.47", - "@primeuix/themes": "^3.0.0", + "@primeuix/themes": "^3.0.1", "axios": "^1.20.0", "bootstrap": "^5.3.8", "bootstrap-vue": "^2.23.1", @@ -29,7 +29,7 @@ "primevue": "^5.0.1", "vue": "^3.5.43", "vue-router": "^5.3.1", - "vuetify": "^3.13.3" + "vuetify": "^3.13.5" }, "@eslint/css": "^0.13.0", "@eslint/js": "^9.38.0", @@ -48,13 +48,13 @@ "vitest": "^1.6.0", "vue-tsc": "^3.0.6", "devDependencies": { - "@playwright/test": "^1.62.1", - "@vitejs/plugin-vue": "^6.0.8", - "@vue/test-utils": "^2.5.0", - "jsdom": "^30.0.1", - "sass": "^1.104.0", + "@playwright/test": "^1.63.0", + "@vitejs/plugin-vue": "^6.0.9", + "@vue/test-utils": "^2.5.1", + "jsdom": "^30.1.1", + "sass": "^1.105.0", "vite-plugin-vue-devtools": "^8.2.1", "vite-plugin-vuetify": "^2.1.3", - "vitest": "^4.1.11" + "vitest": "^5.0.2" } } diff --git a/src/frontend/app/src/main.ts b/src/frontend/app/src/main.ts index 2516621d..cb827059 100755 --- a/src/frontend/app/src/main.ts +++ b/src/frontend/app/src/main.ts @@ -3,6 +3,11 @@ import { createPinia } from 'pinia'; import axios, { AxiosHeaders } from 'axios'; // Import axios import type { AxiosError, AxiosRequestHeaders, InternalAxiosRequestConfig } from 'axios'; import { useAuthStore } from './stores/auth'; // Import useAuthStore +import { + ensureCsrfToken, + isCsrfProtectedMethod, + rememberCsrfToken, +} from './services/csrf'; // Vuetify import './styles/vuetify.scss' @@ -43,6 +48,9 @@ import { config } from './config'; // Axios Configuration axios.defaults.baseURL = config.backendUrl; +// Safe responses issue the CSRF cookie too. Include credentials on reads so +// any token remembered below has its matching cookie retained by the browser. +axios.defaults.withCredentials = true; const isPublicAuthEndpoint = (url?: string): boolean => { if (!url) { @@ -90,10 +98,27 @@ const setAuthorizationHeader = ( requestConfig.headers = headers as AxiosRequestHeaders; }; +const setCsrfHeader = ( + requestConfig: InternalAxiosRequestConfig, + token: string, +) => { + const headers = AxiosHeaders.from(requestConfig.headers); + headers.set('X-CSRF-Token', token); + requestConfig.headers = headers as AxiosRequestHeaders; +}; + // Axios Interceptor axios.interceptors.request.use(async (requestConfig) => { const authStore = useAuthStore(); + if (isCsrfProtectedMethod(requestConfig.method)) { + const csrfToken = await ensureCsrfToken(); + if (csrfToken) { + setCsrfHeader(requestConfig, csrfToken); + } + requestConfig.withCredentials = true; + } + if (isPublicAuthEndpoint(requestConfig.url)) { return requestConfig; } @@ -107,7 +132,10 @@ axios.interceptors.request.use(async (requestConfig) => { }); axios.interceptors.response.use( - (response) => response, + (response) => { + rememberCsrfToken(response.headers['x-csrf-token']); + return response; + }, async (error: AxiosError) => { const authStore = useAuthStore(); const originalRequest = error.config as RetriableRequestConfig | undefined; diff --git a/src/frontend/app/src/services/__tests__/csrf.spec.ts b/src/frontend/app/src/services/__tests__/csrf.spec.ts new file mode 100644 index 00000000..9dead9f9 --- /dev/null +++ b/src/frontend/app/src/services/__tests__/csrf.spec.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('CSRF request helpers', () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('initializes a token and adds it to protected fetch requests', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(null, { + status: 200, + headers: { 'X-CSRF-Token': 'test-token' }, + })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + + const { csrfFetch } = await import('../csrf'); + await csrfFetch('/backend/protected', { method: 'POST' }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'http://localhost:8000/users/has-admin', + expect.objectContaining({ credentials: 'include' }), + ); + + const protectedInit = fetchMock.mock.calls[1][1] as RequestInit; + expect(protectedInit.credentials).toBe('include'); + expect(new Headers(protectedInit.headers).get('X-CSRF-Token')).toBe('test-token'); + }); + + it('does not initialize or attach a token to safe requests', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const { csrfFetch } = await import('../csrf'); + await csrfFetch('/backend/safe'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestInit = fetchMock.mock.calls[0][1] as RequestInit; + expect(new Headers(requestInit.headers).has('X-CSRF-Token')).toBe(false); + }); + + it('sends development mutations when the backend does not issue a token', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response('{}', { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + + const { csrfFetch } = await import('../csrf'); + const response = await csrfFetch('/backend/users/me/profile', { + method: 'PUT', + headers: { Authorization: 'Bearer test-token' }, + }); + + expect(response.status).toBe(204); + expect(fetchMock).toHaveBeenCalledTimes(2); + const headers = new Headers(fetchMock.mock.calls[1][1].headers); + expect(headers.has('X-CSRF-Token')).toBe(false); + expect(headers.get('Authorization')).toBe('Bearer test-token'); + }); + + it('does not send a mutation when token initialization fails', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 500 })); + vi.stubGlobal('fetch', fetchMock); + + const { csrfFetch } = await import('../csrf'); + await expect(csrfFetch('/backend/protected', { method: 'POST' })) + .rejects.toThrow('Could not initialize CSRF protection (500)'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/frontend/app/src/services/csrf.ts b/src/frontend/app/src/services/csrf.ts new file mode 100644 index 00000000..14c3b08b --- /dev/null +++ b/src/frontend/app/src/services/csrf.ts @@ -0,0 +1,63 @@ +import { getApiUrl } from '../config'; + +const CSRF_HEADER_NAME = 'X-CSRF-Token'; +const PROTECTED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +let csrfToken: string | null = null; +let csrfTokenRequest: Promise | null = null; + +export const isCsrfProtectedMethod = (method?: string): boolean => + PROTECTED_METHODS.has((method || 'GET').toUpperCase()); + +export const rememberCsrfToken = (token?: string | null): void => { + if (token) { + csrfToken = token; + } +}; + +export const ensureCsrfToken = async (): Promise => { + if (csrfToken) { + return csrfToken; + } + + if (!csrfTokenRequest) { + csrfTokenRequest = window.fetch(getApiUrl('/users/has-admin'), { + credentials: 'include', + cache: 'no-store', + }).then((response) => { + if (!response.ok) { + throw new Error(`Could not initialize CSRF protection (${response.status})`); + } + + const token = response.headers.get(CSRF_HEADER_NAME); + // Development backends do not enable CSRF middleware and therefore + // return no token. Production enforcement remains on the server. + csrfToken = token; + return token; + }).finally(() => { + csrfTokenRequest = null; + }); + } + + return csrfTokenRequest; +}; + +export const csrfFetch = async ( + input: RequestInfo | URL, + init: RequestInit = {}, +): Promise => { + const headers = new Headers(init.headers); + + if (isCsrfProtectedMethod(init.method)) { + const token = await ensureCsrfToken(); + if (token) { + headers.set(CSRF_HEADER_NAME, token); + } + } + + return window.fetch(input, { + ...init, + credentials: init.credentials ?? 'include', + headers, + }); +}; diff --git a/src/frontend/app/src/views/manage/ParserConfig.vue b/src/frontend/app/src/views/manage/ParserConfig.vue index 885efd2d..b50f2d71 100644 --- a/src/frontend/app/src/views/manage/ParserConfig.vue +++ b/src/frontend/app/src/views/manage/ParserConfig.vue @@ -171,6 +171,7 @@ import { ref, onMounted, computed } from 'vue' import { config } from '../../config'; import { useAuthStore } from '../../stores/auth'; +import { csrfFetch } from '../../services/csrf'; interface RegexPath { type: 'regex'; @@ -331,7 +332,7 @@ onMounted(async () => { async function loadCurrentConfig() { try { - const res = await fetch(`${apiBase}/config`, { + const res = await csrfFetch(`${apiBase}/config`, { headers: { 'Authorization': `Bearer ${authStore.token}` }, }) if (!res.ok) throw new Error('Failed to load config') @@ -345,7 +346,7 @@ async function loadCurrentConfig() { async function saveConfig() { try { - const res = await fetch(`${apiBase}/config`, { + const res = await csrfFetch(`${apiBase}/config`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -435,7 +436,7 @@ function markDirty() { async function loadBackupList() { try { - const res = await fetch(`${apiBase}/backups`, { + const res = await csrfFetch(`${apiBase}/backups`, { headers: { 'Authorization': `Bearer ${authStore.token}` }, }) backups.value = await res.json() @@ -446,7 +447,7 @@ async function loadBackupList() { async function createBackup() { try { - const res = await fetch(`${apiBase}/backup`, { + const res = await csrfFetch(`${apiBase}/backup`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -467,7 +468,7 @@ async function createBackup() { async function restoreBackup(id: string) { if (!confirm('Are you sure you want to restore this backup?')) return try { - await fetch(`${apiBase}/restore-backup/${id}`, { + await csrfFetch(`${apiBase}/restore-backup/${id}`, { method: 'POST', headers: { 'Authorization': `Bearer ${authStore.token}` }, }) @@ -482,7 +483,7 @@ async function restoreBackup(id: string) { async function deleteBackup(id: string) { if (!confirm('Are you sure you want to delete this backup?')) return try { - await fetch(`${apiBase}/backup/${id}`, { + await csrfFetch(`${apiBase}/backup/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${authStore.token}` }, }) @@ -495,7 +496,7 @@ async function deleteBackup(id: string) { async function loadTemplateList() { try { - const res = await fetch(`${apiBase}/templates`, { + const res = await csrfFetch(`${apiBase}/templates`, { headers: { 'Authorization': `Bearer ${authStore.token}` }, }) templates.value = await res.json() @@ -505,7 +506,7 @@ async function loadTemplateList() { async function selectTemplate(template: Template) { try { - await fetch(`${apiBase}/load-template/${template.id}`, { + await csrfFetch(`${apiBase}/load-template/${template.id}`, { method: 'POST', headers: { 'Authorization': `Bearer ${authStore.token}` }, }) diff --git a/src/frontend/app/src/views/manage/Quarantine.vue b/src/frontend/app/src/views/manage/Quarantine.vue index 5e61b2aa..782aa811 100644 --- a/src/frontend/app/src/views/manage/Quarantine.vue +++ b/src/frontend/app/src/views/manage/Quarantine.vue @@ -58,6 +58,7 @@ import { ref, onMounted } from 'vue'; import { config } from '../../config'; import { useAuthStore } from '../../stores/auth'; // Import useAuthStore +import { csrfFetch } from '../../services/csrf'; const backend_url = config.backendUrl; @@ -79,7 +80,7 @@ function formatDate(iso) { async function fetchQuarantine() { loading.value = true; try { - const r = await fetch(`${backend_url}/quarantine/files`, { + const r = await csrfFetch(`${backend_url}/quarantine/files`, { cache: 'no-store', headers: { 'Authorization': `Bearer ${authStore.token}`, @@ -97,7 +98,7 @@ async function fetchQuarantine() { async function downloadFile(id) { try { - const r = await fetch(`${backend_url}/quarantine/files/${id}/download`, { + const r = await csrfFetch(`${backend_url}/quarantine/files/${id}/download`, { headers: { 'Authorization': `Bearer ${authStore.token}`, }, @@ -119,7 +120,7 @@ async function downloadFile(id) { async function restoreFile(id) { try { - const r = await fetch(`${backend_url}/quarantine/files/${id}/restore`, { + const r = await csrfFetch(`${backend_url}/quarantine/files/${id}/restore`, { method: 'POST', headers: { 'Authorization': `Bearer ${authStore.token}`, @@ -138,7 +139,7 @@ async function restoreFile(id) { async function deleteFile(id) { if (!confirm('Really delete?')) return; try { - const r = await fetch(`${backend_url}/quarantine/files/${id}`, { + const r = await csrfFetch(`${backend_url}/quarantine/files/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${authStore.token}`, diff --git a/src/frontend/app/src/views/manage/Reports.vue b/src/frontend/app/src/views/manage/Reports.vue index d73fc911..a5014fd1 100644 --- a/src/frontend/app/src/views/manage/Reports.vue +++ b/src/frontend/app/src/views/manage/Reports.vue @@ -221,6 +221,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { config } from '../../config' import { useAuthStore } from '../../stores/auth' +import { csrfFetch } from '../../services/csrf' interface ReportFile { scan_id: number @@ -286,7 +287,7 @@ async function loadReports() { search: searchTerm.value.trim(), formats: selectedTypes.value.join(','), }) - const response = await fetch(`${backendUrl}/scans/report-groups-page?${query}`, { + const response = await csrfFetch(`${backendUrl}/scans/report-groups-page?${query}`, { headers: authorizationHeaders(), signal: controller.signal, }) @@ -360,7 +361,7 @@ function individualFiles(group: ReportGroup) { async function downloadReport(reportFile: ReportFile) { downloadingScanIds.value = new Set(downloadingScanIds.value).add(reportFile.scan_id) try { - const response = await fetch( + const response = await csrfFetch( `${backendUrl}/scans/${reportFile.scan_id}/download`, { headers: authorizationHeaders() }, ) @@ -381,7 +382,7 @@ async function downloadReport(reportFile: ReportFile) { async function downloadSingleHostVisuals(group: ReportGroup) { downloadingGroupIds.value = new Set(downloadingGroupIds.value).add(group.group_id) try { - const response = await fetch( + const response = await csrfFetch( `${backendUrl}/scans/report-groups/${encodeURIComponent(group.group_id)}/single-host-visuals`, { headers: authorizationHeaders() }, ) @@ -410,7 +411,7 @@ async function deleteGroup(group: ReportGroup) { deletingGroupIds.value = new Set(deletingGroupIds.value).add(group.group_id) try { - const response = await fetch( + const response = await csrfFetch( `${backendUrl}/scans/report-groups/${encodeURIComponent(group.group_id)}`, { method: 'DELETE', diff --git a/src/frontend/app/src/views/upload/Upload.vue b/src/frontend/app/src/views/upload/Upload.vue index 24031d11..e242f3f3 100644 --- a/src/frontend/app/src/views/upload/Upload.vue +++ b/src/frontend/app/src/views/upload/Upload.vue @@ -168,6 +168,7 @@ import { ref, onMounted } from 'vue' import { config } from '../../config' import { useAuthStore } from '../../stores/auth' // Import useAuthStore +import { csrfFetch } from '../../services/csrf' interface UploadResult { filename: string @@ -250,7 +251,7 @@ function getParserText(item: UploadResult) { async function refreshClamBadge() { try { - const r = await fetch(`${backend_url}/antivirus-status`, { + const r = await csrfFetch(`${backend_url}/antivirus-status`, { cache: 'no-store', headers: { 'Authorization': `Bearer ${authStore.token}`, @@ -318,7 +319,7 @@ async function confirmFilenamePatterns(files: File[]) { if (filesNeedingCheck.length === 0) return true try { - const response = await fetch(`${backend_url}/upload/filename-check`, { + const response = await csrfFetch(`${backend_url}/upload/filename-check`, { method: 'POST', headers: { 'Authorization': `Bearer ${authStore.token}`, @@ -370,7 +371,7 @@ async function uploadValidFiles(validFilesForUpload: File[]) { formData.append('file', file) // Changed from 'files' to 'file' try { - const response = await fetch(`${backend_url}/upload`, { + const response = await csrfFetch(`${backend_url}/upload`, { method: 'POST', body: formData, headers: { @@ -424,7 +425,7 @@ function pollJobStatus(job: UploadResult) { const pollFunction = async () => { try { - const response = await fetch(`${backend_url}/upload/status/${job.job_id}`, { + const response = await csrfFetch(`${backend_url}/upload/status/${job.job_id}`, { headers: { 'Authorization': `Bearer ${authStore.token}`, }, @@ -474,7 +475,7 @@ async function overwrite(index: number) { formData.append('file', item.file); try { - const response = await fetch(`${backend_url}/upload?override=true`, { + const response = await csrfFetch(`${backend_url}/upload?override=true`, { method: 'POST', body: formData, headers: {