Skip to content

Commit 202df52

Browse files
committed
fix(cli): stop interpreting table content as console markup
The CLI passed table and view content straight to Rich as plain strings, so square-bracket sequences in a property key or value, a column name or doc, a ref name, an identifier or a path were parsed as style tags instead of text. Content therefore controlled the styling of the output, and unbalanced tags could alter how surrounding rows rendered. Disable markup on the consoles the CLI writes through, rather than escaping each call site, so every current and future render is covered. Closes #3984
1 parent 1312c55 commit 202df52

2 files changed

Lines changed: 93 additions & 13 deletions

File tree

pyiceberg/cli/output.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -87,18 +87,23 @@ def __init__(self, **properties: Any) -> None:
8787
def _table(self) -> RichTable:
8888
return RichTable.grid(padding=(0, 2))
8989

90+
def _console(self, *, stderr: bool = False, soft_wrap: bool = False) -> Console:
91+
# Identifiers, properties, schemas and paths all originate outside the CLI,
92+
# so console markup stays disabled to keep them from styling the output.
93+
return Console(markup=False, stderr=stderr, soft_wrap=soft_wrap)
94+
9095
def exception(self, ex: Exception) -> None:
9196
if self.verbose:
92-
Console(stderr=True).print_exception()
97+
self._console(stderr=True).print_exception()
9398
else:
94-
Console(stderr=True).print(ex)
99+
self._console(stderr=True).print(ex)
95100

96101
def identifiers(self, identifiers: list[Identifier]) -> None:
97102
table = self._table
98103
for identifier in identifiers:
99104
table.add_row(".".join(identifier))
100105

101-
Console().print(table)
106+
self._console().print(table)
102107

103108
def describe_table(self, table: Table) -> None:
104109
metadata = table.metadata
@@ -126,7 +131,7 @@ def describe_table(self, table: Table) -> None:
126131
output_table.add_row("Current snapshot", str(table.current_snapshot()))
127132
output_table.add_row("Snapshots", snapshot_tree)
128133
output_table.add_row("Properties", table_properties)
129-
Console().print(output_table)
134+
self._console().print(output_table)
130135

131136
def describe_view(self, view: View) -> None:
132137
metadata = view.metadata
@@ -151,7 +156,7 @@ def describe_view(self, view: View) -> None:
151156
output_table.add_row("Current schema", schema_tree)
152157
output_table.add_row("SQL", representations_tree)
153158
output_table.add_row("Properties", view_properties)
154-
Console().print(output_table)
159+
self._console().print(output_table)
155160

156161
def files(self, table: Table, history: bool) -> None:
157162
if history:
@@ -175,31 +180,31 @@ def files(self, table: Table, history: bool) -> None:
175180
manifest_tree = list_tree.add(f"Manifest: {manifest.manifest_path}")
176181
for manifest_entry in manifest.fetch_manifest_entry(io, discard_deleted=False):
177182
manifest_tree.add(f"Datafile: {manifest_entry.data_file.file_path}")
178-
Console().print(snapshot_tree)
183+
self._console().print(snapshot_tree)
179184

180185
def describe_properties(self, properties: Properties) -> None:
181186
output_table = self._table
182187
for k, v in properties.items():
183188
output_table.add_row(k, v)
184-
Console().print(output_table)
189+
self._console().print(output_table)
185190

186191
def text(self, response: str) -> None:
187-
Console(soft_wrap=True).print(response)
192+
self._console(soft_wrap=True).print(response)
188193

189194
def schema(self, schema: Schema) -> None:
190195
output_table = self._table
191196
for field in schema.fields:
192197
output_table.add_row(field.name, str(field.field_type), field.doc or "")
193-
Console().print(output_table)
198+
self._console().print(output_table)
194199

195200
def spec(self, spec: PartitionSpec) -> None:
196-
Console().print(str(spec))
201+
self._console().print(str(spec))
197202

198203
def uuid(self, uuid: UUID | None) -> None:
199-
Console().print(str(uuid) if uuid else "missing")
204+
self._console().print(str(uuid) if uuid else "missing")
200205

201206
def version(self, version: str) -> None:
202-
Console().print(version)
207+
self._console().print(version)
203208

