I Open-Sourced My AI Dashboard -- and Found My 'Secure' Proxy Was an Open ATM
I built a small offline-first, collaborative AI dashboard called AgentHub — React + Yjs (CRDTs) on the front, a Node/Express proxy on the back. It worked great on my laptop. Then I went to open-source it, read my own ba
I built a small offline-first, collaborative AI dashboard called AgentHub — React + Yjs (CRDTs) on the front, a Node/Express proxy on the back. It worked great on my laptop.
Then I went to open-source it, read my own backend like a stranger would, and realized the "secure" proxy I was so proud of was a wide-open ATM for my API key.
Here's the build, the bug, and the exact code that fixed it — all stealable.
The architecture, in one breath
Three constraints drove the whole thing:
- Works offline — state persists locally, network is optional.
- Conflict-free collaboration — two people, same state, no merge hell.
- API key never reaches the browser — the client talks to my server, my server talks to the model.
Yjs handles the first two almost for free. CRDTs (Conflict-free Replicated Data Types) merge concurrent edits deterministically, so I never write merge logic. The client setup is genuinely this small:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
const doc = new Y.Doc();
// 1) Offline-first: restore + persist local state in IndexedDB
new IndexeddbPersistence('agent-crdt-state-v1', doc);
// 2) Real-time: join a room over WebSocket and sync conflict-free
new WebsocketProvider(WS_URL, 'agent-crdt-state-v1', doc);
Pull the network cable, keep editing, plug it back in — it reconciles. No spinner, no data loss. The first time you see it, it feels illegal.
The third constraint is where I fooled myself
I kept the API key off the client. The browser calls POST /groq, my server injects the key and forwards to the model:
app.post('/groq', async (req, res) => {
const { prompt } = req.body;
const upstream = await fetch(GROQ_OPENAI_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${GROQ_API_KEY}`, // key stays server-side
},
body: JSON.stringify({ model: GROQ_MODEL, messages: [{ role: 'user', content: prompt }] }),
});
// ...forward response
});
"Key's off the client. I'm secure." Pat on the back. Push to GitHub.
Except — look at what's missing. No auth. No rate limit. CORS open to the entire internet. That endpoint is a public, unmetered pipe to my paid API key. Anyone who finds the URL can drain it from any website on Earth.
I'd locked the front door and left the vault open.
Fixing it — three small middlewares
None of this needs a library. Here's the whole hardening pass.
1. CORS allowlist (not cors() with no args):
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '')
.split(',').map(o => o.trim()).filter(Boolean);
app.use(cors(ALLOWED_ORIGINS.length ? { origin: ALLOWED_ORIGINS } : undefined));
if (!ALLOWED_ORIGINS.length) {
console.warn('[proxy] ALLOWED_ORIGINS unset — CORS open to all origins.');
}
The loud warning matters. I will forget to set the env var; I want the server yelling at me when I do.
2. Per-IP rate limiting (in-memory, zero deps):
const buckets = new Map();
function rateLimit(req, res, next) {
const ip = req.ip || 'unknown';
const now = Date.now();
const hits = (buckets.get(ip) || []).filter(t => now - t < WINDOW_MS);
if (hits.length >= MAX_HITS) {
return res.status(429).json({ error: 'Too many requests, slow down.' });
}
hits.push(now);
buckets.set(ip, hits);
next();
}
A sliding window per IP. One bad actor can't empty the key in an afternoon. (For multi-instance deploys you'd back this with Redis — but for a single node, a Map is honest and enough.)
3. Optional bearer-token gate:
function requireAuth(req, res, next) {
if (!PROXY_AUTH_TOKEN) return next(); // disabled unless you set it
const token = (req.headers.authorization || '').replace('Bearer ', '').trim();
if (token !== PROXY_AUTH_TOKEN) return res.status(401).json({ error: 'Unauthorized' });
next();
}
Then chain them onto the route:
app.post('/groq', rateLimit, requireAuth, async (req, res) => { /* ... */ });
Bonus footgun I also found: the original handler logged every prompt and the full upstream response to the console. Fine locally, a privacy leak in production. I gated all of it behind a DEBUG_GROQ flag.
⚠️ Browser-app caveat: any token you ship to the browser is visible to users. For a purely public web app, lean on
ALLOWED_ORIGINS+ rate limiting; usePROXY_AUTH_TOKENfor server-to-server or trusted clients.
"Open-source ready" is more than git push
The code was the easy half. The repo only felt honest once it had:
- A real LICENSE ("it's on GitHub" is not a license).
- A SECURITY.md that's upfront about the proxy's risks.
- A CONTRIBUTING.md so the next dev isn't reverse-engineering my setup.
- A copy-pasteable Quick Start (running in under 5 minutes).
- A guarantee no secret ever touched git history — the whole history, not just HEAD.
That last one deserves a billboard: a key that was ever committed is compromised, even if you delete it later. Grep your full history before you publish, not after.
Takeaway
The CRDT magic was the fun part. The lesson that'll outlive the project is duller and more useful:
Shipping for yourself and shipping for strangers are two different jobs. The second one is mostly respect — for the next dev's time, and for everyone who trusts your code enough to run it.
AgentHub is MIT-licensed and live. If offline-first sync or CRDTs are on your "I should learn that" list, it's a small, readable place to start:
👉 https://github.com/payallenka/AgentHub
Found a hole I missed? Open an issue. That's the whole point of putting it out there.
If this was useful, a ❤️ or 🦄 helps it reach the next dev. What's the worst "secure" code you've shipped? I'll go first — see above.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.