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:

  • Bad input reached the database. Nothing stopped the negative value before it hit Postgres.
  • The error was shown to the user. A raw exception leaked out instead of being hidden.

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:

  • Table names tell them what you store.
  • Column names tell them what fields exist.
  • Framework details tell them what stack you run, which narrows down known vulnerabilities to try next.

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:

  • Treat every value from the client as untrusted, including numbers. A number can be negative, zero, or absurdly large.
  • Clamp ranges explicitly. Page numbers have a floor. Page sizes have a ceiling.
  • Use a number type wide enough that your arithmetic can’t overflow.
  • Add one global error handler so no raw exception ever reaches a user.
  • Make sure error responses carry a generic message for the user and full detail only in your logs.
  • Write a test that sends a negative page, a zero page, and a huge page, and asserts you get a clean response, not a 500.

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.

Related blog posts