Skip to content

Commit 9579f12

Browse files
Omer Lachisharikfr
authored andcommitted
Protect against SQL injections by using tree comparisons (#3109)
* add SQLQuery class with tests for safe queries and non-safe tautology attacks * add test for union query injections * split .apply calls to newline * add tests for comment attacks * remove double underscore * extract complex children check to variable * inherit from object because I'm not a lamer Co-Authored-By: rauchy <omer@rauchy.net> * simplify cognitive complexity * check that additional columns are not injected * detect appended queries * inline .apply calls * move SQLQuery to it's own module * move SQLQuery tests to their own module * serialize SQLQuery instances * raise an exception when attempting to serialize an unsafe query * queries without parameters are safe * remove redundant parentheses * use cached properties * rename SQLInjectionException to SQLInjectionError * support multiple word params and param negations * refactor out methods that don't involve any state * don't cache text() * reduce cognitive complexity
1 parent 463d4ce commit 9579f12

File tree

3 files changed

+165
-0
lines changed

3 files changed

+165
-0
lines changed

redash/utils/sql_query.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import re
2+
3+
import sqlparse
4+
from redash.utils import mustache_render
5+
6+
7+
def _replace_params(template):
8+
return re.sub('-?{{.+?}}', 'param', template)
9+
10+
11+
def _inside_a_where_clause(a):
12+
if a is None:
13+
return False
14+
else:
15+
return type(a.parent) is sqlparse.sql.Where or _inside_a_where_clause(a.parent)
16+
17+
18+
def _populating_an_in_operator(a, b):
19+
if type(a) is sqlparse.sql.Identifier and \
20+
type(b) is sqlparse.sql.IdentifierList and \
21+
_inside_a_where_clause(a):
22+
return True
23+
24+
25+
def _equivalent_leaves(a, b):
26+
return type(a) == type(b) or \
27+
(type(a) is sqlparse.sql.Identifier and type(b) is sqlparse.sql.Token)
28+
29+
30+
def _filter_noise(tokens):
31+
skippable_tokens = [sqlparse.tokens.Error, sqlparse.tokens.Whitespace]
32+
return [t for t in tokens if t.ttype not in skippable_tokens]
33+
34+
35+
def _same_type(a, b):
36+
if _populating_an_in_operator(a, b):
37+
return True
38+
elif type(a) in (list, tuple):
39+
children_are_same = [_same_type(child_a, child_b) for (child_a, child_b) in zip(a, b)]
40+
return len(a) == len(b) and all(children_are_same)
41+
elif (hasattr(a, 'tokens') and hasattr(b, 'tokens')):
42+
return _same_type(_filter_noise(a.tokens), _filter_noise(b.tokens))
43+
else:
44+
return _equivalent_leaves(a, b)
45+
46+
47+
class SQLQuery(object):
48+
def __init__(self, template):
49+
self.template = template
50+
self.query = template
51+
52+
def apply(self, parameters):
53+
self.query = mustache_render(self.template, parameters)
54+
return self
55+
56+
def is_safe(self):
57+
template_tree = sqlparse.parse(_replace_params(self.template))
58+
query_tree = sqlparse.parse(self.query)
59+
60+
return _same_type(template_tree, query_tree)
61+
62+
@property
63+
def text(self):
64+
if not self.is_safe():
65+
raise SQLInjectionError()
66+
else:
67+
return self.query
68+
69+
70+
class SQLInjectionError(Exception):
71+
pass

tests/utils/__init__.py

Whitespace-only changes.

tests/utils/test_sql_query.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from unittest import TestCase
2+
3+
from redash.utils.sql_query import SQLInjectionError, SQLQuery
4+
5+
6+
class TestSQLQuery(TestCase):
7+
def test_serializes(self):
8+
query = SQLQuery("SELECT * FROM users WHERE userid='{{userid}}'").apply({
9+
"userid": 22
10+
})
11+
12+
self.assertEqual(query.text, "SELECT * FROM users WHERE userid='22'")
13+
14+
def test_raises_when_serializing_unsafe_queries(self):
15+
query = SQLQuery("SELECT * FROM users WHERE userid={{userid}}").apply({
16+
"userid": "22 OR 1==1"
17+
})
18+
19+
self.assertRaises(SQLInjectionError, getattr, query, 'text')
20+
21+
def test_marks_queries_without_params_as_safe(self):
22+
query = SQLQuery("SELECT * FROM users")
23+
24+
self.assertTrue(query.is_safe())
25+
26+
def test_marks_simple_queries_with_where_params_as_safe(self):
27+
query = SQLQuery("SELECT * FROM users WHERE userid='{{userid}}'").apply({
28+
"userid": 22
29+
})
30+
31+
self.assertTrue(query.is_safe())
32+
33+
def test_marks_simple_queries_with_column_params_as_safe(self):
34+
query = SQLQuery("SELECT {{this_column}} FROM users").apply({
35+
"this_column": "username"
36+
})
37+
38+
self.assertTrue(query.is_safe())
39+
40+
def test_marks_multiple_simple_queries_as_safe(self):
41+
query = SQLQuery("SELECT * FROM users WHERE userid='{{userid}}' ; SELECT * FROM profiles").apply({
42+
"userid": 22
43+
})
44+
45+
self.assertTrue(query.is_safe())
46+
47+
def test_marks_tautologies_as_not_safe(self):
48+
query = SQLQuery("SELECT * FROM users WHERE userid={{userid}}").apply({
49+
"userid": "22 OR 1==1"
50+
})
51+
52+
self.assertFalse(query.is_safe())
53+
54+
def test_marks_union_queries_as_not_safe(self):
55+
query = SQLQuery("SELECT * FROM users WHERE userid={{userid}}").apply({
56+
"userid": "22 UNION SELECT body, results, 1 FROM reports"
57+
})
58+
59+
self.assertFalse(query.is_safe())
60+
61+
def test_marks_comment_attacks_as_not_safe(self):
62+
query = SQLQuery("SELECT * FROM users WHERE username='{{username}}' AND password='{{password}}'").apply({
63+
"username": "admin' --"
64+
})
65+
66+
self.assertFalse(query.is_safe())
67+
68+
def test_marks_additional_columns_as_not_safe(self):
69+
query = SQLQuery("SELECT {{this_column}} FROM users").apply({
70+
"this_column": "username, password"
71+
})
72+
73+
self.assertFalse(query.is_safe())
74+
75+
def test_marks_query_additions_as_not_safe(self):
76+
query = SQLQuery("SELECT * FROM users ORDER BY {{this_column}}").apply({
77+
"this_column": "id ; DROP TABLE midgets"
78+
})
79+
80+
self.assertFalse(query.is_safe())
81+
82+
def test_marks_multiple_word_params_as_safe(self):
83+
query = SQLQuery("SELECT {{why would you do this}} FROM users").apply({
84+
"why would you do this": "shrug"
85+
})
86+
87+
self.assertTrue(query.is_safe())
88+
89+
def test_marks_param_negations_as_safe(self):
90+
query = SQLQuery("SELECT date_add(some_column, INTERVAL -{{days}} DAY) FROM events").apply({
91+
"days": 7
92+
})
93+
94+
self.assertTrue(query.is_safe())

0 commit comments

Comments
 (0)