Skip to content

Commit af4f4e9

Browse files
authored
Merge pull request #8703 from sfayer/feat_sqlescapetidy
[int] SQL escaping tidyups
2 parents 61b8579 + f70197b commit af4f4e9

3 files changed

Lines changed: 89 additions & 23 deletions

File tree

src/DIRAC/Core/Utilities/MySQL.py

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -188,35 +188,55 @@ def _checkFields(inFields, inValues):
188188
return S_OK()
189189

190190

191-
def _quotedList(fieldList=None, allowDate=False):
191+
def _quotedList(fieldList=None, allowFuncs=None):
192192
"""
193193
Quote a list of MySQL Field Names with "`"
194194
Supports fields containing a single . for a table.field identifier (for non-date values).
195+
allowFuncs - A list of function names to allow, should not include brackets.
196+
Supports functions that take one or zero arguments.
195197
Return a comma separated list of quoted Field Names
196198
197199
To be use for Table and Field Names
198200
"""
201+
202+
def __checkField(field):
203+
field = field.replace("`", "")
204+
if "." in field:
205+
fieldParts = field.split(".", 1)
206+
else:
207+
fieldParts = [field]
208+
for part in fieldParts:
209+
if not MySQL._checkIdentifier(part)["OK"]:
210+
return None
211+
return ".".join([f"`{x}`" for x in fieldParts])
212+
199213
if fieldList is None:
200214
return None
215+
funcList = []
216+
if allowFuncs:
217+
# Ensure all function names are upper case
218+
funcList.extend([x.upper() for x in allowFuncs])
201219
quotedFields = []
202220
try:
203221
for field in fieldList:
204-
if allowDate and field.startswith("date(") and field.endswith(")"):
205-
field = field[len("date(") : -len(")")]
206-
field = field.replace("`", "")
207-
if not MySQL._checkIdentifier(field)["OK"]:
222+
if "(" in field and field.endswith(")"): # contains a function call
223+
funcName = field.split("(")[0].upper()
224+
if funcName not in funcList:
225+
# function name isn't in the allowed list
208226
return None
209-
quotedFields.append(f"date(`{field}`")
210-
else:
211-
field = field.replace("`", "")
212-
if "." in field:
213-
fieldParts = field.split(".", 1)
227+
field = field.split("(")[1][:-1]
228+
# Field is the argument, may be empty string
229+
if field:
230+
quotedField = __checkField(field)
231+
if not quotedField:
232+
return None # Function argument was invalid
233+
quotedFields.append(f"{funcName.upper()}({quotedField})")
214234
else:
215-
fieldParts = [field]
216-
for part in fieldParts:
217-
if not MySQL._checkIdentifier(part)["OK"]:
218-
return None
219-
quotedField = ".".join([f"`{x}`" for x in fieldParts])
235+
quotedFields.append(f"{funcName.upper()}()")
236+
else: # Non-function call case
237+
quotedField = __checkField(field)
238+
if not quotedField:
239+
return None # Field name was invalid
220240
quotedFields.append(quotedField)
221241
except Exception:
222242
return None
@@ -1306,7 +1326,7 @@ def getCounters(
13061326
# self.log.debug('getCounters:', error)
13071327
return S_ERROR(DErrno.EMYSQL, error)
13081328

1309-
attrNames = _quotedList(attrList, allowDate=True)
1329+
attrNames = _quotedList(attrList, allowFuncs=["DATE"])
13101330
if attrNames is None:
13111331
error = "Invalid updateFields argument"
13121332
# self.log.debug('getCounters:', error)
@@ -1508,12 +1528,8 @@ def buildCondition(
15081528
# self.log.debug('buildCondition:', error)
15091529
raise Exception(error)
15101530

1511-
# Do not escape the special RAND case
1512-
if orderAttr.split(":")[:1] == ["RAND()"]:
1513-
orderField = "RAND()"
1514-
else:
1515-
orderField = _quotedList(orderAttr.split(":")[:1])
1516-
1531+
# Allow field names and/or RAND() in the sort list
1532+
orderField = _quotedList(orderAttr.split(":")[:1], allowFuncs=["RAND"])
15171533
if not orderField:
15181534
error = "Invalid orderAttribute argument"
15191535
# self.log.debug('buildCondition:', error)
@@ -1528,6 +1544,7 @@ def buildCondition(
15281544
# self.log.debug('buildCondition:', error)
15291545
raise Exception(error)
15301546
else:
1547+
# orderAttr is safe here as it was checked by _quotedList
15311548
orderList.append(orderAttr)
15321549

15331550
if orderList:

src/DIRAC/Core/Utilities/test/Test_MySQL.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import pytest
55

6-
from DIRAC.Core.Utilities.MySQL import MySQL
6+
from DIRAC.Core.Utilities.MySQL import MySQL, _quotedList
77

88

99
class TestCheckIdentifierValid:
@@ -254,3 +254,44 @@ class TestCheckTypeInvalid:
254254
def test_invalid(self, value_type):
255255
result = MySQL._checkType(value_type)
256256
assert not result["OK"]
257+
258+
259+
class TestQuotedList:
260+
"""Test quotedList escapes/rejects things correctly."""
261+
262+
@pytest.mark.parametrize(
263+
"value,allowFuncs,expected",
264+
[
265+
# Simple cases
266+
([""], None, None),
267+
(["myfield"], None, "`myfield`"),
268+
(["myField"], None, "`myField`"),
269+
(["abc", "def", "ghi"], None, "`abc`, `def`, `ghi`"),
270+
(["bad#name"], None, None),
271+
(["abc", "--invalid"], None, None),
272+
# Qualified names
273+
(["abc.def"], None, "`abc`.`def`"),
274+
(["`abc`.`def`"], None, "`abc`.`def`"),
275+
(["abc", "tbl.def"], None, "`abc`, `tbl`.`def`"),
276+
(["`abc`.`--bad`"], None, None),
277+
(["a.b.c"], None, None), # Only one qualifier is allowed
278+
# Function handling
279+
(["date(myfield)"], ["DATE"], "DATE(`myfield`)"),
280+
(["DATE(myfield)"], ["DATE"], "DATE(`myfield`)"),
281+
(["date(myfield)"], ["date"], "DATE(`myfield`)"),
282+
(["date(--bad)"], ["DATE"], None),
283+
(["date(myfield)"], None, None),
284+
(["date(tbl.myfield)"], ["DATE"], "DATE(`tbl`.`myfield`)"),
285+
(["date(`tbl`.`myfield`)"], ["DATE"], "DATE(`tbl`.`myfield`)"),
286+
(["rand()"], ["RAND"], "RAND()"),
287+
(["RAND()"], ["rand"], "RAND()"),
288+
(["RAND()"], ["RAND"], "RAND()"),
289+
(["RAND())"], ["RAND"], None),
290+
(["OTHER()"], ["RAND"], None),
291+
(["RAND()", "DATE(mycol)", "plain"], ["RAND", "DATE"], "RAND(), DATE(`mycol`), `plain`"),
292+
(["RAND()", "OTH(mycol)", "plain"], ["RAND", "DATE"], None),
293+
],
294+
)
295+
def test_quotedList(self, value, allowFuncs, expected):
296+
res = _quotedList(value, allowFuncs)
297+
assert res == expected

tests/Integration/Core/Test_MySQLDB.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,11 @@ def test_escape_string_accepts_bytes():
393393
result = mysqlDB._escapeString(b"hello bytes")
394394
assert result["OK"], result["Message"]
395395
_expect_quoted("hello bytes", result["Value"])
396+
397+
398+
def test_escape_string_null():
399+
"""Escape string should return NULL if given None."""
400+
mysqlDB = setupDB()
401+
result = mysqlDB._escapeString(None)
402+
assert result["OK"], result["Message"]
403+
assert result["Value"] == "NULL"

0 commit comments

Comments
 (0)