DEV Community

Konstantin
Konstantin

Posted on

3 security bugs I shipped in my open-source SaaS — and how I fixed them

Shipping fast as a solo founder means you will introduce security bugs. That's not a question of skill — it's a question of bandwidth. The question is whether you catch them before someone else does.

I'm building Pronto — an open-source self-hosted POS, CRM, and booking system for service businesses. During a security review of v1.0, I found three issues that made me genuinely uncomfortable. None were exploited. All are now fixed.

Here's what they were, why they happened, and how I fixed each one.


Bug 1: Bot tokens leaking to client-side HTML

What happened

Pronto supports Telegram, WhatsApp, and Viber notifications. Each tenant configures their own bot credentials in Settings. These credentials are stored in the database and used server-side to dispatch notifications.

In an early version, I had a notification preview endpoint that fetched tenant settings — including bot tokens — and returned them in a JSON response that was consumed directly by a client-side component.

The token never appeared visibly in the UI. But it was sitting in a fetch response, readable from the browser's DevTools network tab.

// ❌ WRONG — this response reached the client
const settings = await fetch('/api/settings/notifications')
// response included: { telegram_token: "7412...", viber_token: "1234..." }
Enter fullscreen mode Exit fullscreen mode

Any user logged into that tenant's account could extract their own bot token from the browser. For multi-tenant SaaS, this is a real problem — especially if a tenant's account gets compromised.

Why it happened

I built the notifications settings UI quickly and pulled the same endpoint used for server-side rendering. The path of least resistance was "fetch everything, display what you need." The token fields weren't rendered in the UI, so it felt safe. It wasn't.

The fix

All notification dispatch moved to server-only API routes protected by an internal secret header. Client-facing endpoints now return only non-sensitive configuration (enabled/disabled state, phone number prefix for display) — never credentials.

// ✅ Server-side only — internal routes
// middleware checks: req.headers['x-internal-secret'] === process.env.INTERNAL_API_SECRET

// ✅ Client receives only display data
const settings = await fetch('/api/settings/notifications/display')
// response: { telegram_enabled: true, whatsapp_number_preview: "+1 234 ***" }
Enter fullscreen mode Exit fullscreen mode

Rule learned: If it looks like a credential, it never touches the client. Not even in a field that isn't rendered.


Bug 2: Unauthenticated API endpoint

What happened

The public booking page — where clients book appointments without creating an account — needs to fetch available time slots for a given business. This endpoint is intentionally public. No auth required.

However, the same route handler also accepted a staff_id parameter and, when provided, returned full staff profile data including internal notes and contact details that were never meant to be public.

GET /api/public/slots?business=salon-maya&staff_id=123
// returned available slots ✅
// also returned: { name, phone, internal_notes, salary_type } ❌
Enter fullscreen mode Exit fullscreen mode

Why it happened

The staff lookup was added to pre-fill the booking form when a client clicks a specific staff member's profile. I reused an existing staff fetch function without thinking about what data it exposed. The function was designed for internal admin use.

The fix

Public endpoints now use dedicated lean serializers that explicitly whitelist fields:

// ✅ Public staff serializer — only what the booking page needs
function serializeStaffPublic(staff) {
  return {
    id: staff.id,
    name: staff.display_name,
    avatar_url: staff.avatar_url,
    services: staff.services,
  }
  // internal_notes, phone, salary_type — never included
}
Enter fullscreen mode Exit fullscreen mode

Rule learned: Never reuse internal data fetchers for public endpoints. Write a separate serializer that explicitly lists what's allowed out.


Bug 3: In-memory rate limiting that restarts on deploy

What happened

The public booking form is rate-limited to prevent spam. I implemented this with a simple in-memory store:

// ❌ In-memory rate limiter
const attempts = new Map() // { ip: { count, resetAt } }

export function checkRateLimit(ip) {
  const record = attempts.get(ip)
  if (record && record.count >= 10 && Date.now() < record.resetAt) {
    return false // rate limited
  }
  // update record...
  return true
}
Enter fullscreen mode Exit fullscreen mode

