FSIBLOG

How One Negative Number Leaked Our Entire SQL Schema

How One Negative Number Leaked Our Entire SQL Schema

While reviewing our own FSIBlog, we found a pagination endpoint that returned a 500 error whenever it received a negative page number. Worse, that error response didn’t just say “something broke.” It handed back our raw PostgreSQL query, the table names, the column names, and the framework internals that built the query. No SQL injection. No exploit chain. Just a number that nobody checked before passing it to the database.

If you’re early in your development career, this is one of the most useful bugs you can study, because it’s so easy to write and so easy to fix once you see it.

What Actually Happened

Our endpoint builds a paginated query. Page size and page number come from the user, and the database offset is calculated from them. The query itself was parameterized correctly, so it wasn’t vulnerable to injection. The problem was simpler: the offset was never validated.

When a user requested a negative page, the calculated offset went negative too. PostgreSQL has a hard rule here, an offset can never drop below zero. So the database threw an exception. And because we had no global error handler, that exception traveled all the way back to the user as a raw 500 response with the full query attached.

Two things went wrong, and they’re worth separating because they need separate fixes:

Either one alone is a problem. Together they turn a typo into a schema disclosure.

Why Leaking The Error Matters

It’s tempting to shrug at a verbose error message. The data wasn’t exposed, after all. But for an attacker, a leaked query is a free map:

You’ve done their reconnaissance for them. The rule is simple: users should never see your internal error details. They get a generic “something went wrong.” Your logs get the full story.

The fix SQL Schema

We fixed both problems independently.

First, validate and clamp the pagination inputs before they touch the query. Page number can’t go below zero. Page size is capped at a sensible maximum, which also stops someone from requesting a million rows at once. The offset is calculated using a wider number type so the multiplication can’t overflow.

Second, add a global error handler. If anything unexpected throws, anywhere in the application, the user gets a clean generic message and the real exception goes only to our server logs.

The validation step is the gate that stops the original bug. The error handler is the safety net that catches every future version of it, including ones we haven’t thought of yet.

The Code

The validation lives in the data access method:

int safePage     = Math.max(page, 0);                       // never below zero
int safePageSize = Math.min(Math.max(pageSize, 1), 100);    // clamp to 1..100
long offset      = (long) safePage * safePageSize;          // long math, no overflow

The error handler lives once, at the application level:

@RestControllerAdvice
public class ApiExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleAny(Exception ex) {
        log.error("Unhandled exception", ex);   // full detail stays server-side
        return ResponseEntity.status(500).body(
            new ApiError(500, "Internal Server Error",
                         "Something went wrong. Please try again later."));
    }
}

A Checklist So This Never Ships Again

Run through this whenever user input feeds a query:

Why We’re Sharing This

It’s easy to only publish the wins. But the bugs that teach the most are the small, ordinary ones that almost everyone writes at least once. A missing bounds check isn’t exotic. It’s the kind of thing that slips through when you’re focused on the happy path and assume the input will behave.

If reading this saves one person from leaking their schema over a minus sign, it was worth writing down.

Exit mobile version