From 72559f737bd56bf119f352dccdfd6addb6cd8a37 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 29 Mar 2026 13:44:46 +0100 Subject: [PATCH] Harden Proxmox numeric parsing bounds --- pkg/proxmox/client.go | 113 +++++++++++++++++++++++++++++++------ pkg/proxmox/client_test.go | 51 +++++++++++++++++ 2 files changed, 147 insertions(+), 17 deletions(-) diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 53c4c0400..e3f90e8d4 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -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 diff --git a/pkg/proxmox/client_test.go b/pkg/proxmox/client_test.go index 0f4b50f89..f4583d4aa 100644 --- a/pkg/proxmox/client_test.go +++ b/pkg/proxmox/client_test.go @@ -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},