Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@

## Unreleased

* Security: `UserItem.CSVImport` no longer logs the password column when
validating a user-import CSV file. The password field was previously written
to any caller-supplied logger at DEBUG level, and the raw row was returned in
`validate_file_for_import`'s `invalid_lines` list unmasked. Fixes #1829.
* Added `Projects.get_by_path(path)` to look up a project by its slash-separated
hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by
level using the REST API name filter, so a path with *n* components issues *n*
Expand Down
29 changes: 21 additions & 8 deletions tableauserverclient/models/user_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,17 +478,29 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l
csv_file.seek(0) # set to start of file in case it has been read earlier
line: str = csv_file.readline()
while line and line != "":
# Log only the username (column 0); the rest of the line contains the password (column 1) and other PII.
username = line.partition(",")[0].strip()
try:
# do not print passwords
logger.info(f"Reading user {line[:4]}")
logger.debug(f"Reading user {username}")
UserItem.CSVImport._validate_import_line_or_throw(line, logger)
num_valid_lines += 1
except Exception as exc:
logger.info(f"Error parsing {line[:4]}: {exc}")
invalid_lines.append(line)
logger.debug(f"Error parsing user {username}: {exc}")
invalid_lines.append(UserItem.CSVImport._redact_password_column(line))
line = csv_file.readline()
return num_valid_lines, invalid_lines

# Return a copy of a raw CSV line with the password column replaced by "***".
# Callers that log or expose invalid rows will not disclose the credential.
@staticmethod
def _redact_password_column(line: str) -> str:
trailing_newline = "\n" if line.endswith("\n") else ""
fields = line.rstrip("\n").split(",")
pass_index = UserItem.CSVImport.ColumnType.PASS.value
if len(fields) > pass_index:
fields[pass_index] = "***"
return ",".join(fields) + trailing_newline
Comment thread
jacalata marked this conversation as resolved.
Outdated

# Some fields in the import file are restricted to specific values
# Iterate through each field and validate the given value against hardcoded constraints
@staticmethod
Expand All @@ -511,10 +523,11 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
logger.debug(f"> details - {username}")
UserItem.validate_username_or_throw(username)
for i in range(1, len(line)):
logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}")
UserItem.CSVImport._validate_attribute_value(
line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i)
)
column = UserItem.CSVImport.ColumnType(i)
# Mask the password column so it never reaches log handlers.
safe_value = "***" if column == UserItem.CSVImport.ColumnType.PASS else line[i]
logger.debug(f"column {column.name}: {safe_value}")
UserItem.CSVImport._validate_attribute_value(line[i], _valid_attributes[i], column)

# Given a restricted set of possible values, confirm the item is in that set
@staticmethod
Expand Down
69 changes: 69 additions & 0 deletions test/test_user_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,72 @@ def test_validate_usernames_file() -> None:
test_data = _mock_file_content(usernames)
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + len(invalid)}"


def _mask_present(records: list) -> bool:
combined = "\n".join(record.getMessage() for record in records)
return "PASS" in combined and "***" in combined


def test_password_not_logged_at_debug(caplog: pytest.LogCaptureFixture) -> None:
"""Regression test for #1829: passwords must not appear in DEBUG logs."""
secret = "hunter2SUPERSECRET"
line = f"jsmith,{secret},John Smith,creator,site,yes,jsmith@example.com"
with caplog.at_level(logging.DEBUG, logger=logger.name):
TSC.UserItem.CSVImport._validate_import_line_or_throw(line, logger)
combined = "\n".join(record.getMessage() for record in caplog.records)
assert secret not in combined, f"Password leaked into logs: {combined!r}"
# Positive assertion: something references the PASS column and something is
# masked as ***, so a "fix" that only removed the log line would not pass.
assert _mask_present(caplog.records), f"Expected masked PASS log line; got: {combined!r}"


def test_password_not_logged_when_line_invalid(caplog: pytest.LogCaptureFixture) -> None:
"""Regression test for #1829: passwords must not appear when a row fails to validate."""
secret = "hunter2SUPERSECRET"
line = f"jsmith,{secret},John Smith,not-a-real-license,site,yes,jsmith@example.com"
test_data = _mock_file_content([line])
with caplog.at_level(logging.DEBUG, logger=logger.name):
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
assert valid == 0
assert len(invalid) == 1
assert secret not in invalid[0], f"Password leaked into returned invalid_lines: {invalid[0]!r}"
combined = "\n".join(record.getMessage() for record in caplog.records)
assert secret not in combined, f"Password leaked into logs on invalid row: {combined!r}"


def test_password_with_comma_partially_masks(caplog: pytest.LogCaptureFixture) -> None:
"""A password containing commas is misaligned by the naive split parser: only the
portion that lands in column 1 gets masked. The remaining fragments still leak.
This documents the limitation — fully protecting passwords with embedded commas
requires a proper CSV parser — but confirms that the column-1 mask holds even
when the password value contains a comma."""
line = "jsmith,hunter2,SECRETTAIL,creator,site,yes,jsmith@example.com"
with caplog.at_level(logging.DEBUG, logger=logger.name):
try:
TSC.UserItem.CSVImport._validate_import_line_or_throw(line, logger)
except Exception:
pass # misaligned columns are expected to fail validation
combined = "\n".join(record.getMessage() for record in caplog.records)
# Column 1 ("hunter2") is masked; the fragment that spilled into column 2
# ("SECRETTAIL") is not — this is the documented limitation.
assert "hunter2" not in combined
assert _mask_present(caplog.records)


def test_redact_password_column_helper() -> None:
"""Unit-level coverage for _redact_password_column across newline and edge cases."""
redact = TSC.UserItem.CSVImport._redact_password_column
# LF-terminated
assert redact("jsmith,hunter2,fname\n") == "jsmith,***,fname\n"
# CRLF-terminated (the \r rides with the last field, ending is preserved)
assert redact("jsmith,hunter2,fname\r\n") == "jsmith,***,fname\r\n"
# No trailing newline
assert redact("jsmith,hunter2,fname") == "jsmith,***,fname"
Comment thread
jacalata marked this conversation as resolved.
# Empty password field: still replaced (unconditional mask)
assert redact("jsmith,,fname") == "jsmith,***,fname"
# Trailing comma with nothing after: column 1 exists as empty string, gets masked
assert redact("jsmith,") == "jsmith,***"
# Single column: no password to redact; return line unchanged
assert redact("jsmith") == "jsmith"
assert redact("jsmith\n") == "jsmith\n"
Loading