204209
def describe_refs(self, ref_details: list[tuple[str, SnapshotRefType, dict[str, str]]]) -> None:
205210
refs_table = RichTable(title="Snapshot Refs")
@@ -212,7 +217,7 @@ def describe_refs(self, ref_details: list[tuple[str, SnapshotRefType, dict[str,
212217
refs_table.add_row(
213218
name, type, ref_detail["max_ref_age_ms"], ref_detail["min_snapshots_to_keep"], ref_detail["max_snapshot_age_ms"]
214219
)
215-
Console().print(refs_table)
220+
self._console().print(refs_table)
216221

217222

218223
class JsonOutput(Output):

tests/cli/test_console.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,17 @@ def test_describe_namespace_does_not_exists(catalog: InMemoryCatalog) -> None:
184184
assert result.output == "Namespace doesnotexist does not exists\n"
185185

186186

187+
def test_describe_namespace_property_with_rich_markup_is_rendered_literally(catalog: InMemoryCatalog) -> None:
188+
malicious_value = "[bold red]injected[/]"
189+
catalog.create_namespace(TEST_TABLE_NAMESPACE, {"malicious": malicious_value})
190+
191+
runner = CliRunner()
192+
result = runner.invoke(run, ["describe", "--entity", "namespace", "default"])
193+
194+
assert result.exit_code == 0
195+
assert malicious_value in result.output
196+
197+
187198
@pytest.fixture()
188199
def test_describe_table(catalog: InMemoryCatalog, mock_datetime_now: None) -> None:
189200
catalog.create_table(
@@ -227,6 +238,25 @@ def test_describe_table_does_not_exists(catalog: InMemoryCatalog) -> None:
227238
assert result.output == "Table, view, or namespace does not exist: default.doesnotexist\n"
228239

229240

241+
def test_describe_table_property_with_rich_markup_is_rendered_literally(
242+
catalog: InMemoryCatalog, mock_datetime_now: None
243+
) -> None:
244+
malicious_value = "[bold red]injected[/]"
245+
catalog.create_namespace(TEST_TABLE_NAMESPACE)
246+
catalog.create_table(
247+
identifier=TEST_TABLE_IDENTIFIER,
248+
schema=TEST_TABLE_SCHEMA,
249+
partition_spec=TEST_TABLE_PARTITION_SPEC,
250+
properties={"malicious": malicious_value},
251+
)
252+
253+
runner = CliRunner()
254+
result = runner.invoke(run, ["describe", "default.my_table"])
255+
256+
assert result.exit_code == 0
257+
assert malicious_value in result.output
258+
259+
230260
@pytest.mark.parametrize("entity_args", [[], ["--entity", "table"]], ids=["any", "table"])
231261
def test_describe_table_entity_detection(catalog: InMemoryCatalog, mock_datetime_now: None, entity_args: list[str]) -> None:
232262
catalog.create_namespace(TEST_TABLE_NAMESPACE)
@@ -277,6 +307,38 @@ def test_schema(catalog: InMemoryCatalog) -> None:
277307
)
278308

279309

310+
def test_schema_field_with_rich_markup_is_rendered_literally(catalog: InMemoryCatalog) -> None:
311+
markup = "[bold red]injected[/]"
312+
catalog.create_namespace(TEST_TABLE_NAMESPACE)
313+
catalog.create_table(
314+
identifier=TEST_TABLE_IDENTIFIER,
315+
schema=Schema(NestedField(1, markup, LongType(), required=False, doc=markup)),
316+
)
317+
318+
runner = CliRunner()
319+
result = runner.invoke(run, ["schema", "default.my_table"])
320+
321+
assert result.exit_code == 0
322+
assert result.output.count(markup) == 2
323+
324+
325+
def test_describe_table_schema_field_with_rich_markup_is_rendered_literally(
326+
catalog: InMemoryCatalog, mock_datetime_now: None
327+
) -> None:
328+
markup = "[bold red]injected[/]"
329+
catalog.create_namespace(TEST_TABLE_NAMESPACE)
330+
catalog.create_table(
331+
identifier=TEST_TABLE_IDENTIFIER,
332+
schema=Schema(NestedField(1, markup, LongType(), required=False)),
333+
)
334+
335+
runner = CliRunner()
336+
result = runner.invoke(run, ["describe", "default.my_table"])
337+
338+
assert result.exit_code == 0
339+
assert markup in result.output
340+
341+
280342
def test_schema_does_not_exists(catalog: InMemoryCatalog) -> None:
281343
# pylint: disable=unused-argument
282344

@@ -1242,6 +1304,19 @@ def test_describe_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None:
12421304
assert "spark: SELECT * FROM my_table" in result.output
12431305

12441306

1307+
def test_describe_view_property_with_rich_markup_is_rendered_literally(catalog: InMemoryCatalog) -> None:
1308+
malicious_value = "[bold red]injected[/]"
1309+
view_metadata = {**TEST_VIEW_METADATA, "properties": {"malicious": malicious_value}}
1310+
view = View(TEST_VIEW_IDENTIFIER, ViewMetadata.model_validate(view_metadata))
1311+
catalog.load_view = MagicMock(return_value=view) # type: ignore
1312+
1313+
runner = CliRunner()
1314+
result = runner.invoke(run, ["describe", "--entity=view", "default.my_view"])
1315+
1316+
assert result.exit_code == 0
1317+
assert malicious_value in result.output
1318+
1319+
12451320
def test_describe_view_does_not_exist(catalog: InMemoryCatalog) -> None:
12461321
catalog.load_view = MagicMock(side_effect=NoSuchViewError("View does not exist: default.doesnotexist")) # type: ignore
12471322

0 commit comments

Comments
 (0)