Fix SQL injection via bracket escape bypass in escape_sqlite() (#2677)

escape_sqlite() wrapped identifiers in [brackets] without escaping any ]
characters inside the string. Since SQLite does not support escaping ]
within bracket quoting, an identifier containing ] could break out and
inject arbitrary SQL. Fall back to double-quote quoting (doubling any
embedded ") when the identifier contains ].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2026-06-10 23:15:18 -07:00
commit 9622662132
2 changed files with 36 additions and 0 deletions

View file

@ -410,6 +410,10 @@ def escape_css_string(s):
def escape_sqlite(s):
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
return s
elif "]" in s:
# SQLite does not support escaping ] inside [bracket] quoting, so fall
# back to double-quote quoting (doubling any embedded ") - #2677
return '"{}"'.format(s.replace('"', '""'))
else:
return f"[{s}]"

View file

@ -216,6 +216,38 @@ def test_detect_fts(open_quote, close_quote):
conn.close()
@pytest.mark.parametrize(
"identifier,expected",
(
("plain", "plain"),
("select", "[select]"),
("has space", "[has space]"),
("has'quote", "[has'quote]"),
# Identifiers containing ] must fall back to double-quote quoting
# (SQLite does not support escaping ] inside [brackets]) - #2677
("has]bracket", '"has]bracket"'),
('has"dquote]', '"has""dquote]"'),
),
)
def test_escape_sqlite(identifier, expected):
assert utils.escape_sqlite(identifier) == expected
def test_escape_sqlite_prevents_injection():
# https://github.com/simonw/datasette/issues/2677
conn = utils.sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER, password TEXT)")
conn.execute("INSERT INTO users VALUES (1, 'super_secret_password')")
malicious = "users] UNION SELECT password FROM users--"
conn.execute('CREATE TABLE "{}" (id INTEGER)'.format(malicious))
sql = "select count(*) from {}".format(utils.escape_sqlite(malicious))
results = conn.execute(sql).fetchall()
conn.close()
# The injected UNION must not execute - only the empty malicious table
# is queried, so we get a single count row and no leaked password
assert results == [(0,)]
@pytest.mark.parametrize("table", ("regular", "has'single quote"))
def test_detect_fts_different_table_names(table):
sql = """