diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 862ffc34..87de3c29 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -13,8 +13,10 @@ type SQLQuery struct { CountArgs []any } +const maxSQLPageSize = 10_000 + func buildVehicleListSQL(query url.Values) SQLQuery { - limit := parsePositive(query.Get("limit"), 20) + limit := parseSQLPageSize(query.Get("limit"), 20) offset := parsePositive(query.Get("offset"), 0) canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols)) args := []any{} @@ -65,7 +67,7 @@ func buildVehicleListSQL(query url.Values) SQLQuery { } func buildVehicleCoverageSQL(query url.Values) SQLQuery { - limit := parsePositive(query.Get("limit"), 20) + limit := parseSQLPageSize(query.Get("limit"), 20) offset := parsePositive(query.Get("offset"), 0) canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols)) args := []any{} @@ -288,7 +290,7 @@ func archiveMissingFieldPredicate(field string) string { } func buildRealtimeLocationSQL(query url.Values) SQLQuery { - limit := parsePositive(query.Get("limit"), 20) + limit := parseSQLPageSize(query.Get("limit"), 20) offset := parsePositive(query.Get("offset"), 0) args := []any{} where := []string{"1 = 1"} @@ -316,7 +318,7 @@ func buildRealtimeLocationSQL(query url.Values) SQLQuery { } func buildVehicleRealtimeSQL(query url.Values) SQLQuery { - limit := parsePositive(query.Get("limit"), 20) + limit := parseSQLPageSize(query.Get("limit"), 20) offset := parsePositive(query.Get("offset"), 0) canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols)) args := []any{} @@ -415,7 +417,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery { } func buildDailyMileageSQL(query url.Values) SQLQuery { - limit := parsePositive(query.Get("limit"), 20) + limit := parseSQLPageSize(query.Get("limit"), 20) offset := parsePositive(query.Get("offset"), 0) args := []any{} where := []string{"1 = 1"} @@ -671,11 +673,22 @@ func appendVINListFilter(where []string, args []any, column string, raw string) } func buildLimitOffset(query url.Values) (int, int) { - return parsePositive(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0) + return parseSQLPageSize(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0) } func parseLimitOffset(rawLimit, rawOffset string) (int, int) { - return parsePositive(rawLimit, 20), parsePositive(rawOffset, 0) + return parseSQLPageSize(rawLimit, 20), parsePositive(rawOffset, 0) +} + +func parseSQLPageSize(raw string, fallback int) int { + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return fallback + } + if value > maxSQLPageSize { + return maxSQLPageSize + } + return value } func mustInt(value string) int { diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index d53efbc7..6ca1ec6c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -319,6 +319,29 @@ func TestBuildDailyMileageSQLCanMatchStatisticsVehicleDayScope(t *testing.T) { } } +func TestMySQLListBuildersClampUntrustedPageSizes(t *testing.T) { + query := url.Values{"limit": {"1000000000"}, "offset": {"7"}} + builders := map[string]SQLQuery{ + "vehicles": buildVehicleListSQL(query), + "vehicle coverage": buildVehicleCoverageSQL(query), + "realtime location": buildRealtimeLocationSQL(query), + "vehicle realtime": buildVehicleRealtimeSQL(query), + "daily mileage": buildDailyMileageSQL(query), + } + for name, built := range builders { + if len(built.Args) < 2 || built.Args[len(built.Args)-2] != maxSQLPageSize || built.Args[len(built.Args)-1] != 7 { + t.Fatalf("%s pagination was not clamped: %#v", name, built.Args) + } + } + limit, offset := buildLimitOffset(query) + if limit != maxSQLPageSize || offset != 7 { + t.Fatalf("response pagination metadata must match SQL: limit=%d offset=%d", limit, offset) + } + if limit, _ := parseLimitOffset("0", "0"); limit != 20 { + t.Fatalf("zero page size must use the safe default, got %d", limit) + } +} + func TestMileageQueriesRespectEnabledProtocolPriority(t *testing.T) { query := url.Values{ "protocols": {"JT808,GB32960"},