Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions security/pentest-2026-06-12.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Pentest mini-report — lima-app — 2026-06-12

**Probed URL:** https://limaimpro.duckdns.org/
**Stack:** React 18 + Vite 7 (SPA/PWA) / npm / Python FastAPI backend / PWA=yes
**Counts:** Critical=1 High=5 Medium=5 Low=3 Info=2

## Findings

| Sev | Cat | Title | Location |
|---|---|---|---------|
| Critical | SCA | vitest <3.2.6: arbitrary file read/exec | package.json |
| High | SAST | Hardcoded seed admin credentials, no production guard | backend/app/main.py:53-121 |
| High | SAST | JWT stored in sessionStorage (unconditional, not Safari-only) | src/lib/api.ts:44 |
| High | SCA | serialize-javascript RCE via RegExp.flags (override insufficient) | node_modules/serialize-javascript@6.0.2 |
| High | SCA | fast-uri path traversal + host confusion (override insufficient) | node_modules/fast-uri@3.1.0 |
| High | SCA | @babel/plugin-transform-modules-systemjs arbitrary code gen | indirect via workbox-build |
| Medium | SAST | Unauthenticated /health/db leaks DB URL prefix + table names | backend/app/main.py:202 |
| Medium | SAST | DEFAULT_JWT_SECRET hardcoded in source | backend/app/config.py:10 |
| Medium | SAST | CORS defaults include localhost origins in production | backend/app/config.py:15 |
| Medium | Infra | CSP style-src 'unsafe-inline' | nginx.conf:20 |
| Medium | SCA | react-router 6.30.3 open redirect via // prefix | react-router@6.30.3 |
| Medium | SCA | postcss 8.5.9 XSS via </style> in CSS stringify | postcss@8.5.9 |
| Low | Infra | HSTS missing preload directive | nginx.conf:21 |
| Low | SAST | dangerouslySetInnerHTML in ChartStyle (static data) | src/components/ui/chart.tsx:70 |
| Low | SAST | 8-hour access token window widens session hijack exposure | backend/app/config.py:33 |
| Info | Infra | API /docs disabled in production | backend/app/main.py:146 |
| Info | PWA | SW NetworkFirst caching may serve stale data offline | vite.config.ts:76 |

## Top 3 fixes

1. **Hardcoded seed credentials** — Add `if not settings.is_development: return` as the first line of `_ensure_seed_data()` so demo accounts are never seeded in production; rotate any accounts already created.
2. **vitest critical CVE** — Run `npm install vitest@^3.2.6`; CVSS 9.8 path traversal/RCE affects the dev/CI environment.
3. **JWT in sessionStorage** — Gate `setSessionToken()` behind a Safari/ITP detection check; rely on httpOnly cookies for all other browsers to prevent XSS token theft.

## Evidence (Critical/High only)

### [Critical] vitest <3.2.6 — GHSA-5xrq-8626-4rwp
- **Location:** `node_modules/vitest@3.2.4` (devDependency)
- **Evidence:** CVSS 9.8 — "When Vitest UI server is listening, arbitrary file can be read and executed" (CWE-862)
- **Impact:** Attacker with network access to the Vitest UI port can read any file on the CI/dev host and execute code.
- **Fix:** `npm install vitest@^3.2.6`

### [High] Hardcoded seed admin credentials — no production guard
- **Location:** `backend/app/main.py:53-62` + `main.py:129-135`
- **Evidence:** `{"email": "admin@lima-impro.fr", ..., "password": "Admin1234!", "app_role": "admin"}`
- **Impact:** On a fresh production DB, an admin account with a publicly-known password is created automatically at first boot — full application compromise.
- **Fix:** Add `if not settings.is_development: return` as the first line of `_ensure_seed_data()`.

