From 70243fac76b62b75e9feca8d4829056afa6fc4ce Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 11 Jun 2026 01:06:52 -0700 Subject: [PATCH] fix(api): reject NaN/Inf in min-savings query params parseMinSavingsParam used strconv.ParseFloat, which accepts "NaN", "Inf", "+Inf" and "Infinity" (case-insensitively), and only rejected v < 0, which is false for NaN. A NaN min_savings_usd bound into "monthly_savings >= $n" excludes every row with HTTP 200, and a NaN min_savings_pct is a silent no-op floor. Reject non-finite values at the input boundary with a 400 client error instead, per the fail-loud policy. Extends TestParseMinSavingsParam with NaN, +Inf, -Inf, bare/word infinity, and pct-path cases; the new cases fail on the pre-fix code. Closes #1183 --- internal/api/validation.go | 12 +++++++----- internal/api/validation_test.go | 13 +++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/api/validation.go b/internal/api/validation.go index fd96890ba..66593eb6a 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -4,6 +4,7 @@ package api import ( "encoding/base64" "fmt" + "math" "net/mail" "regexp" "strconv" @@ -611,9 +612,10 @@ func decodeBase64Password(encoded string) (string, error) { // parseMinSavingsParam parses a numeric savings-floor query parameter. // Returns (0, nil) when the parameter is absent or empty (no floor). -// Returns 400 when the value is present but not a valid non-negative -// integer or float. Fractional values are allowed (e.g. "12.5") since -// savings floors can be sub-dollar amounts. +// Returns 400 when the value is present but not a finite non-negative +// integer or float (NaN and +/-Inf are rejected: a NaN floor silently +// disables or inverts the filter downstream). Fractional values are +// allowed (e.g. "12.5") since savings floors can be sub-dollar amounts. // // paramName is included in the error message so callers can distinguish // min_savings_usd vs min_savings_pct errors in client logs. @@ -623,8 +625,8 @@ func parseMinSavingsParam(raw, paramName string) (float64, error) { return 0, nil } v, err := strconv.ParseFloat(raw, 64) - if err != nil { - return 0, NewClientError(400, fmt.Sprintf("%s must be a non-negative number", paramName)) + if err != nil || math.IsNaN(v) || math.IsInf(v, 0) { + return 0, NewClientError(400, fmt.Sprintf("%s must be a finite non-negative number", paramName)) } if v < 0 { return 0, NewClientError(400, fmt.Sprintf("%s must be non-negative", paramName)) diff --git a/internal/api/validation_test.go b/internal/api/validation_test.go index d3db0a33f..338496559 100644 --- a/internal/api/validation_test.go +++ b/internal/api/validation_test.go @@ -531,6 +531,19 @@ func TestParseMinSavingsParam(t *testing.T) { {"non-numeric word", "thirty", "min_savings_usd", 0, true}, {"negative value", "-5", "min_savings_usd", 0, true}, {"mixed string", "30abc", "min_savings_usd", 0, true}, + + // Non-finite inputs (COR-09 regression, issue #1183): + // strconv.ParseFloat accepts these case-insensitively, but a NaN + // floor silently excludes all rows in SQL (monthly_savings >= NaN) + // or applies no pct floor; +Inf excludes everything. All must 400. + {"NaN", "NaN", "min_savings_usd", 0, true}, + {"lowercase nan", "nan", "min_savings_usd", 0, true}, + {"positive infinity", "+Inf", "min_savings_usd", 0, true}, + {"bare infinity", "Inf", "min_savings_usd", 0, true}, + {"infinity word", "Infinity", "min_savings_usd", 0, true}, + {"negative infinity", "-Inf", "min_savings_usd", 0, true}, + {"pct NaN", "NaN", "min_savings_pct", 0, true}, + {"pct infinity", "Inf", "min_savings_pct", 0, true}, } for _, tc := range cases {