Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions internal/api/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package api
import (
"encoding/base64"
"fmt"
"math"
"net/mail"
"regexp"
"strconv"
Expand Down Expand Up @@ -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.
Expand All @@ -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))
Expand Down
13 changes: 13 additions & 0 deletions internal/api/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading