Skip to content

Commit 31ac6d8

Browse files
rowanseymourclaude
andcommitted
Add range check for numbers to prevent DynamoDB overflow
Numbers in expressions could grow arbitrarily large via parsing or arithmetic, causing persist failures when stored as DynamoDB N type (max 38 digits). This adds a range check (max 36 significant digits, magnitude ±1E100) enforced at all number creation points. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 25f89f8 commit 31ac6d8

File tree

9 files changed

+138
-11
lines changed

9 files changed

+138
-11
lines changed

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: 49 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 too large")
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,36 @@ 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 persisted
152+
func CheckDecimalRange(d decimal.Decimal) error {
153+
if d.IsZero() {
154+
return nil
155+
}
156+
157+
// count significant digits by removing trailing zeros from the coefficient
158+
s := d.Coefficient().String()
159+
s = strings.TrimLeft(s, "-")
160+
s = strings.TrimRight(s, "0")
161+
if len(s) > MaxNumberDigits {
162+
return errors.New("number has too many digits")
163+
}
164+
165+
adjExp := int64(d.Exponent()) + int64(d.NumDigits()) - 1
166+
if adjExp > 100 || adjExp < -100 {
167+
return errors.New("number value is out of permitted range")
168+
}
169+
170+
return nil
171+
}
172+
173+
// NewXNumberFromString parses a number from a string
174+
func NewXNumberFromString(s string) (*XNumber, error) {
175+
return newXNumberFromString(s)
176+
}
177+
139178
// parses a number from a string
140179
func newXNumberFromString(s string) (*XNumber, error) {
141180
s = strings.TrimSpace(s)
@@ -147,7 +186,11 @@ func newXNumberFromString(s string) (*XNumber, error) {
147186
// we can assume anything that matched our regex is parseable
148187
d := decimal.RequireFromString(s)
149188

150-
return NewXNumber(d), nil
189+
if err := CheckDecimalRange(d); err != nil {
190+
return XNumberZero, err
191+
}
192+
193+
return newXNumber(d), nil
151194
}
152195

153196
// 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("invalid number")}
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)