DrawmaticAI has been running for weeks. Users -- well, me -- create diagrams, collaborate via WebSocket, and let the AI generate XML. It felt secure enough. OAuth, CSRF tokens, invite gates. Then I sat down with a list of thirteen findings from a proper security review and realized felt secure is not the same thing as is secure.
The worst was the IDOR. Any authenticated user could read, modify, or delete any other user's diagram just by changing the UUID in the URL. The GET /api/diagrams/{uuid} endpoint had no ownership check at all. The WebSocket handler had one, but the REST endpoints were wide open. I added a single _require_diagram_access() helper and applied it to all sixteen diagram endpoints. Now every request verifies both authentication and ownership. Unauthorized requests return 403. Obvious, in retrospect. Embarrassing, in practice.
The JWT secret was another quiet failure. When JWT_SECRET was not set, the app generated a random one on startup. Every restart invalidated every session. Worse, the secret was never persisted. I added a .jwt_secret file alongside the existing .session_secret, with a _load_or_create_jwt_secret() function that prefers the environment variable, falls back to the file, and only generates a new one if neither exists. Sessions now survive restarts. The minimum 32-character check prevents weak secrets from being silently accepted.
The WebSocket had its own problems. The per-diagram lock was created with setdefault, which is not atomic. Two simultaneous updates could create two locks, defeating the purpose. I added a global lock to protect the lock dictionary itself. Then there was the fact that WebSocket updates only touched the in-memory cache, not the database. A restart wiped everything. Now save_xml() persists to SQLite before broadcasting the update.
I also found asyncio.get_event_loop() in two places -- deprecated in Python 3.10 and removed in 3.12. Replaced with the already-imported _asyncio module. The Host header fallback in _trusted_base_url() was using the raw request URL, which can be forged. Now it defaults to localhost instead. The rate limiter had unbounded memory growth -- I added periodic cleanup of stale IP entries. The Ollama URL had no SSRF protection, so I restricted it to private addresses only.
Then there was the stored XSS risk in diagram names. A user could name a diagram <script>alert(1)</script> and it would persist. I added _sanitize_diagram_name() that strips tags, HTML-escapes, and truncates to 200 characters. Applied in both create() and save_metadata(). The WebSocket now validates XML with validate_xml() before accepting updates, preventing malformed or malicious content from corrupting the diagram.
Security headers were missing entirely -- no X-Frame-Options, no X-Content-Type-Options, no CSP, no HSTS. I added a FastAPI middleware that injects all of them on every response. The demo mode had a 1-year cookie max-age, which is excessive for a test token. Reduced to 7 days to match the production OAuth token lifetime.
Finally, the TEST_MODE auth bypass. Setting TEST_MODE=1 and hitting /login would grant instant access with a demo user. Even with the existing localhost and DEMO_SECRET checks, this felt wrong for production. I added an ENV=production guard that blocks TEST_MODE entirely when running in production.
While I was deep in the codebase, I also fixed a production bug: the WebSocket was rejecting connections for diagrams with corrupted owner_id values -- legacy data from before proper user management where JWT tokens had been stored as owner IDs. The frontend was also sending undefined as a diagram UUID during reconnects. Both are now handled gracefully -- invalid UUIDs are rejected early, and corrupted owner IDs are reclaimed by the current user on first access.
I also took the opportunity to improve Code Reviewer's branch review output. The LLM was regurgitating entire source files. I added a hard system-prompt instruction forbidding full-file dumps, requiring references by function name and line number instead. Much more readable.
Thirteen findings. A hundred and sixty lines of changes. Sixteen new tests. And the uncomfortable realization that security is not a feature you add. It is a posture you maintain. Every endpoint you write, every fallback you accept, every shortcut you take -- they all compound. The perimeter is the code. And the code is yours.