Skip to content

Commit 3848ae7

Browse files
committed
Math: Rewrite calc functions with a proper evaluator.
Instead of hacking around eval(), which everyone knows is a bad idea even with prior expression sanitizing.
1 parent 5e2343f commit 3848ae7

File tree

3 files changed

+185
-109
lines changed

3 files changed

+185
-109
lines changed

plugins/Math/evaluator.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
###
2+
# Copyright (c) 2019, Valentin Lorentz
3+
# All rights reserved.
4+
#
5+
# Redistribution and use in source and binary forms, with or without
6+
# modification, are permitted provided that the following conditions are met:
7+
#
8+
# * Redistributions of source code must retain the above copyright notice,
9+
# this list of conditions, and the following disclaimer.
10+
# * Redistributions in binary form must reproduce the above copyright notice,
11+
# this list of conditions, and the following disclaimer in the
12+
# documentation and/or other materials provided with the distribution.
13+
# * Neither the name of the author of this software nor the name of
14+
# contributors to this software may be used to endorse or promote products
15+
# derived from this software without specific prior written consent.
16+
#
17+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
21+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27+
# POSSIBILITY OF SUCH DAMAGE.
28+
###
29+
30+
import ast
31+
import math
32+
import cmath
33+
import operator
34+
35+
class InvalidNode(Exception):
36+
pass
37+
38+
def filter_module(module, safe_names):
39+
return dict([
40+
(name, getattr(module, name))
41+
for name in safe_names
42+
if hasattr(module, name)
43+
])
44+
45+
UNARY_OPS = {
46+
ast.UAdd: lambda x: x,
47+
ast.USub: lambda x: -x,
48+
}
49+
50+
BIN_OPS = {
51+
ast.Add: operator.add,
52+
ast.Sub: operator.sub,
53+
ast.Mult: operator.mul,
54+
ast.Div: operator.truediv,
55+
ast.Pow: operator.pow,
56+
ast.BitXor: operator.xor,
57+
ast.BitOr: operator.or_,
58+
ast.BitAnd: operator.and_,
59+
}
60+
61+
MATH_CONSTANTS = 'e inf nan pi tau'.split()
62+
SAFE_MATH_FUNCTIONS = (
63+
'acos acosh asin asinh atan atan2 atanh copysign cos cosh degrees erf '
64+
'erfc exp expm1 fabs fmod frexp fsum gamma hypot ldexp lgamma log log10 '
65+
'log1p log2 modf pow radians remainder sin sinh tan tanh'
66+
).split()
67+
SAFE_CMATH_FUNCTIONS = (
68+
'acos acosh asin asinh atan atanh cos cosh exp inf infj log log10 '
69+
'nanj phase polar rect sin sinh tan tanh tau'
70+
).split()
71+
72+
SAFE_ENV = filter_module(math, MATH_CONSTANTS + SAFE_MATH_FUNCTIONS)
73+
SAFE_ENV.update(filter_module(cmath, SAFE_CMATH_FUNCTIONS))
74+
75+
def _sqrt(x):
76+
if isinstance(x, complex) or x < 0:
77+
return cmath.sqrt(x)
78+
else:
79+
return math.sqrt(x)
80+
81+
def _cbrt(x):
82+
return math.pow(x, 1.0/3)
83+
84+
def _factorial(x):
85+
if x<=10000:
86+
return float(math.factorial(x))
87+
else:
88+
raise Exception('factorial argument too large')
89+
90+
SAFE_ENV.update({
91+
'i': 1j,
92+
'abs': abs,
93+
'max': max,
94+
'min': min,
95+
'round': lambda x, y=0: round(x, int(y)),
96+
'factorial': _factorial,
97+
'sqrt': _sqrt,
98+
'cbrt': _cbrt,
99+
'ceil': lambda x: float(math.ceil(x)),
100+
'floor': lambda x: float(math.floor(x)),
101+
})
102+
103+
UNSAFE_ENV = SAFE_ENV.copy()
104+
# Add functions that return integers
105+
UNSAFE_ENV.update(filter_module(math, 'ceil floor factorial gcd'.split()))
106+
107+
108+
# It would be nice if ast.literal_eval used a visitor so we could subclass
109+
# to extend it, but it doesn't, so let's reimplement it entirely.
110+
class SafeEvalVisitor(ast.NodeVisitor):
111+
def __init__(self, allow_ints):
112+
self._allow_ints = allow_ints
113+
self._env = UNSAFE_ENV if allow_ints else SAFE_ENV
114+
115+
def _convert_num(self, x):
116+
"""Converts numbers to complex if ints are not allowed."""
117+
if self._allow_ints:
118+
return x
119+
else:
120+
x = complex(x)
121+
if x.imag == 0:
122+
x = x.real
123+
# Need to use string-formatting here instead of str() because
124+
# use of str() on large numbers loses information:
125+
# str(float(33333333333333)) => '3.33333333333e+13'
126+
# float('3.33333333333e+13') => 33333333333300.0
127+
return float('%.16f' % x)
128+
else:
129+
return x
130+
131+
def visit_Expression(self, node):
132+
return self.visit(node.body)
133+
134+
def visit_Num(self, node):
135+
return self._convert_num(node.n)
136+
137+
def visit_Name(self, node):
138+
id_ = node.id.lower()
139+
if id_ in self._env:
140+
return self._env[id_]
141+
else:
142+
raise NameError(node.id)
143+
144+
def visit_Call(self, node):
145+
func = self.visit(node.func)
146+
args = map(self.visit, node.args)
147+
# TODO: keywords?
148+
return func(*args)
149+
150+
def visit_UnaryOp(self, node):
151+
op = UNARY_OPS.get(node.op.__class__)
152+
if op:
153+
return op(self.visit(node.operand))
154+
else:
155+
raise InvalidNode('illegal operator %s' % node.op.__class__.__name__)
156+
157+
def visit_BinOp(self, node):
158+
op = BIN_OPS.get(node.op.__class__)
159+
if op:
160+
return op(self.visit(node.left), self.visit(node.right))
161+
else:
162+
raise InvalidNode('illegal operator %s' % node.op.__class__.__name__)
163+
164+
def generic_visit(self, node):
165+
raise InvalidNode('illegal construct %s' % node.__class__.__name__)
166+
167+
def safe_eval(text, allow_ints):
168+
node = ast.parse(text, mode='eval')
169+
return SafeEvalVisitor(allow_ints).visit(node)

