Skip to content

Commit d04c27d

Browse files
fix: escape embedded quotes in identifiers to prevent SQL injection by dev-engineer
1 parent 817596e commit d04c27d

4 files changed

Lines changed: 77 additions & 18 deletions

File tree

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,20 @@ jobs:
3434
run: pip install ruff && ruff check src/ tests/ --target-version py310
3535

3636
- name: Run tests
37+
run: |
38+
python -m pytest tests/ -v --cov=src --cov-report=term-missing
39+
40+
js-wrapper:
41+
runs-on: ubuntu-latest
42+
steps:
43+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
44+
with:
45+
persist-credentials: false
46+
47+
- name: Set up Node
48+
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
49+
with:
50+
node-version: "20"
51+
52+
- name: Test the npm wrapper
53+
run: node --test tests/*.test.js

src/json2sql/dialects.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,20 @@ def sql_type_for(value: Any, dialect: Dialect) -> str:
4747

4848

4949
def quote_identifier(name: str, dialect: Dialect) -> str:
50-
"""Quote an identifier (table/column name) for the given dialect."""
50+
"""Quote an identifier (table/column name) for the given dialect.
51+
52+
Embedded quote characters are escaped by doubling — the standard SQL rule
53+
for quoted identifiers — mirroring how ``format_value`` escapes string
54+
literals. Without this, a table/column name (which comes straight from
55+
untrusted JSON object keys) containing a ``"`` (Postgres/SQLite) or a
56+
backtick (MySQL) could break out of the quoted identifier and inject
57+
arbitrary SQL into the generated statement.
58+
"""
5159
if dialect == Dialect.MYSQL:
52-
return f"`{name}`"
53-
return f'"{name}"'
60+
escaped = name.replace("`", "``")
61+
return f"`{escaped}`"
62+
escaped = name.replace('"', '""')
63+
return f'"{escaped}"'
5464

5565

5666
def format_value(value: Any, dialect: Dialect) -> str:

tests/test_converter.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,7 @@ def test_flatten_nested_array_column_value_count(self):
174174
if cols and vals:
175175
c_count = len([c for c in cols.group(1).split(",") if c.strip()])
176176
v_count = len([v for v in vals.group(1).split(",") if v.strip()])
177-
assert c_count == v_count, (
178-
f"Column count ({c_count}) != value count ({v_count})"
179-
)
177+
assert c_count == v_count, f"Column count ({c_count}) != value count ({v_count})"
180178

181179
def test_flatten_mixed_nested_array_and_object_count(self):
182180
"""
@@ -201,9 +199,7 @@ def test_flatten_mixed_nested_array_and_object_count(self):
201199
if cols and vals:
202200
c_count = len([c for c in cols.group(1).split(",") if c.strip()])
203201
v_count = len([v for v in vals.group(1).split(",") if v.strip()])
204-
assert c_count == v_count, (
205-
f"Column count ({c_count}) != value count ({v_count})"
206-
)
202+
assert c_count == v_count, f"Column count ({c_count}) != value count ({v_count})"
207203

208204

209205
class TestFlattenDetail:
@@ -401,6 +397,28 @@ def test_string_with_quotes(self, converter_postgres):
401397
result = converter_postgres.convert(data, table_name="profiles")
402398
assert "It''s a test" in result # SQL-escaped single quote
403399

400+
def test_key_with_embedded_double_quote_postgres(self, converter_postgres):
401+
# A JSON key containing " must be escaped in the generated identifier
402+
# to prevent SQL injection through untrusted object keys.
403+
data = json.dumps({'col"evil': 1})
404+
result = converter_postgres.convert(data, table_name="profiles")
405+
assert '"col""evil"' in result
406+
# Ensure the dangerous unescaped form is NOT present
407+
assert '"col"evil"' not in result
408+
409+
def test_key_with_embedded_double_quote_sqlite(self, converter_sqlite):
410+
data = json.dumps({'col"evil': 1})
411+
result = converter_sqlite.convert(data, table_name="profiles")
412+
assert '"col""evil"' in result
413+
assert '"col"evil"' not in result
414+
415+
def test_key_with_embedded_backtick_mysql(self, converter_mysql):
416+
# A JSON key containing a backtick must be escaped in MySQL identifiers.
417+
data = json.dumps({"col`evil": 1})
418+
result = converter_mysql.convert(data, table_name="profiles")
419+
assert "`col``evil`" in result
420+
assert "`col`evil`" not in result
421+
404422
def test_float_values(self, converter_postgres):
405423
data = json.dumps({"price": 19.99})
406424
result = converter_postgres.convert(data, table_name="products")
@@ -515,6 +533,5 @@ def test_version_in_init_matches_pyproject(self):
515533
with open(pyproject, "rb") as f:
516534
data = tomllib.load(f)
517535
assert data["project"]["version"] == __version__, (
518-
f"pyproject.toml version ({data['project']['version']}) != "
519-
f"__init__.__version__ ({__version__})"
536+
f"pyproject.toml version ({data['project']['version']}) != __init__.__version__ ({__version__})"
520537
)

tests/test_dialects.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,9 @@ def test_all_members(self):
3636
class TestSQLTypeFor:
3737
"""Python type → SQL type mapping per dialect."""
3838

39-
@pytest.mark.parametrize(
40-
"dialect", [Dialect.POSTGRES, Dialect.MYSQL, Dialect.SQLITE]
41-
)
39+
@pytest.mark.parametrize("dialect", [Dialect.POSTGRES, Dialect.MYSQL, Dialect.SQLITE])
4240
def test_string_type(self, dialect):
43-
assert (
44-
"TEXT" in sql_type_for("hello", dialect).upper()
45-
or "VARCHAR" in sql_type_for("hello", dialect).upper()
46-
)
41+
assert "TEXT" in sql_type_for("hello", dialect).upper() or "VARCHAR" in sql_type_for("hello", dialect).upper()
4742

4843
def test_postgres_int(self):
4944
assert sql_type_for(42, Dialect.POSTGRES) == "INTEGER"
@@ -115,6 +110,26 @@ def test_empty_name(self):
115110
result = quote_identifier("", dialect)
116111
assert len(result) >= 2
117112

113+
def test_embedded_quote_mysql(self):
114+
"""MySQL: backtick embedded in identifier is escaped by doubling."""
115+
assert quote_identifier("my`table", Dialect.MYSQL) == "`my``table`"
116+
117+
def test_embedded_quote_postgres(self):
118+
"""Postgres: double-quote embedded in identifier is escaped by doubling."""
119+
assert quote_identifier('my"table', Dialect.POSTGRES) == '"my""table"'
120+
121+
def test_embedded_quote_sqlite(self):
122+
"""SQLite: double-quote embedded in identifier is escaped by doubling."""
123+
assert quote_identifier('my"table', Dialect.SQLITE) == '"my""table"'
124+
125+
def test_multiple_embedded_quotes_mysql(self):
126+
"""MySQL: multiple backticks in identifier are all escaped."""
127+
assert quote_identifier("a`b`c", Dialect.MYSQL) == "`a``b``c`"
128+
129+
def test_multiple_embedded_quotes_postgres(self):
130+
"""Postgres: multiple double-quotes in identifier are all escaped."""
131+
assert quote_identifier('a"b"c', Dialect.POSTGRES) == '"a""b""c"'
132+
118133

119134
# --- format_value ---
120135

0 commit comments

Comments
 (0)