bugfix: only list files with proper extension delimited by dot; fix UX showing suffixes in file_picker when no suitable files found
What changed, and why it matters
This commit fixes a file-picker bug in the COLDCARD hardware wallet firmware. Previously, callers passed file extensions without a leading dot (e.g., 'txt' or 'psbt'), so the picker would match any filename ending in those characters, not just proper dotted extensions. A file named 'backuptxt' could be treated as a .txt file, and 'backup7z' as a .7z backup. The patch forces every extension to start with a dot and updates all callers. The main risk is that a maliciously named file on the SD card could be offered to the user as if it were a valid PSBT, backup, text message, or other sensitive file type, potentially tricking the user into signing or restoring from the wrong data.
Treat this as a low-to-moderate security hardening fix. Ensure the patched firmware is included in the next release, and consider whether any downstream documentation or third-party integrations still pass undotted suffixes to file_picker. No immediate incident response is required unless users were already tricked by crafted filenames.
Security signals we found
File-extension filter bypass due to missing leading dot in suffix matching
Potential UI confusion / social-engineering via maliciously named SD-card files
Regression test added to prevent reintroduction of the bypass
Assertion added to enforce dotted-extension invariant in file_picker
Evidence from the diff
The shared file_picker() function filters SD-card files by suffix using str.endswith(). Callers historically passed suffixes without a leading period, so the match was purely substring-based at the end of the filename. The patch adds an assertion that every suffix begins with ‘.’ and updates all call sites to use dotted forms (‘.txt’, ‘.psbt’, ‘.aes’, ‘.csv’, ‘.json’, ‘.7z’, ‘.pdf’). A new regression test verifies that files like ‘backup7z’, ‘backuptxt’, and ‘template:pdf’ are rejected and that the no-files message lists the expected dotted suffixes. The bug is a logic/filtering error, not a buffer overflow or cryptographic flaw, but it could enable UI-level confusion or social-engineering attacks where an attacker places a crafted file on the victim’s SD card.
Changed components
shared/actions.pyshared/ccc.pyshared/tapsigner.pyshared/teleport.pytesting/test_ux.pyInspect captured patch +54 / −9
diff --git a/shared/actions.py b/shared/actions.py
index 6910aba..dfb371c 100644
--- a/shared/actions.py
+++ b/shared/actions.py
@@ -1397,7 +1397,7 @@ async def import_xprv(_1, _2, item):
else:
# only get here if NFC was not chosen
# pick a likely-looking file.
- fn = await file_picker(suffix='txt', min_size=50, max_size=2000, taster=contains_xprv,
+ fn = await file_picker(suffix='.txt', min_size=50, max_size=2000, taster=contains_xprv,
none_msg="Must contain " + label + ".", **choice)
if not fn: return
@@ -1728,6 +1728,8 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
# - escape: allow these chars to skip picking process
# - slot_b: None=>pick slot w/ card in it, or A if both.
# - allow_batch: adds an "all of the above" choice: ("menu label", menu_handler)
+ # suffix argument MUST contain the dot (.), if list of suffixes - all MUST contain the dot
+
if choices is None:
choices = []
@@ -1744,6 +1746,8 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
if suffix:
if not isinstance(suffix, list):
suffix = [suffix]
+
+ assert all(s[0] == "." for s in suffix)
if not any([fn.lower().endswith(s) for s in suffix]):
continue
@@ -1793,7 +1797,7 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
if none_msg:
msg += none_msg
if suffix:
- msg += '\n\nThe filename must end in %r. ' % suffix
+ msg += '\n\nThe filename must end in: %s' % ",".join(["*" + s for s in suffix])
msg += '\n\nMaybe insert (another) SD card and try again?'
@@ -1873,7 +1877,7 @@ async def _batch_sign(choices=None):
return
assert isinstance(picked, dict)
- choices = await file_picker(suffix='psbt', min_size=50, ux=False,
+ choices = await file_picker(suffix='.psbt', min_size=50, ux=False,
max_size=MAX_TXN_LEN, taster=is_psbt, **picked)
if not choices:
@@ -1911,7 +1915,7 @@ async def ready2sign(*a):
opt = {}
# just check if we have candidates, no UI
- choices = await file_picker(suffix='psbt', min_size=50, ux=False,
+ choices = await file_picker(suffix='.psbt', min_size=50, ux=False,
max_size=MAX_TXN_LEN, taster=is_psbt)
if pa.tmp_value:
@@ -1938,7 +1942,7 @@ from your desktop wallet software or command line tools.'''
title=title)
if isinstance(picked, dict):
opt = picked # reset options to what was chosen by user
- choices = await file_picker(suffix='psbt', min_size=50, ux=False,
+ choices = await file_picker(suffix='.psbt', min_size=50, ux=False,
max_size=MAX_TXN_LEN, taster=is_psbt,
**opt)
if not choices:
@@ -1980,7 +1984,7 @@ async def sign_message_on_sd(*a):
# min 1 line max 3 lines
return 1 <= len(lines) <= 3
- fn = await file_picker(suffix=['txt', "json"], min_size=2, max_size=500, taster=is_signable,
+ fn = await file_picker(suffix=['.txt', ".json"], min_size=2, max_size=500, taster=is_signable,
none_msg=('Must be txt file with one msg line, optionally '
'followed by a subkey derivation path on a second line '
'and/or address format on third line. JSON msg signing '
diff --git a/shared/ccc.py b/shared/ccc.py
index 641aa0d..bdcca00 100644
--- a/shared/ccc.py
+++ b/shared/ccc.py
@@ -587,7 +587,7 @@ class SPAddrWhitelist(MenuSystem):
pat = re.compile(r'[^A-Za-z0-9]')
# pick a likely-looking file: just looking at size and extension
- fn = await file_picker(suffix=['csv', 'txt'],
+ fn = await file_picker(suffix=['.csv', '.txt'],
min_size=20, max_size=20000,
none_msg="Must contain payment addresses", **choice)
diff --git a/shared/tapsigner.py b/shared/tapsigner.py
index e8def94..9bce68d 100644
--- a/shared/tapsigner.py
+++ b/shared/tapsigner.py
@@ -67,7 +67,7 @@ async def import_tapsigner_backup_file(_1, _2, item):
continue
break
else:
- fn = await file_picker(suffix="aes", min_size=100, max_size=160, **choice)
+ fn = await file_picker(suffix=".aes", min_size=100, max_size=160, **choice)
if not fn: return
origin += (" (%s)" % fn)
try:
diff --git a/shared/teleport.py b/shared/teleport.py
index 37f8db3..0594189 100644
--- a/shared/teleport.py
+++ b/shared/teleport.py
@@ -734,7 +734,7 @@ async def kt_send_file_psbt(*a):
picked = await import_export_prompt("PSBT", is_import=True, no_nfc=True, no_qr=True)
if picked == KEY_CANCEL:
return
- choices = await file_picker(suffix='psbt', min_size=50, ux=False,
+ choices = await file_picker(suffix='.psbt', min_size=50, ux=False,
max_size=MAX_TXN_LEN, taster=is_psbt, **picked)
if not choices:
# error msg already shown
diff --git a/testing/test_ux.py b/testing/test_ux.py
index afb1eea..bd206cd 100644
--- a/testing/test_ux.py
+++ b/testing/test_ux.py
@@ -1155,6 +1155,47 @@ def test_q1_24_8char_words(set_seed_words, is_q1, goto_home, pick_menu_item, pre
assert w0 == w1 == w2 == word
+def test_file_picker_suffixes(pick_menu_item, goto_home, cap_story, microsd_wipe, press_select,
+ microsd_path):
+ # make sure no .txt, .7z & .pdf files are not on the SD card
+ microsd_wipe()
+ # create files that must not be recognized, because they're missing the dot
+ for fn in ["backup7z", "backuptxt", "template:pdf"]:
+ with open(microsd_path(fn), "w") as f:
+ f.write("dummy")
+
+ goto_home()
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item("Danger Zone")
+ pick_menu_item("I Am Developer.")
+ pick_menu_item("Restore Bkup")
+ time.sleep(.1)
+ _, story = cap_story()
+ assert "No suitable files found" in story
+ assert "The filename must end in: *.7z,*.txt" in story
+ press_select()
+
+ goto_home()
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item("Paper Wallets")
+ press_select()
+ pick_menu_item("Don't make PDF")
+ time.sleep(.1)
+ _, story = cap_story()
+ assert "No suitable files found" in story
+ assert "The filename must end in: *.pdf" in story
+
+ goto_home()
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item("File Management")
+ pick_menu_item("Sign Text File")
+ time.sleep(.1)
+ _, story = cap_story()
+ assert "No suitable files found" in story
+ assert "The filename must end in: *.txt,*.json" in story
+ microsd_wipe()
+
+
@pytest.mark.onetime
def test_dump_menutree(sim_execfile):
# saves to ../unix/work/menudump.txt
Why this scored 40/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.