From b735d01b2c3fe57e87724515c353bf0b36902862 Mon Sep 17 00:00:00 2001 From: Peter Broadhurst Date: Thu, 1 Oct 2026 13:25:53 -0400 Subject: [PATCH 1/3] Handle large numbers in filter values Signed-off-by: Peter Broadhurst --- pkg/ffapi/restfilter_json.go | 31 ++++++++++++++++++++++++---- pkg/ffapi/restfilter_json_test.go | 34 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/pkg/ffapi/restfilter_json.go b/pkg/ffapi/restfilter_json.go index 63a8348..1bd38da 100644 --- a/pkg/ffapi/restfilter_json.go +++ b/pkg/ffapi/restfilter_json.go @@ -17,11 +17,12 @@ package ffapi import ( + "bytes" "context" "database/sql/driver" "encoding/json" "fmt" - "strconv" + "math/big" "strings" "github.com/hyperledger-firefly/common/pkg/i18n" @@ -146,13 +147,21 @@ func SkipFieldValidation() *JSONBuildFilterOpt { func (js *SimpleFilterValue) UnmarshalJSON(b []byte) error { var v interface{} - err := json.Unmarshal(b, &v) + d := json.NewDecoder(bytes.NewReader(b)) + d.UseNumber() + err := d.Decode(&v) if err != nil { return err } switch vi := v.(type) { - case float64: - *js = (SimpleFilterValue)(strconv.FormatFloat(vi, 'f', -1, 64)) + case json.Number: + // Parsed exactly (rather than as a float64) so large values such as a uint256 are not + // rounded, and bounded to 256 bits so an exponent cannot expand into an enormous string + r, ok := new(big.Rat).SetString(vi.String()) + if !ok || r.Num().BitLen() > maxFilterNumberBits || r.Denom().BitLen() > maxFilterNumberBits { + return i18n.NewError(context.Background(), i18n.MsgJSONQueryValueUnsupported, string(b)) + } + *js = (SimpleFilterValue)(formatDecimal(r)) return nil case string: *js = (SimpleFilterValue)(vi) @@ -165,6 +174,20 @@ func (js *SimpleFilterValue) UnmarshalJSON(b []byte) error { } } +const maxFilterNumberBits = 256 + +// formatDecimal renders r, which was parsed from a decimal literal, as a plain decimal with +// no exponent and no trailing zeros (so "5.0" and "1e3" arrive as "5" and "1000") +func formatDecimal(r *big.Rat) string { + if r.IsInt() { + return r.Num().String() + } + // The denominator is 2^a*5^b, which terminates within max(a,b) places - and BitLen + // is always at least that + s := r.FloatString(r.Denom().BitLen()) + return strings.TrimRight(s, "0") +} + func (js SimpleFilterValue) String() string { return (string)(js) } diff --git a/pkg/ffapi/restfilter_json_test.go b/pkg/ffapi/restfilter_json_test.go index c13097f..b6ee58c 100644 --- a/pkg/ffapi/restfilter_json_test.go +++ b/pkg/ffapi/restfilter_json_test.go @@ -777,3 +777,37 @@ func TestBuildQueryAndFail(t *testing.T) { _, err = qf.BuildFilter(context.Background(), TestQueryFactory) assert.Regexp(t, "FF00142.*color", err) } + +func TestSimpleFilterValueNumbers(t *testing.T) { + for in, expected := range map[string]string{ + `9007199254740993`: "9007199254740993", // 2^53+1, not representable as a float64 + `115792089237316195423570985008687907853269984665640564039457584007913129639935`: "115792089237316195423570985008687907853269984665640564039457584007913129639935", // 2^256-1 + `-57896044618658097711785492504343953926634992332820282019728792003956564819968`: "-57896044618658097711785492504343953926634992332820282019728792003956564819968", // -2^255 + `-12345678901234567890123`: "-12345678901234567890123", + `42`: "42", + `5.0`: "5", + `1e3`: "1000", + `1.5`: "1.5", + `-0.000123`: "-0.000123", + `1.25e-2`: "0.0125", + `0.0009765625`: "0.0009765625", // 1/1024 - more places than denominator digits + `9007199254740993e0`: "9007199254740993", + `123456789012345678901.5`: "123456789012345678901.5", + `1e30`: "1000000000000000000000000000000", + `true`: "true", + `"str"`: "str", + } { + var js SimpleFilterValue + err := json.Unmarshal([]byte(in), &js) + assert.NoError(t, err, in) + assert.Equal(t, expected, js.String(), in) + } + + var js SimpleFilterValue + err := js.UnmarshalJSON([]byte(`115792089237316195423570985008687907853269984665640564039457584007913129639936`)) // 2^256 + assert.Regexp(t, "FF00241", err) + err = js.UnmarshalJSON([]byte(`1e999999`)) + assert.Regexp(t, "FF00241", err) + err = js.UnmarshalJSON([]byte(`1e-400`)) + assert.Regexp(t, "FF00241", err) +} From f36a146d84b020448de18efda158106d7d42ade1 Mon Sep 17 00:00:00 2001 From: Peter Broadhurst Date: Thu, 1 Oct 2026 14:55:20 -0400 Subject: [PATCH 2/3] Bounds checking on filter strings before big.Rat parsing Signed-off-by: Peter Broadhurst --- pkg/ffapi/restfilter_json.go | 28 ++++++++++++++++++++++++++-- pkg/ffapi/restfilter_json_test.go | 9 +++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/pkg/ffapi/restfilter_json.go b/pkg/ffapi/restfilter_json.go index 1bd38da..3132906 100644 --- a/pkg/ffapi/restfilter_json.go +++ b/pkg/ffapi/restfilter_json.go @@ -23,6 +23,7 @@ import ( "encoding/json" "fmt" "math/big" + "strconv" "strings" "github.com/hyperledger-firefly/common/pkg/i18n" @@ -157,7 +158,10 @@ func (js *SimpleFilterValue) UnmarshalJSON(b []byte) error { case json.Number: // Parsed exactly (rather than as a float64) so large values such as a uint256 are not // rounded, and bounded to 256 bits so an exponent cannot expand into an enormous string - r, ok := new(big.Rat).SetString(vi.String()) + if !boundedLiteral(vi.String()) { + return i18n.NewError(context.Background(), i18n.MsgJSONQueryValueUnsupported, string(b)) + } + r, ok := new(big.Rat).SetString(vi.String()) // #nosec G113 - length and exponent bounded by boundedLiteral above if !ok || r.Num().BitLen() > maxFilterNumberBits || r.Denom().BitLen() > maxFilterNumberBits { return i18n.NewError(context.Background(), i18n.MsgJSONQueryValueUnsupported, string(b)) } @@ -174,7 +178,27 @@ func (js *SimpleFilterValue) UnmarshalJSON(b []byte) error { } } -const maxFilterNumberBits = 256 +const ( + maxFilterNumberBits = 256 + maxFilterNumberDigits = 78 // decimal digits in 2^256 + maxFilterNumberLength = maxFilterNumberDigits + maxFilterNumberBits + 8 // integer digits, decimal places (2^-255 needs 255), sign, point and exponent +) + +// boundedLiteral reports whether s, a valid JSON number, is short enough and has a small enough +// exponent that the value might fit in maxFilterNumberBits. +// Check before parsing to big.Rat (per gosec G113) +func boundedLiteral(s string) bool { + if len(s) > maxFilterNumberLength { + return false + } + i := strings.IndexAny(s, "eE") + if i < 0 { + return true + } + exp, err := strconv.Atoi(s[i+1:]) + limit := maxFilterNumberLength + maxFilterNumberDigits + return err == nil && exp >= -limit && exp <= limit +} // formatDecimal renders r, which was parsed from a decimal literal, as a plain decimal with // no exponent and no trailing zeros (so "5.0" and "1e3" arrive as "5" and "1000") diff --git a/pkg/ffapi/restfilter_json_test.go b/pkg/ffapi/restfilter_json_test.go index b6ee58c..5520989 100644 --- a/pkg/ffapi/restfilter_json_test.go +++ b/pkg/ffapi/restfilter_json_test.go @@ -21,6 +21,7 @@ import ( "database/sql/driver" "encoding/json" "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -794,6 +795,8 @@ func TestSimpleFilterValueNumbers(t *testing.T) { `9007199254740993e0`: "9007199254740993", `123456789012345678901.5`: "123456789012345678901.5", `1e30`: "1000000000000000000000000000000", + // 2^-255, the most decimal places a value within 256 bits can need + `0.000000000000000000000000000000000000000000000000000000000000000000000000000017272337110188889250772703725600799142232000728872562770047406940337183606324854115943015006944576453121094587892299327193990197893663893387306007554116149549372494220733642578125`: "0.000000000000000000000000000000000000000000000000000000000000000000000000000017272337110188889250772703725600799142232000728872562770047406940337183606324854115943015006944576453121094587892299327193990197893663893387306007554116149549372494220733642578125", `true`: "true", `"str"`: "str", } { @@ -808,6 +811,12 @@ func TestSimpleFilterValueNumbers(t *testing.T) { assert.Regexp(t, "FF00241", err) err = js.UnmarshalJSON([]byte(`1e999999`)) assert.Regexp(t, "FF00241", err) + err = js.UnmarshalJSON([]byte(`1e99999999999999999999`)) // exponent overflows an int + assert.Regexp(t, "FF00241", err) + err = js.UnmarshalJSON([]byte(`1e80`)) // within the exponent bound, but over 256 bits + assert.Regexp(t, "FF00241", err) + err = js.UnmarshalJSON([]byte(strings.Repeat("9", 1_000_000))) // too long to parse + assert.Regexp(t, "FF00241", err) err = js.UnmarshalJSON([]byte(`1e-400`)) assert.Regexp(t, "FF00241", err) } From ebc2f5fbdb4a125dfd6bce3f47ecf7714ba1b79c Mon Sep 17 00:00:00 2001 From: Peter Broadhurst Date: Thu, 1 Oct 2026 14:47:41 -0400 Subject: [PATCH 3/3] Add error mapper to router Signed-off-by: Peter Broadhurst --- pkg/ffapi/apiserver.go | 2 + pkg/ffapi/handler.go | 4 ++ pkg/ffapi/handler_test.go | 81 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/pkg/ffapi/apiserver.go b/pkg/ffapi/apiserver.go index c349cc7..0a95920 100644 --- a/pkg/ffapi/apiserver.go +++ b/pkg/ffapi/apiserver.go @@ -87,6 +87,7 @@ type APIServerOptions[T any] struct { VersionedAPIs *VersionedAPIs MonitoringRoutes []*Route EnrichRequest func(r *APIRequest) (T, error) + MapError func(ctx context.Context, err error) error // optional: applied to every error before its status is derived Description string APIConfig config.Section MonitoringConfig config.Section @@ -304,6 +305,7 @@ func (as *apiServer[T]) handlerFactory(logLevel logrus.Level) *HandlerFactory { SupportFieldRedaction: as.SupportFieldRedaction, AlwaysPaginate: as.alwaysPaginate, HandleYAML: as.handleYAML, + MapError: as.MapError, } hf.SetAPIEntryLoggingLevel(logLevel) return hf diff --git a/pkg/ffapi/handler.go b/pkg/ffapi/handler.go index 8c3b40f..e2bdc66 100644 --- a/pkg/ffapi/handler.go +++ b/pkg/ffapi/handler.go @@ -78,6 +78,7 @@ type HandlerFactory struct { SupportFieldRedaction bool BasePath string BasePathParams []*PathParam + MapError func(ctx context.Context, err error) error // optional error mapping function, runs before status code extraction/setting apiEntryLoggingLevel logrus.Level // the log level at which entry/exit logging is enabled at all (does not affect trace logging) } @@ -396,6 +397,9 @@ func (hs *HandlerFactory) APIWrapper(handler HandlerFunction) http.HandlerFunc { status, err := handler(res, req) durationMS := float64(time.Since(startTime)) / float64(time.Millisecond) if err != nil { + if hs.MapError != nil { + err = hs.MapError(ctx, err) + } if ffe, ok := (interface{}(err)).(i18n.FFError); ok { if logrus.IsLevelEnabled(logrus.DebugLevel) { l.Debugf("%s:\n%s", ffe.Error(), ffe.StackTrace()) diff --git a/pkg/ffapi/handler_test.go b/pkg/ffapi/handler_test.go index 90ce1a4..4fced88 100644 --- a/pkg/ffapi/handler_test.go +++ b/pkg/ffapi/handler_test.go @@ -37,6 +37,7 @@ import ( "github.com/gorilla/mux" "github.com/hyperledger-firefly/common/pkg/config" "github.com/hyperledger-firefly/common/pkg/httpserver" + "github.com/hyperledger-firefly/common/pkg/i18n" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) @@ -749,3 +750,83 @@ func TestGetFormEmptyValue(t *testing.T) { _, err := hs.getFormParams(req) require.NoError(t, err) } + +func TestMapErrorAppliedToEveryError(t *testing.T) { + driverErr := fmt.Errorf("driver: check constraint violated") + hs := newTestHandlerFactory("", nil) + var mapped []error + hs.MapError = func(ctx context.Context, err error) error { + assert.NotNil(t, ctx) + mapped = append(mapped, err) + if err == driverErr { + return i18n.NewError(ctx, i18n.MsgUnknownFieldValue, "thing", "x") // 400 + } + return err + } + jsonRoute := &Route{ + Name: "json", + Path: "/json", + Method: http.MethodPost, + JSONInputValue: func() interface{} { return make(map[string]interface{}) }, + JSONOutputValue: func() interface{} { return make(map[string]interface{}) }, + JSONOutputCodes: []int{200}, + JSONHandler: func(r *APIRequest) (output interface{}, err error) { + return nil, driverErr + }, + } + streamRoute := &Route{ + Name: "stream", + Path: "/stream", + Method: http.MethodGet, + JSONInputValue: nil, + JSONOutputCodes: []int{200}, + StreamHandler: func(r *APIRequest) (output io.ReadCloser, err error) { + return nil, driverErr + }, + } + + for _, tc := range []struct { + name string + route *Route + req *http.Request + status int + match string + }{ + {"json handler", jsonRoute, httptest.NewRequest(http.MethodPost, "/json", strings.NewReader(`{}`)), 400, "FF00111"}, + {"stream handler", streamRoute, httptest.NewRequest(http.MethodGet, "/stream", nil), 400, "FF00111"}, + {"input decode", jsonRoute, httptest.NewRequest(http.MethodPost, "/json", strings.NewReader(`{`)), 400, "unexpected EOF"}, + } { + t.Run(tc.name, func(t *testing.T) { + mapped = nil + tc.req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + hs.RouteHandler(tc.route)(res, tc.req) + assert.Equal(t, tc.status, res.Code) + assert.Len(t, mapped, 1) + var resJSON map[string]interface{} + require.NoError(t, json.NewDecoder(res.Body).Decode(&resJSON)) + assert.Regexp(t, tc.match, resJSON["error"]) + }) + } +} + +func TestMapErrorNotCalledOnSuccess(t *testing.T) { + hs := newTestHandlerFactory("", nil) + hs.MapError = func(ctx context.Context, err error) error { + assert.Fail(t, "MapError called without an error") + return err + } + res := httptest.NewRecorder() + hs.RouteHandler(&Route{ + Name: "json", + Path: "/json", + Method: http.MethodGet, + JSONInputValue: nil, + JSONOutputValue: func() interface{} { return make(map[string]interface{}) }, + JSONOutputCodes: []int{200}, + JSONHandler: func(r *APIRequest) (output interface{}, err error) { + return map[string]interface{}{}, nil + }, + })(res, httptest.NewRequest(http.MethodGet, "/json", nil)) + assert.Equal(t, 200, res.Code) +}