Skip to content

Commit 9d3e9f9

Browse files
Copilotbcantoni
andauthored
Fix CSV-aware user password redaction
Co-authored-by: bcantoni <58372+bcantoni@users.noreply.github.com>
1 parent 5298991 commit 9d3e9f9

2 files changed

Lines changed: 45 additions & 17 deletions

File tree

‎tableauserverclient/models/user_item.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import csv
12
import io
23
import warnings
34
import xml.etree.ElementTree as ET
@@ -471,7 +472,7 @@ class ColumnType(IntEnum):
471472
def create_user_from_line(line: str):
472473
if line is None or line is False or line == "\n" or line == "":
473474
return None
474-
values: list[str] = list(map(str.strip, line.strip().split(",")))
475+
values = UserItem.CSVImport._parse_csv_line(line)
475476
if len(values) > UserItem.CSVImport.COLUMN_COUNT:
476477
raise ValueError("Too many attributes for user import")
477478
username = values[UserItem.CSVImport.ColumnType.USERNAME]
@@ -521,6 +522,13 @@ def create_user_from_line(line: str):
521522
)
522523
return user
523524

525+
@staticmethod
526+
def _parse_csv_line(line: str) -> list[str]:
527+
values = next(csv.reader([line], strict=True), [])
528+
if not values:
529+
values = [""]
530+
return list(map(str.strip, values))
531+
524532
# Read through an entire CSV file meant for user import
525533
# Return the number of valid lines and a list of all the invalid lines
526534
@staticmethod
@@ -554,11 +562,17 @@ def _redact_password_column(line: str) -> str:
554562
body, ending = line[:-1], "\n"
555563
else:
556564
body, ending = line, ""
557-
fields = body.split(",")
565+
try:
566+
fields = UserItem.CSVImport._parse_csv_line(body)
567+
except csv.Error:
568+
return "<invalid CSV row redacted>" + ending
558569
pass_index = UserItem.CSVImport.ColumnType.PASS.value
559570
if len(fields) > pass_index:
560571
fields[pass_index] = "***"
561-
return ",".join(fields) + ending
572+
output = io.StringIO()
573+
csv.writer(output, lineterminator="").writerow(fields)
574+
return output.getvalue() + ending
575+
return line
562576

563577
# Some fields in the import file are restricted to specific values
564578
# Iterate through each field and validate the given value against hardcoded constraints
@@ -577,7 +591,7 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
577591
list(UserItem.CSVImport._AUTH_CANONICAL.values()), # auth - normalized before comparison
578592
]
579593

580-
line = list(map(str.strip, incoming.split(",")))
594+
line = UserItem.CSVImport._parse_csv_line(incoming)
581595
if len(line) > UserItem.CSVImport.COLUMN_COUNT:
582596
raise ValueError("Too many attributes for user import")
583597
username = line[UserItem.CSVImport.ColumnType.USERNAME.value]

‎test/test_user_model.py‎

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -203,25 +203,33 @@ def test_password_not_logged_when_line_invalid(caplog: pytest.LogCaptureFixture)
203203
assert secret not in combined, f"Password leaked into logs on invalid row: {combined!r}"
204204

205205

206-
def test_password_with_comma_partially_masks(caplog: pytest.LogCaptureFixture) -> None:
207-
"""A password containing commas is misaligned by the naive split parser: only the
208-
portion that lands in column 1 gets masked. The remaining fragments still leak.
209-
This documents the limitation — fully protecting passwords with embedded commas
210-
requires a proper CSV parser — but confirms that the column-1 mask holds even
211-
when the password value contains a comma."""
212-
line = "jsmith,hunter2,SECRETTAIL,creator,site,yes,jsmith@example.com"
206+
def test_password_with_comma_is_fully_redacted(caplog: pytest.LogCaptureFixture) -> None:
207+
"""A quoted comma in a password must not leak from invalid row output or logs."""
208+
line = 'jsmith,"hunter2,SECRETTAIL",John Smith,not-a-real-license,site,yes,jsmith@example.com'
209+
test_data = _mock_file_content([line])
213210
with caplog.at_level(logging.DEBUG, logger=logger.name):
214-
try:
215-
TSC.UserItem.CSVImport._validate_import_line_or_throw(line, logger)
216-
except Exception:
217-
pass # misaligned columns are expected to fail validation
211+
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
212+
assert valid == 0
213+
assert len(invalid) == 1
214+
assert "hunter2" not in invalid[0]
215+
assert "SECRETTAIL" not in invalid[0]
218216
combined = "\n".join(record.getMessage() for record in caplog.records)
219-
# Column 1 ("hunter2") is masked; the fragment that spilled into column 2
220-
# ("SECRETTAIL") is not — this is the documented limitation.
221217
assert "hunter2" not in combined
218+
assert "SECRETTAIL" not in combined
222219
assert _mask_present(caplog.records)
223220

224221

222+
def test_malformed_csv_row_is_not_returned_unredacted(caplog: pytest.LogCaptureFixture) -> None:
223+
secret = "SECRETTAIL"
224+
test_data = _mock_file_content([f'jsmith,"{secret}'])
225+
with caplog.at_level(logging.DEBUG, logger=logger.name):
226+
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
227+
assert valid == 0
228+
assert invalid == ["<invalid CSV row redacted>"]
229+
combined = "\n".join(record.getMessage() for record in caplog.records)
230+
assert secret not in combined
231+
232+
225233
def test_password_not_in_validate_attribute_value_error(
226234
caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
227235
) -> None:
@@ -310,6 +318,12 @@ def test_create_user_with_auth_column() -> None:
310318
assert user.auth_setting == "SAML", f"Expected SAML, got {user.auth_setting}"
311319

312320

321+
def test_create_user_with_comma_in_password() -> None:
322+
user = TSC.UserItem.CSVImport.create_user_from_line('username,"pass,word",fname,creator,none,yes,email')
323+
assert user is not None
324+
assert user.fullname == "fname"
325+
326+
313327
def test_too_many_columns_raises() -> None:
314328
with pytest.raises(ValueError):
315329
TSC.UserItem.CSVImport.create_user_from_line("u, p, n, creator, none, yes, email, SAML, extra")

0 commit comments

Comments
 (0)