### [High] JWT access token in sessionStorage (unconditional)
- **Location:** `src/lib/api.ts:44-45`, `src/contexts/AuthContext.tsx:76-77`
- **Evidence:** `setSessionToken(res.access_token)` called for every login; `sessionStorage.setItem(_SESSION_KEY, token)`
- **Impact:** Any XSS payload can read `sessionStorage` and exfiltrate the raw access token to authenticate as the victim.
- **Fix:** Only call `setSessionToken()` when httpOnly cookies are blocked (Safari ITP detection); rely on httpOnly cookie flow for all other browsers.

### [High] serialize-javascript RCE — GHSA-5c6j-r48x-rmvq
- **Location:** `node_modules/serialize-javascript@6.0.2` (via @rollup/plugin-terser → workbox-build)
- **Evidence:** Override `"serialize-javascript": ">=6.0.2"` resolves to 6.0.2 which is in vulnerable range `<=7.0.4`; fix requires `>=7.0.5`.
- **Impact:** Crafted input to the build pipeline can trigger RCE during `npm run build`.
- **Fix:** Change override to `"serialize-javascript": ">=7.0.5"` and run `npm install`.

### [High] fast-uri path traversal + host confusion — GHSA-q3j6-qgpj-74h6 / GHSA-v39h-62p7-jpjc
- **Location:** `node_modules/fast-uri@3.1.0`
- **Evidence:** Override `"fast-uri": ">=3.1.0"` installs 3.1.0 which is `<=3.1.1` (vulnerable); fix requires `>=3.1.2`.
- **Impact:** Percent-encoded dot segments can bypass path validation; host confusion via encoded authority delimiters.
- **Fix:** Change override to `"fast-uri": ">=3.1.2"` and run `npm install`.

### [High] @babel/plugin-transform-modules-systemjs — GHSA-fv7c-fp4j-7gwp
- **Location:** Indirect dependency via workbox-build
- **Evidence:** CVSS 8.2 — "generates arbitrary code when compiling malicious input" (CWE-94, CWE-843); current override `>=7.27.0` remains in vulnerable range 7.12.0–7.29.3.
- **Impact:** Malicious input to the build pipeline can cause arbitrary code execution at build time.
- **Fix:** Override `"@babel/plugin-transform-modules-systemjs": ">=7.30.0"` and run `npm install`.

## Verified safe

- `.env`, `.env.local`, `.git/config`, `.git/HEAD`, `backup.zip`, `dump.sql` — all return HTTP 403 (Traefik + Nginx blocking)
- TLS 1.0 and TLS 1.1 are disabled; only TLS 1.2 and 1.3 accepted
- TLS certificate is valid (Let's Encrypt, auto-renewed, ~30 days remaining)
- API `/docs` and `/redoc` are disabled in production via FastAPI config
- All `navigate()` calls use hardcoded string paths — no user-controlled redirect targets found
- `dangerouslySetInnerHTML` in `chart.tsx` is populated only from typed developer config, not user data
- No raw secrets found in tracked source files or `.env` files in repo
- JWT validation enforces `type: "access"` / `type: "refresh"` claim separation
- Login endpoint has `@limiter.limit("5/minute")` rate limiting
- `DEFAULT_JWT_SECRET` is rejected at startup for non-development environments via `validate_jwt_secret`

## Needs server-side verification

- Whether `CORS_ORIGINS` is explicitly set in the Railway production environment (if not, localhost origins are allowed)
- Whether `_ensure_seed_data()` already ran in production (check if `admin@lima-impro.fr` exists in the production DB)
- Whether the Railway API host `/health/db` endpoint is network-accessible without authentication
- HSTS preload eligibility for `limaimpro.duckdns.org` (DuckDNS subdomains may be ineligible)

## Tools
ran=npm-audit, grep-secrets, curl-headers(blocked-403), curl-sensitive-paths, openssl-tls, grep-SAST-innerHTML/eval/postMessage/localStorage/JWT/prototype-pollution, manual-code-review(nginx.conf, vite.config.ts, api.ts, AuthContext.tsx, main.py, config.py, security.py, auth.py, chart.tsx)
skipped=dynamic-api-probe(Railway-API-blocked-by-sandbox-egress), pip-audit(Python-not-installed)
Loading