commit ef94b1db08bfe7c4cd8b37fabdd0d4d3f2403949
parent d22e513d6c9de8463f2c9655cf551af9ece329e2
Author: Christian Grothoff <christian@grothoff.org>
Date: Sun, 9 Aug 2026 13:11:41 +0200
db: bound the negative page limit and cap the rows returned per query
'limit' had an upper bound but no lower one, and abs(Int.MIN_VALUE)
silently stays negative, so ?limit=-2147483648 reached Postgres as a
negative LIMIT.
Diffstat:
4 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/API_CHANGES.md b/API_CHANGES.md
@@ -63,7 +63,12 @@ This files contains all the API changes for the current release:
- Add GET /accounts/USERNAME/tokens
- GET /accounts/USERNAME/tokens: add missing row_id field
- GET /withdrawals/WITHDRAWAL_ID: add min_amount
+- Paginated endpoints: reject a 'limit' (or 'delta') below -MAX_PAGE_SIZE,
+ mirroring the pre-existing upper bound
## bank cli
## nexus
+
+- Paginated endpoints: reject a 'limit' (or 'delta') below -MAX_PAGE_SIZE,
+ mirroring the pre-existing upper bound
diff --git a/libeufin-common/src/main/kotlin/db/helpers.kt b/libeufin-common/src/main/kotlin/db/helpers.kt
@@ -29,6 +29,13 @@ import tech.libeufin.common.PageParams
import java.sql.PreparedStatement
import java.sql.ResultSet
import kotlin.math.abs
+import kotlin.math.min
+
+/**
+ * Hard upper bound on the number of records returned by a single
+ * query, regardless of the limit requested by the client.
+ */
+private const val MAX_RECORDS: Long = 50_000
/** Apply paging logic to a sql query */
suspend fun <T> DbPool.page(
@@ -48,7 +55,9 @@ suspend fun <T> DbPool.page(
return serializable(pageQuery) {
args()
bind(params.offset)
- bind(abs(params.limit))
+ // Widen before abs(): abs(Int.MIN_VALUE) is Int.MIN_VALUE, which would
+ // reach Postgres as a negative LIMIT.
+ bind(min(MAX_RECORDS, abs(params.limit.toLong())))
all { map(it) }
}
}
diff --git a/libeufin-common/src/main/kotlin/params.kt b/libeufin-common/src/main/kotlin/params.kt
@@ -80,6 +80,11 @@ data class PageParams(
val limit: Int = new_limit_value ?: legacy_limit_value ?: -20
if (limit == 0) throw paramsMalformed("Param 'limit' must be non-zero")
else if (limit > MAX_PAGE_SIZE) throw paramsMalformed("Param 'limit' must be <= ${MAX_PAGE_SIZE}")
+ // The sign of 'limit' only selects the direction, so the negative
+ // side needs the same bound. Note that abs(Int.MIN_VALUE) silently
+ // returns Int.MIN_VALUE, so an unbounded negative limit would reach
+ // SQL as a negative LIMIT.
+ else if (limit < -MAX_PAGE_SIZE) throw paramsMalformed("Param 'limit' must be >= ${-MAX_PAGE_SIZE}")
val offset: Long = new_offset_value ?: legacy_offset_value ?: if (limit >= 0) 0L else Long.MAX_VALUE
if (offset < 0) throw paramsMalformed("Param 'offset' must be a positive number")
diff --git a/libeufin-common/src/test/kotlin/ParamsTest.kt b/libeufin-common/src/test/kotlin/ParamsTest.kt
@@ -48,6 +48,9 @@ class ParamsTest {
"limit=${MAX_PAGE_SIZE}".check(0, MAX_PAGE_SIZE, 0)
"limit=0".fail("Param 'limit' must be non-zero")
"limit=${MAX_PAGE_SIZE+1}".fail("Param 'limit' must be <= ${MAX_PAGE_SIZE}")
+ "limit=${-MAX_PAGE_SIZE}".check(0, -MAX_PAGE_SIZE, Long.MAX_VALUE)
+ "limit=${-MAX_PAGE_SIZE-1}".fail("Param 'limit' must be >= ${-MAX_PAGE_SIZE}")
+ "limit=${Int.MIN_VALUE}".fail("Param 'limit' must be >= ${-MAX_PAGE_SIZE}")
"offset=-1".fail("Param 'offset' must be a positive number")
"long_poll_ms=${MAX_TIMEOUT_MS+1}&limit=10".check(MAX_TIMEOUT_MS, 10, 0)
"long_poll_ms=1&timeout_ms=2".fail("Param 'timeout_ms' cannot be used with param 'long_poll_ms'")