Skip to content

Commit de16a04

Browse files
authored
Merge pull request #1488 from nyaruka/number_fix
Add range check for numbers to prevent DynamoDB overflow
2 parents 25f89f8 + dbfb89b commit de16a04

File tree

10 files changed

+149
-11
lines changed

10 files changed

+149
-11
lines changed

excellent/base_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,10 @@ func TestEvaluationErrors(t *testing.T) {
439439
{`@(format_datetime("x"))`, `error evaluating @(format_datetime("x")): error calling format_datetime(...): unable to convert "x" to a datetime`},
440440
{`@(format_datetime(3))`, `error evaluating @(format_datetime(3)): error calling format_datetime(...): unable to convert 3 to a datetime`},
441441

442+
// number range errors
443+
{`@(2 ^ 400)`, `error evaluating @(2 ^ 400): number value out of range`},
444+
{`@(1234567890123456789012345678901234567)`, `error evaluating @(1234567890123456789012345678901234567): number 1234567890123456789012345678901234567 is out of range`},
445+
442446
// function call errors
443447
{`@(FOO())`, `error evaluating @(FOO()): foo is not a function`},
444448
{`@(count(1))`, `error evaluating @(count(1)): error calling count(...): value isn't countable`},

excellent/functions/builtin_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,7 @@ func TestFunctions(t *testing.T) {
622622

623623
{"sum", dmy, []types.XValue{xa(xn("1"), xn("2"), xs("3"))}, xn("6")},
624624
{"sum", dmy, []types.XValue{xa()}, xn("0")},
625+
{"sum", dmy, []types.XValue{xa(xn("999999999999999999999999999999999999"), xn("999999999999999999999999999999999999"))}, ERROR}, // overflow
625626
{"sum", dmy, []types.XValue{xs("xx")}, ERROR},
626627
{"sum", dmy, []types.XValue{xa(xn("1"), xn("2"), xs("xx"))}, ERROR},
627628
{"sum", dmy, []types.XValue{xa(xn("1"), xn("2"), ERROR)}, ERROR},

excellent/operators/builtin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ var Divide = numericalBinary(func(env envs.Environment, num1 *types.XNumber, num
100100
// Exponent raises a number to the power of a another number.
101101
//
102102
// @(2 ^ 8) -> 256
103+
// @(2 ^ 400) -> ERROR
103104
//
104105
// @operator exponent "^"
105106
var Exponent = numericalBinary(func(env envs.Environment, num1 *types.XNumber, num2 *types.XNumber) types.XValue {

excellent/operators/builtin_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,14 @@ func TestBinaryOperators(t *testing.T) {
7373
{operators.Exponent, xn("2"), xn("32.000"), xn("4294967296")},
7474
{operators.Exponent, xn("9"), xn("0.5"), xn("3")},
7575
{operators.Exponent, xn("4"), xn("2.5"), xn("32")},
76+
{operators.Exponent, xn("2"), xn("400"), ERROR}, // overflow
7677
{operators.Exponent, ERROR, xi(1), ERROR},
7778
{operators.Exponent, xi(1), ERROR, ERROR},
7879

80+
// overflow cases
81+
{operators.Multiply, xn("9999999999999999999"), xn("9999999999999999999"), ERROR}, // product > 36 digits
82+
{operators.Add, xn("999999999999999999999999999999999999"), xn("999999999999999999999999999999999999"), ERROR}, // sum > 36 significant digits
83+
7984
{operators.LessThan, xi(2), xi(3), types.XBooleanTrue},
8085
{operators.LessThan, xi(3), xi(3), types.XBooleanFalse},
8186
{operators.LessThan, xi(4), xi(3), types.XBooleanFalse},

excellent/tree.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,23 @@ func (x *NumberLiteral) String() string {
476476
return x.Value.Describe()
477477
}
478478

479+
// ErrorLiteral is a literal error value
480+
type ErrorLiteral struct {
481+
Err *types.XError
482+
}
483+
484+
func (x *ErrorLiteral) Evaluate(env envs.Environment, scope *Scope, warnings *Warnings) types.XValue {
485+
return x.Err
486+
}
487+
488+
func (x *ErrorLiteral) Visit(v func(Expression)) {
489+
v(x)
490+
}
491+
492+
func (x *ErrorLiteral) String() string {
493+
return "ERROR"
494+
}
495+
479496
// BooleanLiteral is a literal bool
480497
type BooleanLiteral struct {
481498
Value *types.XBoolean

excellent/types/number.go

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import (
1515
// only parse numbers like 123 or 123.456 or .456
1616
var decimalRegexp = regexp.MustCompile(`^-?(([0-9]+)|([0-9]+\.[0-9]+)|(\.[0-9]+))$`)
1717

18+
// MaxNumberDigits is the maximum number of significant digits in a number
19+
const MaxNumberDigits = 36
20+
1821
func init() {
1922
decimal.MarshalJSONWithoutQuotes = true
2023
}
@@ -33,19 +36,28 @@ type XNumber struct {
3336
native decimal.Decimal
3437
}
3538

36-
// NewXNumber creates a new XNumber
37-
func NewXNumber(value decimal.Decimal) *XNumber {
39+
// newXNumber creates a new XNumber without range checking - for use with known-safe values
40+
func newXNumber(value decimal.Decimal) *XNumber {
3841
return &XNumber{native: value}
3942
}
4043

44+
// NewXNumber creates a new XNumber from the given decimal value, returning an error if the value
45+
// is outside the range of values that can be persisted
46+
func NewXNumber(value decimal.Decimal) XValue {
47+
if err := CheckDecimalRange(value); err != nil {
48+
return NewXErrorf("number value out of range")
49+
}
50+
return newXNumber(value)
51+
}
52+
4153
// NewXNumberFromInt creates a new XNumber from the given int
4254
func NewXNumberFromInt(value int) *XNumber {
43-
return NewXNumber(decimal.New(int64(value), 0))
55+
return newXNumber(decimal.New(int64(value), 0))
4456
}
4557

4658
// NewXNumberFromInt64 creates a new XNumber from the given int
4759
func NewXNumberFromInt64(value int64) *XNumber {
48-
return NewXNumber(decimal.New(value, 0))
60+
return newXNumber(decimal.New(value, 0))
4961
}
5062

5163
// RequireXNumberFromString creates a new XNumber from the given string or panics (used for tests)
@@ -133,9 +145,43 @@ func (x *XNumber) UnmarshalJSON(data []byte) error {
133145
}
134146

135147
// XNumberZero is the zero number value
136-
var XNumberZero = NewXNumber(decimal.Zero)
148+
var XNumberZero = newXNumber(decimal.Zero)
137149
var _ XValue = XNumberZero
138150

151+
// CheckDecimalRange checks that the given decimal value is within the range of values that can be
152+
// persisted to our database. It enforces two constraints:
153+
//
154+
// 1. The number of significant digits (excluding trailing zeros) must not exceed MaxNumberDigits (36).
155+
// 2. The magnitude (adjusted exponent) must be within ±100.
156+
//
157+
// Trailing zeros in the coefficient are not counted as significant, so a number like
158+
// 1234567895171680000000000000000000000000 (15 significant digits) is valid despite having 40 total digits.
159+
func CheckDecimalRange(d decimal.Decimal) error {
160+
if d.IsZero() {
161+
return nil
162+
}
163+
164+
// count significant digits by removing trailing zeros from the coefficient
165+
s := d.Coefficient().String()
166+
s = strings.TrimLeft(s, "-")
167+
s = strings.TrimRight(s, "0")
168+
if len(s) > MaxNumberDigits {
169+
return errors.New("number has too many digits")
170+
}
171+
172+
adjExp := int64(d.Exponent()) + int64(d.NumDigits()) - 1
173+
if adjExp > 100 || adjExp < -100 {
174+
return errors.New("number value is out of permitted range")
175+
}
176+
177+
return nil
178+
}
179+
180+
// NewXNumberFromString parses a number from a string
181+
func NewXNumberFromString(s string) (*XNumber, error) {
182+
return newXNumberFromString(s)
183+
}
184+
139185
// parses a number from a string
140186
func newXNumberFromString(s string) (*XNumber, error) {
141187
s = strings.TrimSpace(s)
@@ -147,7 +193,11 @@ func newXNumberFromString(s string) (*XNumber, error) {
147193
// we can assume anything that matched our regex is parseable
148194
d := decimal.RequireFromString(s)
149195

150-
return NewXNumber(d), nil
196+
if err := CheckDecimalRange(d); err != nil {
197+
return XNumberZero, err
198+
}
199+
200+
return newXNumber(d), nil
151201
}
152202

153203
// ToXNumber converts the given value to a number or returns an error if that isn't possible

excellent/types/number_test.go

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/nyaruka/gocommon/jsonx"
77
"github.com/nyaruka/goflow/envs"
88
"github.com/nyaruka/goflow/excellent/types"
9+
"github.com/shopspring/decimal"
910
"github.com/stretchr/testify/assert"
1011
)
1112

@@ -48,6 +49,14 @@ func TestXNumber(t *testing.T) {
4849
data, err := jsonx.Marshal(types.RequireXNumberFromString("23.45"))
4950
assert.NoError(t, err)
5051
assert.Equal(t, []byte(`23.45`), data)
52+
53+
// test NewXNumber range check
54+
assert.False(t, types.IsXError(types.NewXNumber(decimal.New(123, 0))))
55+
assert.True(t, types.IsXError(types.NewXNumber(decimal.New(1, 200)))) // magnitude too large
56+
assert.True(t, types.IsXError(types.NewXNumber(decimal.New(1, -200)))) // magnitude too small
57+
assert.False(t, types.IsXError(types.NewXNumber(decimal.New(1, 50)))) // within range
58+
assert.False(t, types.IsXError(types.NewXNumber(decimal.New(1, -50)))) // within range
59+
assert.False(t, types.IsXError(types.NewXNumber(decimal.RequireFromString("0")))) // zero always ok
5160
}
5261

5362
func TestToXNumberAndInteger(t *testing.T) {
@@ -66,9 +75,11 @@ func TestToXNumberAndInteger(t *testing.T) {
6675
"__default__": types.NewXNumberFromInt(123), // should use default
6776
"foo": types.NewXNumberFromInt(234),
6877
}), types.NewXNumberFromInt(123), 123, false},
69-
{types.NewXText("12345678901234567890"), types.RequireXNumberFromString("12345678901234567890"), 0, true}, // out of int range
70-
{types.NewXText("1E100"), types.XNumberZero, 0, true}, // scientific notation not allowed
71-
{types.NewXText("1e100"), types.XNumberZero, 0, true}, // scientific notation not allowed
78+
{types.NewXText("12345678901234567890"), types.RequireXNumberFromString("12345678901234567890"), 0, true}, // out of int range
79+
{types.NewXText("123456789012345678901234567890123456"), types.RequireXNumberFromString("123456789012345678901234567890123456"), 0, true}, // 36 digits, ok as number but out of int range
80+
{types.NewXText("1234567890123456789012345678901234567"), types.XNumberZero, 0, true}, // 37 digits, too many
81+
{types.NewXText("1E100"), types.XNumberZero, 0, true}, // scientific notation not allowed
82+
{types.NewXText("1e100"), types.XNumberZero, 0, true}, // scientific notation not allowed
7283
{types.NewXText("234."), types.XNumberZero, 0, true},
7384
{types.NewXText("+1800567890"), types.XNumberZero, 0, true},
7485
}
@@ -89,6 +100,37 @@ func TestToXNumberAndInteger(t *testing.T) {
89100
}
90101
}
91102

103+
func TestCheckDecimalRange(t *testing.T) {
104+
tests := []struct {
105+
value decimal.Decimal
106+
isError bool
107+
}{
108+
{decimal.Zero, false},
109+
{decimal.New(1, 0), false},
110+
{decimal.New(-1, 0), false},
111+
{decimal.New(123, 0), false},
112+
{decimal.RequireFromString("123456789012345678901234567890123456"), false}, // 36 significant digits - ok
113+
{decimal.RequireFromString("1234567890123456789012345678901234567"), true}, // 37 significant digits - too many
114+
{decimal.RequireFromString("-1234567890123456789012345678901234567"), true}, // negative 37 significant digits
115+
{decimal.RequireFromString("1234567895171680000000000000000000000000"), false}, // 40 digits but only 15 significant - ok
116+
{decimal.RequireFromString("12345678901234567890123456789012345670000000"), true}, // 37 significant digits with trailing zeros
117+
{decimal.RequireFromString("0.000000000000000000000000000000000001"), false},
118+
{decimal.New(1, 100), false}, // 1E100 - ok magnitude
119+
{decimal.New(1, 200), true}, // 1E200 - too large magnitude
120+
{decimal.New(1, -100), false},
121+
{decimal.New(1, -200), true}, // 1E-200 - too small magnitude
122+
}
123+
124+
for _, tc := range tests {
125+
err := types.CheckDecimalRange(tc.value)
126+
if tc.isError {
127+
assert.Error(t, err, "expected error for %s", tc.value)
128+
} else {
129+
assert.NoError(t, err, "unexpected error for %s", tc.value)
130+
}
131+
}
132+
}
133+
92134
func TestFormatCustom(t *testing.T) {
93135
fmtTests := []struct {
94136
input *types.XNumber

excellent/visitor.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,11 @@ func (v *visitor) VisitTextLiteral(ctx *gen.TextLiteralContext) any {
218218

219219
// VisitNumberLiteral deals with numbers like 123 or 1.5
220220
func (v *visitor) VisitNumberLiteral(ctx *gen.NumberLiteralContext) any {
221-
return &NumberLiteral{Value: types.RequireXNumberFromString(ctx.GetText())}
221+
num, err := types.NewXNumberFromString(ctx.GetText())
222+
if err != nil {
223+
return &ErrorLiteral{Err: types.NewXErrorf("number %s is out of range", ctx.GetText())}
224+
}
225+
return &NumberLiteral{Value: num}
222226
}
223227

224228
// VisitTrue deals with the `true` reserved word

flows/routers/cases/utils.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"strings"
55

66
"github.com/nyaruka/goflow/envs"
7+
"github.com/nyaruka/goflow/excellent/types"
78

89
"github.com/shopspring/decimal"
910
)
@@ -67,5 +68,14 @@ func ParseDecimal(val string, format *envs.NumberFormat) (decimal.Decimal, error
6768
// replace non-Arabic (0-9) numerals with their equivalents
6869
cleaned = strings.Map(numeralMapper, cleaned)
6970

70-
return decimal.NewFromString(cleaned)
71+
d, err := decimal.NewFromString(cleaned)
72+
if err != nil {
73+
return d, err
74+
}
75+
76+
if err := types.CheckDecimalRange(d); err != nil {
77+
return decimal.Zero, err
78+
}
79+
80+
return d, nil
7181
}

flows/routers/cases/utils_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,8 @@ func TestParseDecimal(t *testing.T) {
4646
assert.NoError(t, err)
4747
assert.Equal(t, test.expected, val, "parse decimal failed for input '%s'", test.input)
4848
}
49+
50+
// test that oversized numbers are rejected
51+
_, err := cases.ParseDecimal("1234567890123456789012345678901234567", envs.DefaultNumberFormat)
52+
assert.EqualError(t, err, "number has too many digits")
4953
}

0 commit comments

Comments
 (0)