DEV Community

Cover image for Our API Was Successfully Hacked. Here's What We Fixed Before the Regulator Showed Up
Guy Gontar
Guy Gontar

Posted on • Edited on

Our API Was Successfully Hacked. Here's What We Fixed Before the Regulator Showed Up

The report was a disaster.

During a scheduled Penetration Test, the security firm didn’t just find "theoretical vulnerabilities"—they walked out the digital front door with a database full of real customer PII. They didn't need a complex zero-day exploit; they just used the doors we left wide open.

This is the story of how a "successful" hack became the starting point for a deep-cleaning mission of a legacy system built on outsourced layers and technical debt.

Phase 0: The Outsourcing Relay
The system I inherited was a classic "black box."

Team A (Outsourced) built the foundation under a "speed at all costs" mandate. They were eventually fired for delays.

Team B (Also outsourced) took over, but followed a strict "don't touch what isn't broken" policy. The API layer was treated as sacred ground, even though it was built on sand.

The Result: A backend where business logic worked, but security was non-existent.

1. The "String Interpolation" Disaster
The Pentest confirmed our worst fear: SQL Injection was everywhere. The code was littered with raw string interpolations:

// The hacker's playground
val sql = "SELECT * FROM account WHERE id = $accountId"
Enter fullscreen mode Exit fullscreen mode

In a legacy environment, developers often use $var because it’s fast. But in a financial system, it’s a liability. By passing variables directly into the query string, we were essentially letting the client-side input write our database logic.

The Hardening: We systematically replaced every raw query with JDBI Named Parameters.

val sql = "SELECT * FROM account WHERE id = :accId"
// Bound securely via .bind("accId", accountId)
Enter fullscreen mode Exit fullscreen mode

This change alone neutralized the primary entry point the hackers used. It moved the responsibility of data sanitization from the developer to the database driver.

2. The Unlocked Front Door: Query Parameter Injection
After I started binding my SQL queries, I realized a second, more subtle vulnerability: the Filter Builder.

In an effort to be "flexible," the legacy system had a method that grabbed parameters from the URL and appended them into a list of strings to build the WHERE clause. It was a masterpiece of technical debt that looked like this:

The "Legacy Special" (Implicit Trust):

// Direct concatenation from the URL context
ctx.queryParam("accountId")?.let { 
    filters.add("doc.account_id = $it") 
}
ctx.queryParam("startDate")?.let {
    filters.add("DATE(dt) >= '$it'")
}
Enter fullscreen mode Exit fullscreen mode

The Vulnerability: Even if the final query was "parameterized," the logic itself was being written by the user. If a hacker sent ?accountId=1 OR 1=1, the filter list would happily include a condition that exposed every record in the database.

The Surgical Fix: The "Type Shield"
We refactored this into a Type Gateway. Instead of trusting the string, we forced every parameter to prove its identity before it was allowed near the query builder.

The Hardened Version (Zero Trust):

// Numeric Shields (toLongOrNull is your best friend)
ctx.queryParam("accountId")?.toLongOrNull()?.let { id ->
    filters.add("doc.account_id = $id")
}

// Regex Validation for Strings
val dateRegex = Regex("""^\d{4}-\d{2}-\d{2}$""")
ctx.queryParam("startDate")?.takeIf { it.matches(dateRegex) }?.let { date ->
    filters.add("DATE(dt) >= '$date'")
}

// 3. JSONB Hardening
ctx.queryParam("depositId")?.toLongOrNull()?.let { id ->
    // Casting to Long ensures the JSON structure can't be "broken out of"
    filters.add("""doc.meta @> '{"items":[$id]}'""")
}
Enter fullscreen mode Exit fullscreen mode

Why this mattered:
The BIGINT Shield: By using toLongOrNull(), any non-numeric "injection" simply returns null, and the filter is ignored. The attack disappears before it's even born.

Regex as a Filter: If a startDate doesn't match the YYYY-MM-DD pattern perfectly, it’s not invited to the party.

Sanitizing the Strings: For fields where we had to allow text (like type or status), we implemented a surgical replace("'", "''") to ensure a single quote couldn't break the SQL string.

3. The "Any?" Type-Safety Minefield
Security isn't just about SQL; it’s about contracts. Because the system grew through different teams, the core functions were written to be "flexible." One crucial function looked like this:

fun newMovement(accountId: Any?, ...) { ... }
Enter fullscreen mode Exit fullscreen mode

Why Any?? Because the Account ID could come from a JSON ObjectNode, a String key, or a Long from the DB. Instead of handling types, the code just accepted everything.

The Production Crash: While hardening this, a colleague added new parameters to the middle of the function. Because the call was positional and the type was Any?, the compiler stayed silent. In production, a String was passed into a BIGINT slot.
The Lesson: If your API doesn't enforce types at the front door, "garbage" will eventually reach your database. In our case, it led to a PSQLException that I had to debug over the phone while on a road trip.

When you can’t refactor the entire app (because the system ID is an Int but the DB expects a Long), you have to build a "Gatekeeper."

We couldn't fix Team A's architectural choices overnight. Instead, we implemented Surgical Casting at the service boundary:

// Forcing the contract before it touches the DAO
val boundId = when (accountId) {
    is Number -> accountId.toLong()
    is String -> accountId.toLongOrNull() 
    else -> throw SecurityException("Unauthorized ID format")
}
Enter fullscreen mode Exit fullscreen mode

This ensured that no matter how messy the "Outsourced Relay" became, the core database operations were shielded by strict type validation.


Hardening a legacy system under regulatory pressure isn’t a sprint — it’s a negotiation. You’re negotiating with old code you didn’t write, with business stakeholders who fear lawsuits and with a regulator who expects a remediation report with actual closure dates. What the pentest gave us, ironically, was leverage. For the first time, the risk wasn’t theoretical. It was a PDF with our customer data in it. That changes conversations. We didn’t fix everything at once — but we fixed what mattered, documented it properly, and passed the next audit. Sometimes that’s exactly what winning looks like.

Top comments (0)