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
92 changes: 92 additions & 0 deletions security/pentest-2026-06-30.md
Original file line number Diff line number Diff line change
@@ -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=<runtime>)`); 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)
Loading