diff --git a/security/pentest-2026-06-30.md b/security/pentest-2026-06-30.md new file mode 100644 index 0000000..ecdaf50 --- /dev/null +++ b/security/pentest-2026-06-30.md @@ -0,0 +1,92 @@ +# Pentest mini-report — versila22/lima-app — 2026-06-30 + +**Probed URL:** https://limaimpro.duckdns.org/ +**Stack:** React 18 / Vite 7 / shadcn-ui / FastAPI (Python) / PostgreSQL / npm / PWA=yes (vite-plugin-pwa, Workbox SW, manifest) +**Counts:** Critical=1 High=4 Medium=3 Low=2 Info=2 + +## Findings + +| Sev | Cat | Title | Location | +|---|---|---|---| +| Critical | SAST | Vitest UI — arbitrary file read/execute | `package.json` vitest@3.2.4 (CVE GHSA-5xrq-8626-4rwp) | +| High | SAST | User enumeration via distinct login error messages | `backend/app/routers/auth.py:64-70` | +| High | SAST | Rate-limit bypass via X-Forwarded-For spoofing | `backend/app/limiting.py:14-16` | +| High | SAST | Base64 photo stored in DB with no size cap | `backend/app/routers/members.py` (`/photo-data` endpoint) | +| High | SAST | react-router open redirect (//path) | `package.json` react-router@6.30.3 (GHSA-2j2x-hqr9-3h42) | +| Medium | SAST | CSP `style-src 'unsafe-inline'` weakens XSS protection | `nginx.conf:14` | +| Medium | SAST | python-jose 3.5.0 — outdated JWT library (no active maintenance) | `backend/requirements.txt` | +| Medium | Infra | serialize-javascript RCE in build toolchain | `package.json` serialize-javascript@6.0.2 (devDep) | +| Low | SAST | HSTS missing `preload` directive | `nginx.conf:15` | +| Low | Infra | TLS 1.2 still enabled (TLS 1.3-only preferred) | DAST — openssl probe | +| Info | SAST | Login returns access token in body (dual-channel) | `backend/app/routers/auth.py` | +| Info | Infra | TLS cert valid 30 days only (Let's Encrypt, auto-renews via Traefik) | DAST | + +## Top 3 fixes + +1. **User enumeration** — Collapse all 3 auth error branches into a single generic message ("Identifiants invalides") and delete the `_AUTH_MESSAGES` dict in `backend/app/routers/auth.py`. +2. **X-Forwarded-For rate-limit bypass** — Replace `forwarded_for.split(",")[0].strip()` with the rightmost untrusted IP or use a trusted-proxy list; alternatively use `slowapi`'s `get_remote_address` key function which reads `request.client.host`. +3. **Base64 photo size cap** — Add a size guard (`if len(payload.data) > 500_000: raise HTTPException(413)`) in `upload_member_photo_data` before the DB write. + +## Evidence (Critical/High only) + +### [CRITICAL] Vitest UI — arbitrary file read/execute +- **Location:** `package.json` devDependency `vitest@3.2.4` (CVE GHSA-5xrq-8626-4rwp, fixed in 3.2.6) +- **Snippet:** `"vitest": "^3.2.4"` — installed version is 3.2.4 +- **Impact:** If `vitest --ui` is inadvertently exposed (e.g., dev server port open) any file on the host can be read or executed by a remote attacker. +- **Fix:** `npm update vitest` to >=3.2.6; confirm vitest UI port is never reachable from the internet in CI/CD. + +### [HIGH] User enumeration via distinct login error messages +- **Location:** `backend/app/routers/auth.py` lines 62-72 +- **Snippet:** `"email_not_found": "Aucun compte trouvé pour cet email.", "wrong_password": "Mot de passe incorrect.", ...` +- **Impact:** Attacker can enumerate which email addresses have registered accounts, facilitating targeted phishing or credential stuffing. +- **Fix:** Return a single message for all failure cases: `"Identifiants invalides."`. + +### [HIGH] Rate-limit bypass via X-Forwarded-For spoofing +- **Location:** `backend/app/limiting.py` line 14-16 +- **Snippet:** `forwarded_for = request.headers.get("X-Forwarded-For"); return forwarded_for.split(",")[0].strip()` +- **Impact:** Attacker can set a spoofed `X-Forwarded-For` header to rotate their apparent source IP, bypassing the 5/min login rate limit and enabling unlimited brute-force. +- **Fix:** Use the rightmost (trusted) IP from `X-Forwarded-For`, or switch key function to `get_remote_address` and configure Railway to strip/inject the header reliably. + +### [HIGH] Unbounded base64 photo stored in DB +- **Location:** `backend/app/routers/members.py` — `upload_member_photo_data` endpoint (`POST /members/{id}/photo-data`) +- **Snippet:** `member.photo_url = payload.data` with no size check after `startswith("data:image/")` +- **Impact:** Any authenticated member can upload arbitrarily large base64 strings into the `photo_url` column, causing DB bloat, OOM on the API process, and potential DoS. +- **Fix:** Enforce a max length (e.g., 500 KB) on `payload.data` before persisting; consider moving all photos to R2 exclusively. + +### [HIGH] react-router open redirect +- **Location:** `package.json` — `react-router-dom@6.30.3` (GHSA-2j2x-hqr9-3h42, fixed in 6.30.4) +- **Snippet:** `react-router-dom: ^6.30.1` — installed 6.30.3; paths starting with `//` are reinterpreted as protocol-relative URLs. +- **Impact:** Attacker crafts a link like `https://app/#//evil.com/path`; react-router redirects user to `//evil.com`, enabling phishing. +- **Fix:** `npm update react-router-dom` to >=6.30.4. + +## Verified safe + +- No hardcoded secrets found in `.ts/.tsx/.js/.jsx` source files — all keys use `VITE_` prefix from env vars. +- No `dangerouslySetInnerHTML`, `innerHTML =`, or `eval()` found in React frontend source. +- No `postMessage` usage found that could allow cross-origin message injection. +- JWT tokens use httpOnly cookies (`set_cookie(..., httponly=True, secure=)`); not stored in localStorage. +- API docs (`/docs`, `/redoc`) disabled in production via `docs_url=None`. +- Admin endpoints correctly guarded by `require_admin` dependency. +- TLS 1.0 and TLS 1.1 disabled; TLS 1.2 + 1.3 enabled. +- TLS cert valid (notBefore: 2026-06-30, notAfter: 2026-07-30); auto-renew via Traefik + Let's Encrypt. +- Sensitive paths (`.env`, `.git/config`, `backup.zip`) all return non-200 (proxy-blocked or 404). +- `python-jose` JWT decode uses explicit `algorithms=[settings.JWT_ALGORITHM]` — `alg:none` attack mitigated. +- Rate limiting on `/auth/login` (5/min) and `/auth/forgot-password` (3/min) is in place. +- HSTS header present (`max-age=31536000; includeSubDomains`). +- `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy` all set. +- CORS `allow_origins` is an explicit list (not wildcard), controlled by `settings.CORS_ORIGINS`. +- No `.env` files with real credentials committed to repo (only `.env.example` with placeholders). + +## Needs server-side verification + +- Confirm Railway strips/sanitises `X-Forwarded-For` before it reaches the API — if it does, the rate-limit bypass is mitigated at the infrastructure layer. +- Verify `APP_ENV` is set to `"production"` on Railway so the `validate_jwt_secret` model validator rejects startup with default JWT secret. +- Verify `REFRESH_JWT_SECRET` is distinct from `JWT_SECRET` in Railway env vars. +- Confirm Vitest UI port (typically 51204) is not exposed externally in any CI/CD environment. +- Check whether `bun.lockb` (binary lock) and `package-lock.json` are both committed — dual lockfiles can cause non-deterministic installs; recommend keeping only one. +- Verify that R2 bucket for photo storage has appropriate public-access controls (no public listing). + +## Tools + +ran=npm-audit, openssl-s_client (TLS), curl (HTTP headers/sensitive-path probe), grep/find (SAST), manual code review +skipped=pnpm-audit (not installed, fell back to npm audit), yarn-audit (not installed), safety-check (network-blocked for DB fetch), nmap/nikto (not installed)