plugins/Math/plugin.py

Lines changed: 12 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
_ = PluginInternationalization('Math')
4545

4646
from .local import convertcore
47+
from .evaluator import safe_eval, InvalidNode, SAFE_ENV
4748

4849
baseArg = ('int', 'base', lambda i: i <= 36)
4950

@@ -97,36 +98,6 @@ def _convertBaseToBase(self, number, toBase, fromBase):
9798
return str(number)
9899
return self._convertDecimalToBase(number, toBase)
99100

100-
_mathEnv = {'__builtins__': types.ModuleType('__builtins__'), 'i': 1j}
101-
_mathEnv.update(math.__dict__)
102-
_mathEnv.update(cmath.__dict__)
103-
def _sqrt(x):
104-
if isinstance(x, complex) or x < 0:
105-
return cmath.sqrt(x)
106-
else:
107-
return math.sqrt(x)
108-
def _cbrt(x):
109-
return math.pow(x, 1.0/3)
110-
def _factorial(x):
111-
if x<=10000:
112-
return float(math.factorial(x))
113-
else:
114-
raise Exception('factorial argument too large')
115-
_mathEnv['sqrt'] = _sqrt
116-
_mathEnv['cbrt'] = _cbrt
117-
_mathEnv['abs'] = abs
118-
_mathEnv['max'] = max
119-
_mathEnv['min'] = min
120-
_mathEnv['round'] = lambda x, y=0: round(x, int(y))
121-
_mathSafeEnv = dict([(x,y) for x,y in _mathEnv.items()])
122-
_mathSafeEnv['factorial'] = _factorial
123-
_mathRe = re.compile(r'((?:(?<![A-Fa-f\d)])-)?'
124-
r'(?:0x[A-Fa-f\d]+|'
125-
r'0[0-7]+|'
126-
r'\d+\.\d+|'
127-
r'\.\d+|'
128-
r'\d+\.|'
129-
r'\d+))')
130101
def _floatToString(self, x):
131102
if -1e-10 < x < 1e-10:
132103
return '0'
@@ -157,17 +128,6 @@ def _complexToString(self, x):
157128
else:
158129
return '%s%s' % (realS, imagS)
159130

