Skip to content

Commit c8f3b53

Browse files
committed
fix(athena): escape identifiers in Iceberg SQL to prevent injection via Glue column names
1 parent de58132 commit c8f3b53

2 files changed

Lines changed: 92 additions & 17 deletions

File tree

awswrangler/athena/_write_iceberg.py

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

4141

42+
def _escape_athena_identifier(name: Any) -> str:
43+
# Used for identifiers (column/table names) spliced inside double-quote delimited
44+
# identifiers, e.g. "<name>". Column names can originate from the Glue Data Catalog
45+
# during automatic schema reconciliation, so they are not necessarily caller-trusted.
46+
#
47+
# Trino/Athena delimited identifiers escape an embedded double-quote by doubling it.
48+
# Without this, a name containing " closes the identifier and appends arbitrary SQL.
49+
return str(name).replace('"', '""')
50+
51+
4252
def _create_iceberg_table(
4353
df: pd.DataFrame,
4454
database: str,
@@ -216,9 +226,11 @@ def _alter_iceberg_table_add_columns_sql(
216226
table: str,
217227
columns_to_add: dict[str, str],
218228
) -> list[str]:
219-
add_cols_str = ", ".join([f"{col_name} {columns_to_add[col_name]}" for col_name in columns_to_add])
229+
add_cols_str = ", ".join(
230+
[f'"{_escape_athena_identifier(col_name)}" {columns_to_add[col_name]}' for col_name in columns_to_add]
231+
)
220232

221-
return [f"ALTER TABLE {table} ADD COLUMNS ({add_cols_str})"]
233+
return [f'ALTER TABLE "{_escape_athena_identifier(table)}" ADD COLUMNS ({add_cols_str})']
222234

223235

224236
def _alter_iceberg_table_change_columns_sql(
@@ -228,7 +240,9 @@ def _alter_iceberg_table_change_columns_sql(
228240
sql_statements = []
229241

230242
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}")
243+
escaped_table = _escape_athena_identifier(table)
244+
escaped_col = _escape_athena_identifier(col_name)
245+
sql_statements.append(f'ALTER TABLE "{escaped_table}" CHANGE COLUMN "{escaped_col}" "{escaped_col}" {col_type}')
232246

233247
return sql_statements
234248

@@ -290,7 +304,9 @@ def _build_order_by_clause(partition_cols: list[str] | None) -> str:
290304
if not partition_cols:
291305
return ""
292306

293-
order_cols = [f'"{_extract_column_from_partition_transform(col)}"' for col in partition_cols]
307+
order_cols = [
308+
f'"{_escape_athena_identifier(_extract_column_from_partition_transform(col))}"' for col in partition_cols
309+
]
294310
return f"ORDER BY {', '.join(order_cols)}"
295311

296312

@@ -359,34 +375,40 @@ def _merge_iceberg(
359375
"""
360376
wg_config: _WorkGroupConfig = _get_workgroup_config(session=boto3_session, workgroup=workgroup)
361377

378+
esc_database = _escape_athena_identifier(database)
379+
esc_table = _escape_athena_identifier(table)
380+
esc_source_table = _escape_athena_identifier(source_table)
381+
esc_columns = [_escape_athena_identifier(x) for x in df.columns]
382+
362383
sql_statement: str
363384
if merge_cols:
385+
esc_merge_cols = [_escape_athena_identifier(x) for x in merge_cols]
364386
if merge_condition == "update":
365387
match_condition = f"""WHEN MATCHED THEN
366-
UPDATE SET {", ".join([f'"{x}" = source."{x}"' for x in df.columns])}"""
388+
UPDATE SET {", ".join([f'"{x}" = source."{x}"' for x in esc_columns])}"""
367389
else:
368390
match_condition = ""
369391

370392
if merge_match_nulls:
371-
merge_conditions = [f'(target."{x}" IS NOT DISTINCT FROM source."{x}")' for x in merge_cols]
393+
merge_conditions = [f'(target."{x}" IS NOT DISTINCT FROM source."{x}")' for x in esc_merge_cols]
372394
else:
373-
merge_conditions = [f'(target."{x}" = source."{x}")' for x in merge_cols]
395+
merge_conditions = [f'(target."{x}" = source."{x}")' for x in esc_merge_cols]
374396

375397
sql_statement = f"""
376-
MERGE INTO "{database}"."{table}" target
377-
USING "{database}"."{source_table}" source
398+
MERGE INTO "{esc_database}"."{esc_table}" target
399+
USING "{esc_database}"."{esc_source_table}" source
378400
ON {" AND ".join(merge_conditions)}
379401
{match_condition}
380402
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])})
403+
INSERT ({", ".join([f'"{x}"' for x in esc_columns])})
404+
VALUES ({", ".join([f'source."{x}"' for x in esc_columns])})
383405
"""
384406
else:
385407
order_by_clause = _build_order_by_clause(partition_cols)
386408
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}"
409+
INSERT INTO "{esc_database}"."{esc_table}" ({", ".join([f'"{x}"' for x in esc_columns])})
410+
SELECT {", ".join([f'"{x}"' for x in esc_columns])}
411+
FROM "{esc_database}"."{esc_source_table}"
390412
{order_by_clause}
391413
"""
392414

@@ -829,10 +851,14 @@ def delete_from_iceberg_table(
829851
index=False,
830852
)
831853

854+
esc_database = _escape_athena_identifier(database)
855+
esc_table = _escape_athena_identifier(table)
856+
esc_temp_table = _escape_athena_identifier(temp_table)
857+
esc_merge_cols = [_escape_athena_identifier(x) for x in merge_cols]
832858
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])}
859+
MERGE INTO "{esc_database}"."{esc_table}" target
860+
USING "{esc_database}"."{esc_temp_table}" source
861+
ON {" AND ".join([f'target."{x}" = source."{x}"' for x in esc_merge_cols])}
836862
WHEN MATCHED THEN
837863
DELETE
838864
"""

tests/unit/test_athena_iceberg.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,3 +1270,52 @@ 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_identifier_doubles_quotes() -> None:
1276+
from awswrangler.athena._write_iceberg import _escape_athena_identifier
1277+
1278+
assert _escape_athena_identifier("name") == "name"
1279+
assert _escape_athena_identifier('a"b') == 'a""b'
1280+
assert _escape_athena_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_alter_iceberg_add_columns_escapes_identifier() -> None:
1291+
from awswrangler.athena._write_iceberg import _alter_iceberg_table_add_columns_sql
1292+
1293+
result = _alter_iceberg_table_add_columns_sql(table='t"1', columns_to_add={'c") x --': "bigint"})
1294+
assert result == ['ALTER TABLE "t""1" ADD COLUMNS ("c"") x --" bigint)']
1295+
1296+
1297+
def test_alter_iceberg_change_columns_escapes_identifier() -> None:
1298+
from awswrangler.athena._write_iceberg import _alter_iceberg_table_change_columns_sql
1299+
1300+
result = _alter_iceberg_table_change_columns_sql(table='t"1', columns_to_change={'c"x': "bigint"})
1301+
assert result == ['ALTER TABLE "t""1" CHANGE COLUMN "c""x" "c""x" bigint']
1302+
1303+
1304+
def test_merge_iceberg_escapes_malicious_column_name() -> None:
1305+
"""Column names from the Glue catalog must be escaped before being spliced into SQL."""
1306+
from unittest import mock
1307+
1308+
from awswrangler.athena import _write_iceberg
1309+
1310+
malicious = 'id") ; DROP TABLE victim --'
1311+
df = pd.DataFrame({malicious: [1], "v": [2]})
1312+
1313+
with mock.patch.object(_write_iceberg, "_get_workgroup_config", return_value=mock.MagicMock()), mock.patch.object(
1314+
_write_iceberg, "_start_query_execution", return_value="qid"
1315+
) as start, mock.patch.object(_write_iceberg, "wait_query"):
1316+
_write_iceberg._merge_iceberg(df=df, database="db", table="t", source_table="src")
1317+
1318+
sql = start.call_args.kwargs["sql"]
1319+
# The injected identifier must appear only in doubled-quote form, never as a raw closing quote.
1320+
assert '"id"") ; DROP TABLE victim --"' in sql
1321+
assert '"id") ;' not in sql

0 commit comments

Comments
 (0)