Harden Proxmox numeric parsing bounds

This commit is contained in:
rcourtman 2026-03-29 13:44:46 +01:00
parent 3c0707751b
commit 72559f737b
2 changed files with 147 additions and 17 deletions

View file

@ -46,7 +46,11 @@ func (f *FlexInt) UnmarshalJSON(data []byte) error {
// Try as float (handles cpulimit like 1.5)
var fl float64
if err := json.Unmarshal(data, &fl); err == nil {
*f = FlexInt(int(fl))
parsed, err := floatToIntTrunc(fl)
if err != nil {
return err
}
*f = FlexInt(parsed)
return nil
}
@ -57,16 +61,72 @@ func (f *FlexInt) UnmarshalJSON(data []byte) error {
}
// Parse string to float first (handles "1.5" format from cpulimit)
floatVal, err := strconv.ParseFloat(s, 64)
parsed, err := parseFlexibleIntString(s)
if err != nil {
return err
}
// Convert to int
*f = FlexInt(int(floatVal))
*f = FlexInt(parsed)
return nil
}
func parseFlexibleIntString(raw string) (int, error) {
if strings.ContainsAny(raw, ".eE") {
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, err
}
return floatToIntTrunc(parsed)
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, err
}
return int64ToInt(parsed)
}
func int64ToInt(v int64) (int, error) {
maxInt := int64(^uint(0) >> 1)
minInt := -maxInt - 1
if v > maxInt || v < minInt {
return 0, fmt.Errorf("integer %d exceeds int range", v)
}
return int(v), nil
}
func floatToIntTrunc(v float64) (int, error) {
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0, fmt.Errorf("non-finite float %v cannot be converted to int", v)
}
limit := math.Exp2(float64(strconv.IntSize - 1))
if v >= limit || v < -limit {
return 0, fmt.Errorf("float %v exceeds int range", v)
}
return int(v), nil
}
func looksLikeNumericLiteral(raw string) bool {
hasDigit := false
var prev rune
for i, r := range raw {
switch {
case unicode.IsDigit(r):
hasDigit = true
case r == '.' || r == 'e' || r == 'E':
case r == '+' || r == '-':
if i != 0 && prev != 'e' && prev != 'E' {
return false
}
default:
return false
}
prev = r
}
return hasDigit
}
func coerceUint64(field string, value interface{}) (uint64, error) {
switch v := value.(type) {
case nil:
@ -1563,10 +1623,7 @@ func parseUint64Flexible(value interface{}) (uint64, error) {
}
return uint64(v), nil
case float64:
if v < 0 {
return 0, nil
}
return uint64(v), nil
return floatToUint64Trunc(v)
case json.Number:
return parseUint64Flexible(v.String())
case string:
@ -1586,10 +1643,7 @@ func parseUint64Flexible(value interface{}) (uint64, error) {
if err != nil {
return 0, err
}
if f < 0 {
return 0, nil
}
return uint64(f), nil
return floatToUint64Trunc(f)
}
u, err := strconv.ParseUint(s, 10, 64)
if err != nil {
@ -1601,6 +1655,19 @@ func parseUint64Flexible(value interface{}) (uint64, error) {
}
}
func floatToUint64Trunc(v float64) (uint64, error) {
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0, fmt.Errorf("non-finite float %v cannot be converted to uint64", v)
}
if v < 0 {
return 0, nil
}
if v >= math.Exp2(64) {
return 0, fmt.Errorf("float %v exceeds uint64 range", v)
}
return uint64(v), nil
}
type VMIPAddress struct {
Address string `json:"ip-address"`
Prefix int `json:"prefix"`
@ -2013,7 +2080,12 @@ func (d *Disk) UnmarshalJSON(data []byte) error {
// Handle wearout field which can be int, string ("N/A"), or null
switch v := aux.Wearout.(type) {
case float64:
d.Wearout = int(v)
parsed, err := floatToIntTrunc(v)
if err != nil {
d.Wearout = wearoutUnknown
break
}
d.Wearout = parsed
case string:
// Proxmox returns "N/A" or empty string for HDDs/RAID controllers.
// Some controllers also return numeric wearout values as strings, so try to parse them.
@ -2030,7 +2102,12 @@ func (d *Disk) UnmarshalJSON(data []byte) error {
// Handle rpm field which can be number, string descriptor ("SSD"/"N/A"), or null
switch v := aux.RPM.(type) {
case float64:
d.RPM = int(v)
parsed, err := floatToIntTrunc(v)
if err != nil {
d.RPM = 0
break
}
d.RPM = parsed
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" || strings.EqualFold(trimmed, "ssd") || strings.EqualFold(trimmed, "n/a") {
@ -2080,11 +2157,13 @@ func parseWearoutValue(raw string) int {
return parsed
}
if parsed, err := strconv.ParseFloat(cleaned, 64); err == nil {
if parsed <= 0 {
return int(parsed)
if looksLikeNumericLiteral(cleaned) {
if parsed, err := strconv.ParseFloat(cleaned, 64); err == nil {
if value, convErr := floatToIntTrunc(parsed); convErr == nil {
return value
}
}
return int(parsed)
return wearoutUnknown
}
var digits strings.Builder

View file

@ -7,11 +7,23 @@ import (
"math"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
func platformIntOverflowString() string {
if strconv.IntSize == 32 {
return "2147483648"
}
return "9223372036854775808"
}
func platformIntOverflowFloat() float64 {
return math.Exp2(float64(strconv.IntSize - 1))
}
func TestDiskUnmarshalWearout(t *testing.T) {
tests := []struct {
name string
@ -180,6 +192,20 @@ func TestDiskUnmarshalJSON_UnexpectedRPMType(t *testing.T) {
}
}
func TestDiskUnmarshalJSON_OutOfRangeNumericFieldsNormalize(t *testing.T) {
payload := fmt.Sprintf(`{"devpath":"/dev/sda","model":"Example","serial":"123","type":"hdd","health":"OK","wearout":%.0f,"size":1000,"rpm":%.0f}`, platformIntOverflowFloat(), platformIntOverflowFloat())
var disk Disk
if err := json.Unmarshal([]byte(payload), &disk); err != nil {
t.Fatalf("unexpected error unmarshalling disk: %v", err)
}
if disk.Wearout != wearoutUnknown {
t.Fatalf("wearout: got %d, want %d (unknown)", disk.Wearout, wearoutUnknown)
}
if disk.RPM != 0 {
t.Fatalf("rpm: got %d, want 0", disk.RPM)
}
}
func TestVMFileSystemUnmarshalFlexibleNumbers(t *testing.T) {
t.Run("accepts numeric values", func(t *testing.T) {
payload := `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":8589934592,"used-bytes":3221225472,"disk":[{"dev":"/dev/vtbd0p2"}]}`
@ -541,6 +567,26 @@ func TestFlexIntUnmarshalJSON(t *testing.T) {
}
}
func TestFlexIntUnmarshalJSONRejectsNonFiniteAndOverflow(t *testing.T) {
tests := []struct {
name string
input string
}{
{name: "string NaN", input: `"NaN"`},
{name: "string positive infinity", input: `"Infinity"`},
{name: "string overflow", input: fmt.Sprintf(`"%s"`, platformIntOverflowString())},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var f FlexInt
if err := f.UnmarshalJSON([]byte(tc.input)); err == nil {
t.Fatalf("expected error for %s, got nil", tc.input)
}
})
}
}
func TestParseUint64Flexible(t *testing.T) {
tests := []struct {
name string
@ -565,6 +611,9 @@ func TestParseUint64Flexible(t *testing.T) {
{name: "float64 truncates down", value: float64(9.9), want: 9},
{name: "float64 negative", value: float64(-1.5), want: 0},
{name: "float64 zero", value: float64(0.0), want: 0},
{name: "float64 NaN", value: math.NaN(), wantErr: true},
{name: "float64 positive infinity", value: math.Inf(1), wantErr: true},
{name: "float64 out of range", value: math.Exp2(64), wantErr: true},
// json.Number
{name: "json.Number integer", value: json.Number("99"), want: 99},
{name: "json.Number float", value: json.Number("3.14"), want: 3},
@ -934,6 +983,8 @@ func TestParseWearoutValue(t *testing.T) {
{name: "float value truncated", raw: "81.5", want: 81},
{name: "float zero decimal", raw: "90.0", want: 90},
{name: "float high precision", raw: "75.999", want: 75},
{name: "float non-finite string", raw: "NaN", want: wearoutUnknown},
{name: "float overflow string", raw: "9.223372036854776e18", want: wearoutUnknown},
{name: "negative float", raw: "-5.5", want: -5},
{name: "float zero exactly", raw: "0.0", want: 0},
{name: "float negative zero", raw: "-0.0", want: 0},