160-
_calc_match_forbidden_chars = re.compile('[_\[\]]')
161-
_calc_remover = utils.str.MultipleRemover('_[] \t')
162-
###
163-
# So this is how the 'calc' command works:
164-
# First, we make a nice little safe environment for evaluation; basically,
165-
# the names in the 'math' and 'cmath' modules. Then, we remove the ability
166-
# of a random user to get ints evaluated: this means we have to turn all
167-
# int literals (even octal numbers and hexadecimal numbers) into floats.
168-
# Then we delete all square brackets, underscores, and whitespace, so no
169-
# one can do list comprehensions or call __...__ functions.
170-
###
171131
@internationalizeDocstring
172132
def calc(self, irc, msg, args, text):
173133
"""<math expression>
@@ -178,57 +138,17 @@ def calc(self, irc, msg, args, text):
178138
crash to the bot with something like '10**10**10**10'. One consequence
179139
is that large values such as '10**24' might not be exact.
180140
"""
181-
try:
182-
text = str(text)
183-
except UnicodeEncodeError:
184-
irc.error(_("There's no reason you should have fancy non-ASCII "
185-
"characters in your mathematical expression. "
186-
"Please remove them."))
187-
return
188-
if self._calc_match_forbidden_chars.match(text):
189-
# Note: this is important to keep this to forbid usage of
190-
# __builtins__
191-
irc.error(_('There\'s really no reason why you should have '
192-
'underscores or brackets in your mathematical '
193-
'expression. Please remove them.'))
194-
return
195-
text = self._calc_remover(text)
196-
if 'lambda' in text:
197-
irc.error(_('You can\'t use lambda in this command.'))
198-
return
199-
text = text.lower()
200-
def handleMatch(m):
201-
s = m.group(1)
202-
if s.startswith('0x'):
203-
i = int(s, 16)
204-
elif s.startswith('0') and '.' not in s:
205-
try:
206-
i = int(s, 8)
207-
except ValueError:
208-
i = int(s)
209-
else:
210-
i = float(s)
211-
x = complex(i)
212-
if x.imag == 0:
213-
x = x.real
214-
# Need to use string-formatting here instead of str() because
215-
# use of str() on large numbers loses information:
216-
# str(float(33333333333333)) => '3.33333333333e+13'
217-
# float('3.33333333333e+13') => 33333333333300.0
218-
return '%.16f' % x
219-
return str(x)
220-
text = self._mathRe.sub(handleMatch, text)
221141
try:
222142
self.log.info('evaluating %q from %s', text, msg.prefix)
223-
x = complex(eval(text, self._mathSafeEnv, self._mathSafeEnv))
143+
x = complex(safe_eval(text, allow_ints=False))
224144
irc.reply(self._complexToString(x))
225145
except OverflowError:
226146
maxFloat = math.ldexp(0.9999999999999999, 1024)
227147
irc.error(_('The answer exceeded %s or so.') % maxFloat)
228-
except TypeError:
229-
irc.error(_('Something in there wasn\'t a valid number.'))
148+
except InvalidNode as e:
149+
irc.error(_('Invalid syntax: %s') % e.args[0])
230150
except NameError as e:
231-
irc.error(_('%s is not a defined function.') % str(e).split()[1])
151+
irc.error(_('%s is not a defined function.') % e.args[0])
232152
except Exception as e:
233153
irc.error(str(e))
234154
calc = wrap(calc, ['text'])
@@ -241,28 +161,15 @@ def icalc(self, irc, msg, args, text):
241161
math, and can thus cause the bot to suck up CPU. Hence it requires
242162
the 'trusted' capability to use.
243163
"""
244-
if self._calc_match_forbidden_chars.match(text):
245-
# Note: this is important to keep this to forbid usage of
246-
# __builtins__
247-
irc.error(_('There\'s really no reason why you should have '
248-
'underscores or brackets in your mathematical '
249-
'expression. Please remove them.'))
250-
return
251-
# This removes spaces, too, but we'll leave the removal of _[] for
252-
# safety's sake.
253-
text = self._calc_remover(text)
254-
if 'lambda' in text:
255-
irc.error(_('You can\'t use lambda in this command.'))
256-
return
257-
text = text.replace('lambda', '')
258164
try:
259165
self.log.info('evaluating %q from %s', text, msg.prefix)
260-
irc.reply(str(eval(text, self._mathEnv, self._mathEnv)))
166+
x = safe_eval(text, allow_ints=True)
167+
irc.reply(str(x))
261168
except OverflowError:
262169
maxFloat = math.ldexp(0.9999999999999999, 1024)
263170
irc.error(_('The answer exceeded %s or so.') % maxFloat)
264-
except TypeError:
265-
irc.error(_('Something in there wasn\'t a valid number.'))
171+
except InvalidNode as e:
172+
irc.error(_('Invalid syntax: %s') % e.args[0])
266173
except NameError as e:
267174
irc.error(_('%s is not a defined function.') % str(e).split()[1])
268175
except Exception as e:
@@ -286,8 +193,8 @@ def rpn(self, irc, msg, args):
286193
x = abs(x)
287194
stack.append(x)
288195
except ValueError: # Not a float.
289-
if arg in self._mathSafeEnv:
290-
f = self._mathSafeEnv[arg]
196+
if arg in SAFE_ENV:
197+
f = SAFE_ENV[arg]
291198
if callable(f):
292199
called = False
293200
arguments = []
@@ -310,7 +217,7 @@ def rpn(self, irc, msg, args):
310217
arg1 = stack.pop()
311218
s = '%s%s%s' % (arg1, arg, arg2)
312219
try:
313-
stack.append(eval(s, self._mathSafeEnv, self._mathSafeEnv))
220+
stack.append(safe_eval(s, allow_ints=False))
314221
except SyntaxError:
315222
irc.error(format(_('%q is not a defined function.'),
316223
arg))

plugins/Math/test.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,6 @@ def testBase(self):
9191
self.assertError('base 4 4')
9292
self.assertError('base 10 12 A')
9393

94-
print()
95-
print("If we have not fixed a bug with Math.base, the following ")
96-
print("tests will hang the test-suite.")
9794
self.assertRegexp('base 2 10 [base 10 2 -12]', '-12')
9895
self.assertRegexp('base 16 2 [base 2 16 -110101]', '-110101')
9996

@@ -117,7 +114,10 @@ def testCalc(self):
117114
self.assertError('calc factorial(20000)')
118115

119116
def testCalcNoNameError(self):
120-
self.assertNotRegexp('calc foobar(x)', 'NameError')
117+
self.assertRegexp('calc foobar(x)', 'foobar is not a defined function')
118+
119+
def testCalcInvalidNode(self):
120+
self.assertRegexp('calc {"foo": "bar"}', 'Illegal construct Dict')
121121

122122
def testCalcImaginary(self):
123123
self.assertResponse('calc 3 + sqrt(-1)', '3+i')

0 commit comments

Comments
 (0)