Skip to content

Commit 8a73ecb

Browse files
authored
fix(athena): properly escape identifiers in Iceberg SQL generation (#3423)
* fix(athena): escape identifiers in Iceberg SQL to prevent injection via Glue column names * fix(athena): escape DDL identifiers (backticks) in CREATE/ALTER and overwrite DELETE * refactor(athena): rename identifier escaper to _escape_athena_dml_identifier for symmetry with DDL helper
1 parent de58132 commit 8a73ecb

2 files changed

Lines changed: 159 additions & 22 deletions

File tree

awswrangler/athena/_write_iceberg.py

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,27 @@ def _escape_athena_string_literal(value: Any) -> str:
3939
return str(value).replace("'", "''")
4040

4141

42+
def _escape_athena_dml_identifier(name: Any) -> str:
43+
# For identifiers (column/table names) spliced inside DOUBLE-QUOTE delimited
44+
# identifiers in DML statements (SELECT/INSERT/MERGE/DELETE), e.g. "<name>".
45+
# Column names can originate from the Glue Data Catalog during automatic schema
46+
# reconciliation, so they are not necessarily caller-trusted.
47+
#
48+
# Athena's Trino-based DML engine escapes an embedded double-quote in a delimited
49+
# identifier by doubling it. Without this, a name containing " closes the
50+
# identifier and the remainder is parsed as SQL.
51+
return str(name).replace('"', '""')
52+
53+
54+
def _escape_athena_ddl_identifier(name: Any) -> str:
55+
# For identifiers spliced inside BACKTICK delimited identifiers in DDL statements
56+
# (CREATE TABLE / ALTER TABLE), e.g. `<name>`. Athena's Hive-based DDL engine uses
57+
# backticks (not double quotes) for identifier quoting and escapes an embedded
58+
# backtick by doubling it. Mixing the two families is a syntax error, so DDL and
59+
# DML splices use separate helpers.
60+
return str(name).replace("`", "``")
61+
62+
4263
def _create_iceberg_table(
4364
df: pd.DataFrame,
4465
database: str,
@@ -63,12 +84,14 @@ def _create_iceberg_table(
6384
columns_types, _ = catalog.extract_athena_types(df=df, index=index, dtype=dtype)
6485
cols_str: str = ", ".join(
6586
[
66-
f"{k} {v}"
87+
f"`{_escape_athena_ddl_identifier(k)}` {v}"
6788
if (columns_comments is None or columns_comments.get(k) is None)
68-
else f"{k} {v} COMMENT '{_escape_athena_string_literal(columns_comments[k])}'"
89+
else f"`{_escape_athena_ddl_identifier(k)}` {v} COMMENT '{_escape_athena_string_literal(columns_comments[k])}'"
6990
for k, v in columns_types.items()
7091
]
7192
)
93+
# partition_cols may be partition transform expressions (e.g. "day(ts)", "truncate(10, col)"),
94+
# not plain identifiers, so they are spliced verbatim rather than quoted as identifiers.
7295
partition_cols_str: str = f"PARTITIONED BY ({', '.join([col for col in partition_cols])})" if partition_cols else ""
7396
table_properties_str: str = (
7497
", "
@@ -83,9 +106,9 @@ def _create_iceberg_table(
83106
)
84107

85108
create_sql: str = (
86-
f"CREATE TABLE IF NOT EXISTS `{table}` ({cols_str}) "
109+
f"CREATE TABLE IF NOT EXISTS `{_escape_athena_ddl_identifier(table)}` ({cols_str}) "
87110
f"{partition_cols_str} "
88-
f"LOCATION '{path}' "
111+
f"LOCATION '{_escape_athena_string_literal(path)}' "
89112
f"TBLPROPERTIES ('table_type' ='ICEBERG', 'format'='parquet'{table_properties_str})"
90113
)
91114

@@ -216,9 +239,11 @@ def _alter_iceberg_table_add_columns_sql(
216239
table: str,
217240
columns_to_add: dict[str, str],
218241
) -> list[str]:
219-
add_cols_str = ", ".join([f"{col_name} {columns_to_add[col_name]}" for col_name in columns_to_add])
242+
add_cols_str = ", ".join(
243+
[f"`{_escape_athena_ddl_identifier(col_name)}` {columns_to_add[col_name]}" for col_name in columns_to_add]
244+
)
220245

221-
return [f"ALTER TABLE {table} ADD COLUMNS ({add_cols_str})"]
246+
return [f"ALTER TABLE `{_escape_athena_ddl_identifier(table)}` ADD COLUMNS ({add_cols_str})"]
222247

223248

224249
def _alter_iceberg_table_change_columns_sql(
@@ -228,7 +253,9 @@ def _alter_iceberg_table_change_columns_sql(
228253
sql_statements = []
229254

230255
for col_name, col_type in columns_to_change.items():
231-
sql_statements.append(f"ALTER TABLE {table} CHANGE COLUMN {col_name} {col_name} {col_type}")
256+
escaped_table = _escape_athena_ddl_identifier(table)
257+
escaped_col = _escape_athena_ddl_identifier(col_name)
258+
sql_statements.append(f"ALTER TABLE `{escaped_table}` CHANGE COLUMN `{escaped_col}` `{escaped_col}` {col_type}")
232259

233260
return sql_statements
234261

@@ -290,7 +317,9 @@ def _build_order_by_clause(partition_cols: list[str] | None) -> str:
290317
if not partition_cols:
291318
return ""
292319

293-
order_cols = [f'"{_extract_column_from_partition_transform(col)}"' for col in partition_cols]
320+
order_cols = [
321+
f'"{_escape_athena_dml_identifier(_extract_column_from_partition_transform(col))}"' for col in partition_cols
322+
]
294323
return f"ORDER BY {', '.join(order_cols)}"
295324

296325

@@ -359,34 +388,40 @@ def _merge_iceberg(
359388
"""
360389
wg_config: _WorkGroupConfig = _get_workgroup_config(session=boto3_session, workgroup=workgroup)
361390

391+
esc_database = _escape_athena_dml_identifier(database)
392+
esc_table = _escape_athena_dml_identifier(table)
393+
esc_source_table = _escape_athena_dml_identifier(source_table)
394+
esc_columns = [_escape_athena_dml_identifier(x) for x in df.columns]
395+
362396
sql_statement: str
363397
if merge_cols:
398+
esc_merge_cols = [_escape_athena_dml_identifier(x) for x in merge_cols]
364399
if merge_condition == "update":
365400
match_condition = f"""WHEN MATCHED THEN
366-
UPDATE SET {", ".join([f'"{x}" = source."{x}"' for x in df.columns])}"""
401+
UPDATE SET {", ".join([f'"{x}" = source."{x}"' for x in esc_columns])}"""
367402
else:
368403
match_condition = ""
369404

370405
if merge_match_nulls:
371-
merge_conditions = [f'(target."{x}" IS NOT DISTINCT FROM source."{x}")' for x in merge_cols]
406+
merge_conditions = [f'(target."{x}" IS NOT DISTINCT FROM source."{x}")' for x in esc_merge_cols]
372407
else:
373-
merge_conditions = [f'(target."{x}" = source."{x}")' for x in merge_cols]
408+
merge_conditions = [f'(target."{x}" = source."{x}")' for x in esc_merge_cols]
374409

375410
sql_statement = f"""
376-
MERGE INTO "{database}"."{table}" target
377-
USING "{database}"."{source_table}" source
411+
MERGE INTO "{esc_database}"."{esc_table}" target
412+
USING "{esc_database}"."{esc_source_table}" source
378413
ON {" AND ".join(merge_conditions)}
379414
{match_condition}
380415
WHEN NOT MATCHED THEN
381-
INSERT ({", ".join([f'"{x}"' for x in df.columns])})
382-
VALUES ({", ".join([f'source."{x}"' for x in df.columns])})
416+
INSERT ({", ".join([f'"{x}"' for x in esc_columns])})
417+
VALUES ({", ".join([f'source."{x}"' for x in esc_columns])})
383418
"""
384419
else:
385420
order_by_clause = _build_order_by_clause(partition_cols)
386421
sql_statement = f"""
387-
INSERT INTO "{database}"."{table}" ({", ".join([f'"{x}"' for x in df.columns])})
388-
SELECT {", ".join([f'"{x}"' for x in df.columns])}
389-
FROM "{database}"."{source_table}"
422+
INSERT INTO "{esc_database}"."{esc_table}" ({", ".join([f'"{x}"' for x in esc_columns])})
423+
SELECT {", ".join([f'"{x}"' for x in esc_columns])}
424+
FROM "{esc_database}"."{esc_source_table}"
390425
{order_by_clause}
391426
"""
392427

@@ -644,7 +679,7 @@ def to_iceberg( # noqa: PLR0913
644679
)
645680
# if mode == "overwrite", delete whole data from table (but not table itself)
646681
elif mode == "overwrite":
647-
delete_sql_statement = f"DELETE FROM {table}"
682+
delete_sql_statement = f'DELETE FROM "{_escape_athena_dml_identifier(table)}"'
648683
delete_query_execution_id: str = _start_query_execution(
649684
sql=delete_sql_statement,
650685
workgroup=workgroup,
@@ -829,10 +864,14 @@ def delete_from_iceberg_table(
829864
index=False,
830865
)
831866

867+
esc_database = _escape_athena_dml_identifier(database)
868+
esc_table = _escape_athena_dml_identifier(table)
869+
esc_temp_table = _escape_athena_dml_identifier(temp_table)
870+
esc_merge_cols = [_escape_athena_dml_identifier(x) for x in merge_cols]
832871
sql_statement = f"""
833-
MERGE INTO "{database}"."{table}" target
834-
USING "{database}"."{temp_table}" source
835-
ON {" AND ".join([f'target."{x}" = source."{x}"' for x in merge_cols])}
872+
MERGE INTO "{esc_database}"."{esc_table}" target
873+
USING "{esc_database}"."{esc_temp_table}" source
874+
ON {" AND ".join([f'target."{x}" = source."{x}"' for x in esc_merge_cols])}
836875
WHEN MATCHED THEN
837876
DELETE
838877
"""

tests/unit/test_athena_iceberg.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,3 +1270,101 @@ def test_build_order_by_clause_multiple() -> None:
12701270

12711271
result = _build_order_by_clause(["name", "day(ts)"])
12721272
assert result == 'ORDER BY "name", "ts"'
1273+
1274+
1275+
def test_escape_athena_dml_identifier_doubles_quotes() -> None:
1276+
from awswrangler.athena._write_iceberg import _escape_athena_dml_identifier
1277+
1278+
assert _escape_athena_dml_identifier("name") == "name"
1279+
assert _escape_athena_dml_identifier('a"b') == 'a""b'
1280+
assert _escape_athena_dml_identifier('x") DROP TABLE t --') == 'x"") DROP TABLE t --'
1281+
1282+
1283+
def test_build_order_by_clause_escapes_identifier() -> None:
1284+
from awswrangler.athena._write_iceberg import _build_order_by_clause
1285+
1286+
result = _build_order_by_clause(['a"b'])
1287+
assert result == 'ORDER BY "a""b"'
1288+
1289+
1290+
def test_escape_athena_ddl_identifier_doubles_backticks() -> None:
1291+
from awswrangler.athena._write_iceberg import _escape_athena_ddl_identifier
1292+
1293+
assert _escape_athena_ddl_identifier("name") == "name"
1294+
assert _escape_athena_ddl_identifier("a`b") == "a``b"
1295+
assert _escape_athena_ddl_identifier("x`) DROP TABLE t --") == "x``) DROP TABLE t --"
1296+
1297+
1298+
def test_alter_iceberg_add_columns_escapes_identifier() -> None:
1299+
# ALTER TABLE is Hive-based DDL: identifiers are backtick-quoted, escaped by doubling `.
1300+
from awswrangler.athena._write_iceberg import _alter_iceberg_table_add_columns_sql
1301+
1302+
result = _alter_iceberg_table_add_columns_sql(table="t`1", columns_to_add={"c`) x --": "bigint"})
1303+
assert result == ["ALTER TABLE `t``1` ADD COLUMNS (`c``) x --` bigint)"]
1304+
1305+
1306+
def test_alter_iceberg_change_columns_escapes_identifier() -> None:
1307+
from awswrangler.athena._write_iceberg import _alter_iceberg_table_change_columns_sql
1308+
1309+
result = _alter_iceberg_table_change_columns_sql(table="t`1", columns_to_change={"c`x": "bigint"})
1310+
assert result == ["ALTER TABLE `t``1` CHANGE COLUMN `c``x` `c``x` bigint"]
1311+
1312+
1313+
def test_merge_iceberg_escapes_malicious_column_name() -> None:
1314+
"""Column names from the Glue catalog must be escaped before being spliced into SQL."""
1315+
from unittest import mock
1316+
1317+
from awswrangler.athena import _write_iceberg
1318+
1319+
malicious = 'id") ; DROP TABLE victim --'
1320+
df = pd.DataFrame({malicious: [1], "v": [2]})
1321+
1322+
with mock.patch.object(_write_iceberg, "_get_workgroup_config", return_value=mock.MagicMock()), mock.patch.object(
1323+
_write_iceberg, "_start_query_execution", return_value="qid"
1324+
) as start, mock.patch.object(_write_iceberg, "wait_query"):
1325+
_write_iceberg._merge_iceberg(df=df, database="db", table="t", source_table="src")
1326+
1327+
sql = start.call_args.kwargs["sql"]
1328+
# The injected identifier must appear only in doubled-quote form, never as a raw closing quote.
1329+
assert '"id"") ; DROP TABLE victim --"' in sql
1330+
assert '"id") ;' not in sql
1331+
1332+
1333+
def test_create_iceberg_table_escapes_ddl_identifiers() -> None:
1334+
"""Table/column names spliced into CREATE TABLE DDL must be backtick-escaped."""
1335+
from unittest import mock
1336+
1337+
from awswrangler.athena import _write_iceberg
1338+
1339+
df = pd.DataFrame({"c`0": [1]})
1340+
1341+
with mock.patch.object(
1342+
_write_iceberg.catalog,
1343+
"extract_athena_types",
1344+
return_value=({"c`0": "bigint"}, {}),
1345+
), mock.patch.object(_write_iceberg, "_start_query_execution", return_value="qid") as start, mock.patch.object(
1346+
_write_iceberg, "wait_query"
1347+
):
1348+
_write_iceberg._create_iceberg_table(
1349+
df=df,
1350+
database="db",
1351+
table="t`1",
1352+
path="s3://bucket/t/",
1353+
wg_config=mock.MagicMock(),
1354+
partition_cols=None,
1355+
additional_table_properties=None,
1356+
)
1357+
1358+
sql = start.call_args.kwargs["sql"]
1359+
assert "CREATE TABLE IF NOT EXISTS `t``1`" in sql
1360+
assert "`c``0` bigint" in sql
1361+
1362+
1363+
def test_delete_from_iceberg_overwrite_escapes_table() -> None:
1364+
"""The DELETE FROM issued for mode='overwrite' must quote/escape the table name."""
1365+
from awswrangler.athena._write_iceberg import _escape_athena_dml_identifier
1366+
1367+
# Mirrors the splice in to_iceberg's overwrite branch.
1368+
table = 'v" ; DROP TABLE x --'
1369+
stmt = f'DELETE FROM "{_escape_athena_dml_identifier(table)}"'
1370+
assert stmt == 'DELETE FROM "v"" ; DROP TABLE x --"'

0 commit comments

Comments
 (0)