Skip to content

Security: BVoychev/cv

Security

SECURITY.md

Security Posture

Consolidated view of how this project defends itself. Mechanics live in the topic docs (voychev-backend/architecture/AUTH.md, voychev-backend/README.md, voychev-deploy/README.md); this doc is the attacker's-eye summary and threat model. Decisions are recorded as ADRs in voychev-backend/architecture/DECISIONS.md (notably ADR-011, auth).

Threat model — what we defend against

Threat Defense Where
Leaked access token Access JWT is short-lived (15m) and verified locally; a leak self-heals on expiry core/security.py, AUTH.md
Stolen refresh token (replay) Refresh tokens are single-use and rotated; replaying a rotated token revokes the whole rotation family (theft detection) and forces re-login services/auth.py, AUTH.md
Forged session cookie Auth cookie is a signed JWT (HS256); empty/placeholder JWT_SECRET is rejected at startup in production. There is no client-side quota cookie to forge — anonymous quota is server-side (ADR-016) core/security.py, core/config.py
Cookie theft via XSS All auth cookies are HttpOnly (JS can't read them); no token in localStorage/Redux; markdown rendered via react-markdown (escapes raw HTML / javascript:) AUTH.md, voychev/architecture/ARCHITECTURE.md
Reading another user's chat history Session history/delete are ownership-checked and return 404 (not 403) for a foreign session — existence isn't leaked services/sessions.py, API.md
Quota / cost abuse (anonymous) Per-client-IP hourly fixed-window limit (ip_rate_limit) — the sole anon control (no client-trusted cookie, ADR-016). Fails closed (503) in production if the limiter DB is down services/quota.py, repositories/rate_limit.py
Quota abuse (signed-in) Per-user fixed-window rate limit; fails closed (503) in production if the limiter's DB is unavailable rather than under-enforcing services/quota.py, repositories/rate_limit.py
Runaway LLM spend per request Hard max_tokens cap per turn (chat_max_output_tokens) bounds worst-case output cost/latency of any single message rag/llm_factory.py
Unauthorized / costly reindex POST /chat/reindex requires a constant-time-compared X-API-Key; a missing key in production rejects all requests api/v1/chat.py, core/config.py
Prompt injection / secret exfiltration via chat Agent system prompt scoped to Boris/this-project topics, with secret-disclosure and prompt-injection guardrails; user-memory namespace comes from the verified cookie user id, never model input rag/agent.py, rag/memory_tools.py
Cross-origin credential theft Narrow CORS allow-list (wildcard * rejected because allow_credentials=True); cookies SameSite=Lax, Secure main.py, core/config.py
Privilege escalation via role/permission tampering Role→permission map is fixed in code (ROLE_PERMISSIONS), not DB-editable — no endpoint can grant a permission. role is never in the JWT; permissions returned to the client are UX-only and re-derived from a fresh DB read of role on every request by require_permission/require_role — a client-sent permissions value is never trusted services/permissions.py, api/deps.py, ADR-018
Compromised/rogue admin account A role change takes effect on the attacker's next request (no JWT/cache to wait out, ADR-018). The bootstrap admin is is_protectedDELETE /auth/me and role-PATCH on it both 403. The last remaining admin can't be demoted (409), so the system can't be locked into zero admins. Every role change is written to role_changes (actor, target, old/new role, timestamp) services/admin_users.py, AUTH.md
Malicious / oversized admin upload Photo/certificate uploads (POST /cv/photo, /cv/certificate) are content.edit-gated and validated server-side: content-type allow-list (images / PDF) + size caps (2 MB / 10 MB) → 415/413. Stored in a private assets bucket (public_access_prevention=enforced, no allUsers); only voychev-run can read/write it. Public reads go through the backend proxy GET /api/v1/assets/<key> (key prefix + charset validated — no traversal), so GCS is never directly exposed api/v1/cv.py, api/v1/assets.py, services/asset_storage.py, ADR-019
Info leak via errors Catch-all handler returns generic 500; tracebacks/DB text never reach clients; API docs (/docs, /openapi.json) disabled outside development main.py, core/logging.py
SQL injection All queries parameterized (SQLAlchemy select() / psycopg3 %s); no string-built SQL. Path params (session_id) constrained to UUID repos, api/v1/
Secret in git gitleaks on every push/PR; .env* gitignored; .gitleaks.toml allowlists templates/fixtures only .github/workflows/security.yml
Vulnerable dependency / image pip-audit (backend), npm audit --audit-level=high (frontend), Trivy HIGH,CRITICAL scan of the backend image security.yml, see CICD.md

Defense layers

Application (FastAPI). Fail-fast config: in APP_ENV=production, Settings refuses to start if JWT_SECRET, REINDEX_API_KEY, or (when Google sign-in is on) GOOGLE_OAUTH_CLIENT_SECRET are empty or change-me; CORS_ORIGINS=["*"] is rejected. Rate limiting and the reindex key fail closed. Generic error bodies, UUID-constrained path params, length-capped OAuth inputs. Covered by tests/unit/test_config.py and tests/unit/test_production_guards.py.

Auth. Google OAuth code flow (only the client ID reaches the browser; the code+secret exchange is server-side). Short access JWT + opaque, rotating, server-side refresh session with theft detection, idle-expiry (2h) and an absolute cap (1d). Logout and account-delete revoke server-side. Full model: AUTH.md, rationale: ADR-011.

Authorization (RBAC). Three fixed roles (viewer/blogger/admin) map to permission codes via a single in-code table, mirrored read-only into the DB for introspection only — there is no permission-editing endpoint, so a write to that mirror can't grant new capabilities. get_current_user does one indexed users lookup per protected request and derives permissions from role fresh — no JWT claim, no cache, immediate effect on revocation/promotion. Content writes (PUT /cv|/links|/videos, blog CRUD, /admin/users*) all gate through require_permission/require_role; blog update/delete additionally check author_id ownership for *.own permissions. Full model: AUTH.md, ADR-018.

Transport / edge (nginx, prod-parity + Cloud Run). TLS termination, HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy (voychev-deploy/nginx/security_headers.conf). Production TLS certs are Google-managed (auto-renewed).

Asset uploads. Admin-uploaded images/PDFs go to a private GCS bucket (${project}-assets, public_access_prevention=enforced, ADR-019), separate from the code corpus and secrets. Only the runtime SA can read/write it (objectAdmin, no allUsers); public reads are mediated by the backend proxy GET /api/v1/assets/ <key> (same-origin, key-validated). Uploads are content-type- and size-validated at the endpoint. The RAG code loader's secret-exclusion (.env*, certs, tfstate) is unchanged — secrets are never embedded or served.

Container / infra. Backend + frontend-build run cap_drop: [ALL] + no-new-privileges; Postgres is never host-exposed (only nginx publishes a port); RAG code-ingestion mounts the repo read-only and the loader skips secrets (.env* by name; certs/tf-state outside its extension allow-list), so nothing sensitive is embedded (and .dockerignore keeps them out of the Cloud Run image). In GCP, the Cloud Run runtime SA (voychev-run) has only cloudsql.client + per-secret secretAccessor; the deploy SA is separate and WIF-scoped to this GitHub repo. Inventory: DEPLOYMENT.md.

Secrets. Local: .env* files (gitignored), split per service (.env.backend holds the sensitive set). Production: GCP Secret Manager, injected as Cloud Run env vars — no .env in production. Never committed; gitleaks enforces.

Known gaps / explicitly out of scope

  • No WAF / DDoS protection beyond Cloud Run's platform defaults.
  • No provider-side spend alarmmax_tokens + per-IP limits bound cost, but a hard budget cap/alert on the OpenAI/Anthropic accounts is still recommended (out-of-band, provider dashboards). Tracked as G1's remaining item.
  • Per-IP limiting trusts X-Forwarded-For — correct behind Cloud Run/nginx (which set it); not safe if the app is ever exposed without a trusted proxy.
  • No CSRF token: mitigated by SameSite=Lax cookies + JSON-only Content-Type on state-changing routes (not form-encoded), so a cross-site form can't forge them.
  • Apex voychev.com not configured — only the two subdomains serve the app.

Reporting

This is a personal project. Security issues: email bvoychev@gmail.com.

There aren't any published security advisories