This works fine in development. In production, every container restart wipes the Map. Every deploy resets all rate limit counters. A bad actor who knows your deploy schedule (or just retries after a restart) bypasses the limit entirely.

For a SaaS with multiple instances, it's even worse — each instance has its own Map, so the effective limit is N instances × 10 requests.

Why it happened

In-memory is the fastest path to "rate limiting is implemented." It works in local testing. The failure mode only appears in production at scale or with deliberate probing.

The fix

The proper solution is Redis (Upstash works well for serverless). But I documented it as a known limitation with a clear upgrade path rather than blocking the release:

// ✅ Production path — Redis via Upstash
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '1 h'),
})

const { success } = await ratelimit.limit(ip)
Enter fullscreen mode Exit fullscreen mode

For self-hosted single-instance deployments, the in-memory version is documented as acceptable with the caveat clearly stated in README. For SaaS — Redis is required.

Rule learned: In-memory state that needs to survive restarts is a production bug, not a dev shortcut. Either implement it properly or document the limitation explicitly so users aren't surprised.


The meta-lesson

All three bugs share a root cause: I made the "fast path" decision without thinking about the security surface.

  • Fastest notification settings: return everything to the client
  • Fastest staff lookup: reuse the existing function
  • Fastest rate limiting: in-memory Map

Speed of implementation and security surface are often in tension. When you're building solo, you need a personal forcing function to catch this tension before it ships.

Mine is now: anything that touches credentials, user data, or auth gets a 5-minute threat model before it goes to production. Not a formal audit. Just five minutes of "who can call this, what can they get, what happens if this is wrong."

It has caught two more issues since I formalized it.


Pronto is open-source under MIT. If you find something I missed — issues are open.

GitHub: github.com/SGrappelli/pronto
SaaS: trypronto.app

Top comments (2)

Collapse
 
harjjotsinghh profile image
Harjot Singh

Writing up your own shipped security bugs takes humility and it's some of the most useful content there is, because security bugs cluster around the same few patterns and seeing real ones beats reading OWASP in the abstract. The usual suspects in a solo SaaS are exactly the boring-but-fatal ones: missing authorization checks (authenticated but not authorized - the classic IDOR where you can read someone else's record by changing an ID), trusting client input, leaking data in error messages or API responses, and secrets/config exposure. None are exotic; all ship constantly because they're invisible until probed and there's no compiler error for "forgot to check ownership."

This is exactly why I treat security as a verify-layer problem, not a remember-to-do-it problem - the boring-critical checks have to be structural defaults, because relying on a solo dev to remember every authz check is how these ship. It's core to Moonshift, the thing I build: a multi-agent pipeline that takes a prompt to a deployed SaaS, where auth/authorization patterns are wired as verified defaults rather than left as an exercise. Multi-model routing keeps a build ~$3 flat, first run free no card. Brave and useful post. Were the three the classic authz/IDOR family, or something subtler? The missing-ownership-check is the one I see ship most in solo SaaS - no error, just a quiet data leak.

Collapse
 
prontodev profile image
Konstantin

Exactly right on the structural defaults point — "remember to check ownership" is not a system, it's a hope.
Two of my three were in the data-leaking-in-responses family rather than classic IDOR. The bot token one was effectively an unintentional credential exposure through a response that was never meant to carry sensitive fields — no ownership check missing, just a function reused across contexts where the output surface wasn't thought through. The staff endpoint was closer to your missing-ownership-check pattern: authenticated, but the serializer was built for internal use and never filtered for public context.
The rate limiter bug is the one I find most instructive to talk about because it works in tests — the failure mode is invisible until production conditions (restarts, multiple instances) expose it. No error, no warning, just silent bypass.
Pronto is multi-tenant SaaS for service SMBs, so the authz surface is relatively contained — every query is RLS-scoped at the DB layer via Supabase, which handles the ownership-check pattern structurally rather than per-query. That one I got right from the start. The credential exposure I did not.