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 | 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_protected — DELETE /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 |
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.
- No WAF / DDoS protection beyond Cloud Run's platform defaults.
- No provider-side spend alarm —
max_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=Laxcookies + JSON-onlyContent-Typeon state-changing routes (not form-encoded), so a cross-site form can't forge them. - Apex
voychev.comnot configured — only the two subdomains serve the app.
This is a personal project. Security issues: email bvoychev@gmail.com.