Package: release.debian.org Severity: normal Tags: trixie X-Debbugs-Cc: [email protected], Debian Security Team <[email protected]>, [email protected], [email protected] Control: affects -1 + src:onionshare User: [email protected] Usertags: pu
[ Reason ] Fixes two upstream advisories: https://bugs.debian.org/1139716 -> https://security-tracker.debian.org/tracker/TEMP-1139716-6892B6 -> https://github.com/onionshare/onionshare/security/advisories/GHSA-v833-3823-cmhp https://bugs.debian.org/1139717 -> https://security-tracker.debian.org/tracker/TEMP-1139717-36B614 -> https://github.com/onionshare/onionshare/security/advisories/GHSA-22p9-r2f5-22mf Both issues are fixed with 2.6.4-1 in Forky already. Unfortunately 2.6.4 updated unrelated things too. So I backported the relevant patches to fix the two issues including the additional unit tests to 2.6.3 for Trixie. The securtiy team already marked those two issues as no-dsa, so the way to fix it is via p-u: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1139716#20 [ Impact ] The user will still be affected by this issues: - Allows unintended disclosure of local files. - Allow a remote sender to create directories on your device. [ Tests ] Build the package and let the autopkgtests run, that has the additional tests added. [ Risks ] The patches come from upstream and could applied without changes on 2.6.3. The affected files are not changed by other commits in the diff 2.6.3..2.6.4. So a hidden side-effect is unlikely. The patches itself are small and focused. The existing tests already tests a big portion of the usages and the additional tests care about the two issues. IMO the risk to introduce new bugs is small. [ Checklist ] [x] *all* changes are documented in the d/changelog [x] I reviewed all changes and I approve them [x] attach debdiff against the package in stable [x] the issue is verified as fixed in unstable [ Changes ] * Backport six upstream commits * Updated debian branch in debian/gbp.conf
diff -Nru onionshare-2.6.3/debian/changelog onionshare-2.6.3/debian/changelog --- onionshare-2.6.3/debian/changelog 2025-03-05 02:53:15.000000000 +0100 +++ onionshare-2.6.3/debian/changelog 2026-07-11 13:34:34.000000000 +0200 @@ -1,3 +1,19 @@ +onionshare (2.6.3-1+deb13u1) trixie; urgency=medium + + * Update debian branch in gbp.conf. + * Fixes TEMP-1139716-6892B6. (Closes: #1139716) + Backport upstream patches from 2.6.4: + - a090e97193efc91fbeac9dace7793ea568b83cf5 + - e4ed559908b55cf41b993ed3d02052911025785b + - 1d76494f5d819adc4c69f08f3842d564a425aa89 + * Fixes TEMP-1139717-36B614. (Closes: #1139717) + Backport upstream patches from 2.6.4: + - 48f31cfac077fcc9c04c67c2a6dbf87d956f5eec + - 96827bdd0580bd34b921a7b269198f65434178d8 + - d0697fa3d05a193138c6052422c313612301d98a + + -- Sandro Knauß <[email protected]> Sat, 11 Jul 2026 13:34:34 +0200 + onionshare (2.6.3-1) unstable; urgency=medium * New upstream release (Closes: #1070349, #1083519). diff -Nru onionshare-2.6.3/debian/gbp.conf onionshare-2.6.3/debian/gbp.conf --- onionshare-2.6.3/debian/gbp.conf 2025-03-05 02:53:15.000000000 +0100 +++ onionshare-2.6.3/debian/gbp.conf 2026-07-11 12:43:13.000000000 +0200 @@ -1,4 +1,4 @@ [DEFAULT] pristine-tar = True -debian-branch = debian/sid +debian-branch = debian/trixie upstream-branch = master diff -Nru onionshare-2.6.3/debian/.gitignore onionshare-2.6.3/debian/.gitignore --- onionshare-2.6.3/debian/.gitignore 2025-03-05 02:53:15.000000000 +0100 +++ onionshare-2.6.3/debian/.gitignore 1970-01-01 01:00:00.000000000 +0100 @@ -1 +0,0 @@ -files diff -Nru onionshare-2.6.3/debian/patches/0005-Prevent-writing-files-in-Receive-mode-when-file-uplo.patch onionshare-2.6.3/debian/patches/0005-Prevent-writing-files-in-Receive-mode-when-file-uplo.patch --- onionshare-2.6.3/debian/patches/0005-Prevent-writing-files-in-Receive-mode-when-file-uplo.patch 1970-01-01 01:00:00.000000000 +0100 +++ onionshare-2.6.3/debian/patches/0005-Prevent-writing-files-in-Receive-mode-when-file-uplo.patch 2026-07-11 13:30:40.000000000 +0200 @@ -0,0 +1,229 @@ +From: Miguel Jacq <[email protected]> +Date: Sat, 30 May 2026 16:35:24 +1000 +Subject: Prevent writing files in Receive mode when file uploads are disabled + +Origin: upstream, commit:a090e97193efc91fbeac9dace7793ea568b83cf5 +Bug: https://github.com/onionshare/onionshare/security/advisories/GHSA-v833-3823-cmhp +Bug-Debian: https://bugs.debian.org/1139716 +--- + cli/onionshare_cli/web/receive_mode.py | 61 +++++++++++++++++++++---- + desktop/tests/test_gui_receive.py | 82 +++++++++++++++++++++++++++++++++- + 2 files changed, 133 insertions(+), 10 deletions(-) + +diff --git a/cli/onionshare_cli/web/receive_mode.py b/cli/onionshare_cli/web/receive_mode.py +index a25f82a..a5a208a 100644 +--- a/cli/onionshare_cli/web/receive_mode.py ++++ b/cli/onionshare_cli/web/receive_mode.py +@@ -22,6 +22,7 @@ import os + import tempfile + import json + import requests ++import io + from datetime import datetime + from flask import Request, request, render_template, make_response, flash, redirect + from werkzeug.utils import secure_filename +@@ -91,10 +92,12 @@ class ReceiveModeWeb: + static_url_path=self.web.static_url_path, + disable_text=self.web.settings.get("receive", "disable_text"), + disable_files=self.web.settings.get("receive", "disable_files"), +- title=self.web.settings.get("general", "title") ++ title=self.web.settings.get("general", "title"), + ) + +- @self.web.app.route("/upload", methods=["POST"], provide_automatic_options=False) ++ @self.web.app.route( ++ "/upload", methods=["POST"], provide_automatic_options=False ++ ) + def upload(ajax=False): + """ + Handle the upload files POST request, though at this point, the files have +@@ -133,6 +136,18 @@ class ReceiveModeWeb: + print(f"Received: {local_path}") + + files_received = len(filenames) ++ else: ++ # Check if files were submitted when disabled ++ files = request.files.getlist("file[]") ++ if files and any(f.filename != "" for f in files): ++ request.upload_error = True ++ if ajax: ++ return json.dumps( ++ {"error_flashes": ["File uploads are not allowed"]} ++ ) ++ else: ++ flash("File uploads are not allowed", "error") ++ return redirect("/") + + # Send webhook if configured + if ( +@@ -194,10 +209,10 @@ class ReceiveModeWeb: + if files_received > 0: + msg = f"Uploaded {files_msg}" + else: +- if not self.web.settings.get("receive", "disable_text"): +- msg = "Nothing submitted or message was too long (> 524288 characters)" +- else: +- msg = "Nothing submitted" ++ if not self.web.settings.get("receive", "disable_text"): ++ msg = "Nothing submitted or message was too long (> 524288 characters)" ++ else: ++ msg = "Nothing submitted" + + if ajax: + info_flashes.append(msg) +@@ -228,7 +243,9 @@ class ReceiveModeWeb: + title=self.web.settings.get("general", "title"), + ) + +- @self.web.app.route("/upload-ajax", methods=["POST"], provide_automatic_options=False) ++ @self.web.app.route( ++ "/upload-ajax", methods=["POST"], provide_automatic_options=False ++ ) + def upload_ajax_public(): + if not self.can_upload: + return self.web.error403() +@@ -377,6 +394,7 @@ class ReceiveModeRequest(Request): + + # No errors yet + self.upload_error = False ++ self.file_upload_rejected = False + + # Figure out what files should be saved + now = datetime.now() +@@ -523,6 +541,16 @@ class ReceiveModeRequest(Request): + writable stream. + """ + if self.upload_request: ++ # Check if file uploads are disabled - reject file streams early ++ if self.web.settings.get("receive", "disable_files"): ++ self.web.common.log( ++ "ReceiveModeRequest", ++ "_get_file_stream", ++ "File upload rejected: disable_files is enabled", ++ ) ++ self.file_upload_rejected = True ++ return io.BytesIO() ++ + self.tell_gui_request_started() + + self.filename = secure_filename(filename) +@@ -586,9 +614,24 @@ class ReceiveModeRequest(Request): + self.web.receive_mode.uploads_in_progress.remove(history_id) + + # If no files were written to self.receive_mode_dir, delete it ++ # Also delete if file uploads were rejected due to disable_files setting + try: +- if len(os.listdir(self.receive_mode_dir)) == 0: +- os.rmdir(self.receive_mode_dir) ++ if ( ++ len(os.listdir(self.receive_mode_dir)) == 0 ++ or self.file_upload_rejected ++ ): ++ # Only delete if there are no actual files (messages are valid) ++ files = os.listdir(self.receive_mode_dir) ++ if len(files) == 0: ++ os.rmdir(self.receive_mode_dir) ++ elif ( ++ self.file_upload_rejected ++ and len(files) == 1 ++ and files[0].endswith("-message.txt") ++ ): ++ # File upload was rejected and only a message file exists ++ os.remove(os.path.join(self.receive_mode_dir, files[0])) ++ os.rmdir(self.receive_mode_dir) + except Exception: + pass + +diff --git a/desktop/tests/test_gui_receive.py b/desktop/tests/test_gui_receive.py +index 11f2d60..74fdf33 100644 +--- a/desktop/tests/test_gui_receive.py ++++ b/desktop/tests/test_gui_receive.py +@@ -262,7 +262,87 @@ class TestReceive(GuiBaseTest): + self.run_all_receive_mode_setup_tests(tab) + self.upload_file(tab, self.tmpfile_test, "test.txt") + url = f"http://127.0.0.1:{tab.app.port}/" +- self.hit_405(url, expected_resp="OnionShare: 405 Method Not Allowed", data = {'foo':'bar'}, methods = ["put", "post", "delete", "options"]) ++ self.hit_405( ++ url, ++ expected_resp="OnionShare: 405 Method Not Allowed", ++ data={"foo": "bar"}, ++ methods=["put", "post", "delete", "options"], ++ ) ++ ++ self.server_is_stopped(tab) ++ self.web_server_is_stopped(tab) ++ self.server_status_indicator_says_closed(tab) ++ self.close_all_tabs() ++ ++ def test_disable_files_prevents_file_upload(self): ++ """ ++ Test that disable_files setting prevents file uploads (security fix) ++ When disable_files is True, file uploads should be rejected and no files written to disk. ++ """ ++ tab = self.new_receive_tab() ++ ++ # Enable disable_files setting ++ tab.get_mode().mode_settings_widget.toggle_advanced_button.click() ++ tab.get_mode().mode_settings_widget.disable_files_checkbox.click() ++ ++ self.run_all_common_setup_tests() ++ self.run_all_receive_mode_setup_tests(tab) ++ ++ # Try to upload a file - should fail ++ files = {"file[]": open(self.tmpfile_test, "rb")} ++ url = f"http://127.0.0.1:{tab.app.port}/upload-ajax" ++ r = requests.post(url, files=files) ++ ++ # Verify error response ++ self.assertIn("error_flashes", r.text) ++ self.assertIn("File uploads are not allowed", r.text) ++ ++ # Verify no files were written to disk ++ QtTest.QTest.qWait(1000, self.gui.qtapp) ++ written_files = [] ++ now = datetime.now() ++ for _ in range(10): ++ date_dir = now.strftime("%Y-%m-%d") ++ time_dir = now.strftime("%H%M%S") ++ receive_mode_dir = os.path.join( ++ tab.settings.get("receive", "data_dir"), date_dir, time_dir ++ ) ++ for path in glob.glob(receive_mode_dir + "*"): ++ for root, dirs, files in os.walk(path): ++ for f in files: ++ if not f.endswith("-message.txt"): ++ written_files.append(os.path.join(root, f)) ++ now = now - timedelta(seconds=1) ++ ++ self.assertEqual( ++ len(written_files), ++ 0, ++ f"No files should be written when disable_files is True, but found: {written_files}", ++ ) ++ ++ self.server_is_stopped(tab) ++ self.web_server_is_stopped(tab) ++ self.server_status_indicator_says_closed(tab) ++ self.close_all_tabs() ++ ++ def test_disable_files_allows_text_message(self): ++ """ ++ Test that disable_files setting still allows text messages ++ """ ++ tab = self.new_receive_tab() ++ ++ # Enable disable_files setting ++ tab.get_mode().mode_settings_widget.toggle_advanced_button.click() ++ tab.get_mode().mode_settings_widget.disable_files_checkbox.click() ++ ++ self.run_all_common_setup_tests() ++ self.run_all_receive_mode_setup_tests(tab) ++ ++ # Submit a text message - should succeed ++ self.submit_message(tab, "This is a test message") ++ ++ # Verify counter incremented (text message was accepted) ++ self.counter_incremented(tab, 1) + + self.server_is_stopped(tab) + self.web_server_is_stopped(tab) diff -Nru onionshare-2.6.3/debian/patches/0006-Prevent-empty-folder-from-being-created-when-no-file.patch onionshare-2.6.3/debian/patches/0006-Prevent-empty-folder-from-being-created-when-no-file.patch --- onionshare-2.6.3/debian/patches/0006-Prevent-empty-folder-from-being-created-when-no-file.patch 1970-01-01 01:00:00.000000000 +0100 +++ onionshare-2.6.3/debian/patches/0006-Prevent-empty-folder-from-being-created-when-no-file.patch 2026-07-11 13:30:40.000000000 +0200 @@ -0,0 +1,237 @@ +From: Miguel Jacq <[email protected]> +Date: Sat, 30 May 2026 18:15:06 +1000 +Subject: Prevent empty folder from being created when no file is uploaded + +Origin: upstream, commit:e4ed559908b55cf41b993ed3d02052911025785b +Bug: https://github.com/onionshare/onionshare/security/advisories/GHSA-v833-3823-cmhp +Bug-Debian: https://bugs.debian.org/1139716 +--- + cli/onionshare_cli/web/receive_mode.py | 173 +++++++++++++++++---------------- + desktop/tests/test_gui_receive.py | 4 +- + 2 files changed, 93 insertions(+), 84 deletions(-) + +diff --git a/cli/onionshare_cli/web/receive_mode.py b/cli/onionshare_cli/web/receive_mode.py +index a5a208a..c99eed4 100644 +--- a/cli/onionshare_cli/web/receive_mode.py ++++ b/cli/onionshare_cli/web/receive_mode.py +@@ -396,63 +396,9 @@ class ReceiveModeRequest(Request): + self.upload_error = False + self.file_upload_rejected = False + +- # Figure out what files should be saved +- now = datetime.now() +- date_dir = now.strftime("%Y-%m-%d") +- time_dir = now.strftime("%H%M%S%f") +- self.receive_mode_dir = os.path.join( +- self.web.settings.get("receive", "data_dir"), date_dir, time_dir +- ) +- +- # Create that directory, which shouldn't exist yet +- try: +- os.makedirs(self.receive_mode_dir, 0o700, exist_ok=False) +- except OSError: +- # If this directory already exists, maybe someone else is uploading files at +- # the same second, so use a different name in that case +- if os.path.exists(self.receive_mode_dir): +- # Keep going until we find a directory name that's available +- i = 1 +- while True: +- new_receive_mode_dir = f"{self.receive_mode_dir}-{i}" +- try: +- os.makedirs(new_receive_mode_dir, 0o700, exist_ok=False) +- self.receive_mode_dir = new_receive_mode_dir +- break +- except OSError: +- pass +- i += 1 +- # Failsafe +- if i == 100: +- self.web.common.log( +- "ReceiveModeRequest", +- "__init__", +- "Error finding available receive mode directory", +- ) +- self.upload_error = True +- break +- except PermissionError: +- self.web.add_request( +- self.web.REQUEST_ERROR_DATA_DIR_CANNOT_CREATE, +- request.path, +- {"receive_mode_dir": self.receive_mode_dir}, +- ) +- print( +- f"Could not create OnionShare data folder: {self.receive_mode_dir}" +- ) +- self.web.common.log( +- "ReceiveModeRequest", +- "__init__", +- "Permission denied creating receive mode directory", +- ) +- self.upload_error = True +- +- # Figure out the message filename, in case there is a message +- self.message_filename = f"{self.receive_mode_dir}-message.txt" +- +- # If there's an error so far, finish early +- if self.upload_error: +- return ++ # Don't create directory yet - wait to see if there's actual content ++ self.receive_mode_dir = None ++ self.message_filename = None + + # A dictionary that maps filenames to the bytes uploaded so far + self.progress = {} +@@ -486,36 +432,95 @@ class ReceiveModeRequest(Request): + if text_message and len(text_message) <= 524288: + if text_message.strip() != "": + self.includes_message = True ++ # Create directory only if there's a message ++ self._create_receive_directory() ++ if not self.upload_error: ++ self.message_filename = f"{self.receive_mode_dir}-message.txt" ++ with open(self.message_filename, "w") as f: ++ f.write(text_message) ++ ++ self.web.common.log( ++ "ReceiveModeRequest", ++ "__init__", ++ f"saved message to {self.message_filename}", ++ ) ++ print(f"Received: {self.message_filename}") ++ ++ # Tell the GUI about the message ++ self.tell_gui_request_started() ++ self.web.common.log( ++ "ReceiveModeRequest", ++ "__init__", ++ "sending REQUEST_UPLOAD_INCLUDES_MESSAGE to GUI", ++ ) ++ self.web.add_request( ++ self.web.REQUEST_UPLOAD_INCLUDES_MESSAGE, ++ self.path, ++ { ++ "id": self.history_id, ++ "filename": self.message_filename, ++ }, ++ ) ++ ++ def _create_receive_directory(self): ++ """ ++ Create the receive mode directory. Called only when there's actual content to save. ++ """ ++ if self.receive_mode_dir is not None: ++ return # Already created ++ ++ now = datetime.now() ++ date_dir = now.strftime("%Y-%m-%d") ++ time_dir = now.strftime("%H%M%S%f") ++ self.receive_mode_dir = os.path.join( ++ self.web.settings.get("receive", "data_dir"), date_dir, time_dir ++ ) + +- with open(self.message_filename, "w") as f: +- f.write(text_message) +- +- self.web.common.log( +- "ReceiveModeRequest", +- "__init__", +- f"saved message to {self.message_filename}", +- ) +- print(f"Received: {self.message_filename}") +- +- # Tell the GUI about the message +- self.tell_gui_request_started() +- self.web.common.log( +- "ReceiveModeRequest", +- "__init__", +- "sending REQUEST_UPLOAD_INCLUDES_MESSAGE to GUI", +- ) +- self.web.add_request( +- self.web.REQUEST_UPLOAD_INCLUDES_MESSAGE, +- self.path, +- { +- "id": self.history_id, +- "filename": self.message_filename, +- }, +- ) ++ try: ++ os.makedirs(self.receive_mode_dir, 0o700, exist_ok=False) ++ except OSError: ++ if os.path.exists(self.receive_mode_dir): ++ i = 1 ++ while True: ++ new_receive_mode_dir = f"{self.receive_mode_dir}-{i}" ++ try: ++ os.makedirs(new_receive_mode_dir, 0o700, exist_ok=False) ++ self.receive_mode_dir = new_receive_mode_dir ++ break ++ except OSError: ++ pass ++ i += 1 ++ if i == 100: ++ self.web.common.log( ++ "ReceiveModeRequest", ++ "_create_receive_directory", ++ "Error finding available receive mode directory", ++ ) ++ self.upload_error = True ++ break ++ except PermissionError: ++ self.web.add_request( ++ self.web.REQUEST_ERROR_DATA_DIR_CANNOT_CREATE, ++ request.path, ++ {"receive_mode_dir": self.receive_mode_dir}, ++ ) ++ print( ++ f"Could not create OnionShare data folder: {self.receive_mode_dir}" ++ ) ++ self.web.common.log( ++ "ReceiveModeRequest", ++ "_create_receive_directory", ++ "Permission denied creating receive mode directory", ++ ) ++ self.upload_error = True + + def tell_gui_request_started(self): + # Tell the GUI about the request + if not self.told_gui_about_request: ++ # Create directory if not already created (for file uploads) ++ if self.receive_mode_dir is None and not self.upload_error: ++ self._create_receive_directory() ++ + self.web.common.log( + "ReceiveModeRequest", + "tell_gui_request_started", +@@ -540,6 +545,10 @@ class ReceiveModeRequest(Request): + This gets called for each file that gets uploaded, and returns an file-like + writable stream. + """ ++ # Ignore empty file uploads (user didn't select a file) ++ if not filename or filename.strip() == "": ++ return io.BytesIO() ++ + if self.upload_request: + # Check if file uploads are disabled - reject file streams early + if self.web.settings.get("receive", "disable_files"): +diff --git a/desktop/tests/test_gui_receive.py b/desktop/tests/test_gui_receive.py +index 74fdf33..10dd555 100644 +--- a/desktop/tests/test_gui_receive.py ++++ b/desktop/tests/test_gui_receive.py +@@ -283,7 +283,7 @@ class TestReceive(GuiBaseTest): + + # Enable disable_files setting + tab.get_mode().mode_settings_widget.toggle_advanced_button.click() +- tab.get_mode().mode_settings_widget.disable_files_checkbox.click() ++ tab.get_mode().disable_files_checkbox.click() + + self.run_all_common_setup_tests() + self.run_all_receive_mode_setup_tests(tab) +@@ -333,7 +333,7 @@ class TestReceive(GuiBaseTest): + + # Enable disable_files setting + tab.get_mode().mode_settings_widget.toggle_advanced_button.click() +- tab.get_mode().mode_settings_widget.disable_files_checkbox.click() ++ tab.get_mode().disable_files_checkbox.click() + + self.run_all_common_setup_tests() + self.run_all_receive_mode_setup_tests(tab) diff -Nru onionshare-2.6.3/debian/patches/0007-Updates-test-to-check-no-receive-directory-created-i.patch onionshare-2.6.3/debian/patches/0007-Updates-test-to-check-no-receive-directory-created-i.patch --- onionshare-2.6.3/debian/patches/0007-Updates-test-to-check-no-receive-directory-created-i.patch 1970-01-01 01:00:00.000000000 +0100 +++ onionshare-2.6.3/debian/patches/0007-Updates-test-to-check-no-receive-directory-created-i.patch 2026-07-11 13:30:40.000000000 +0200 @@ -0,0 +1,38 @@ +From: Saptak S <[email protected]> +Date: Tue, 9 Jun 2026 12:00:09 +0530 +Subject: Updates test to check no receive directory created if no file or + message + +Origin: upstream, commit:1d76494f5d819adc4c69f08f3842d564a425aa89 +Bug: https://github.com/onionshare/onionshare/security/advisories/GHSA-v833-3823-cmhp +Bug-Debian: https://bugs.debian.org/1139716 +--- + cli/tests/test_cli_web.py | 8 ++++---- + 1 file changed, 4 insertions(+), 4 deletions(-) + +diff --git a/cli/tests/test_cli_web.py b/cli/tests/test_cli_web.py +index fd5ca7b..a2ba99c 100644 +--- a/cli/tests/test_cli_web.py ++++ b/cli/tests/test_cli_web.py +@@ -257,6 +257,7 @@ class TestWeb: + + # Date folder should have just a time folder with new_york.jpg + data_dir_date = os.path.join(data_dir, os.listdir(data_dir)[0]) ++ print(data_dir_date) + filenames = os.listdir(data_dir_date) + assert len(filenames) == 1 + time_str = filenames[0][0:12] +@@ -287,10 +288,9 @@ class TestWeb: + assert res.status_code == 200 + assert b"Nothing submitted" in content + +- # Date folder should be empty +- data_dir_date = os.path.join(data_dir, os.listdir(data_dir)[0]) +- filenames = os.listdir(data_dir_date) +- assert len(filenames) == 0 ++ # There should be no receive date directory ++ data_dir_date = os.listdir(data_dir) ++ assert len(data_dir_date) == 0 + + shutil.rmtree(data_dir) + diff -Nru onionshare-2.6.3/debian/patches/0008-Prevent-symlink-traversal-in-share-website-mode.patch onionshare-2.6.3/debian/patches/0008-Prevent-symlink-traversal-in-share-website-mode.patch --- onionshare-2.6.3/debian/patches/0008-Prevent-symlink-traversal-in-share-website-mode.patch 1970-01-01 01:00:00.000000000 +0100 +++ onionshare-2.6.3/debian/patches/0008-Prevent-symlink-traversal-in-share-website-mode.patch 2026-07-11 13:30:40.000000000 +0200 @@ -0,0 +1,515 @@ +From: Miguel Jacq <[email protected]> +Date: Sat, 30 May 2026 16:48:51 +1000 +Subject: Prevent symlink traversal in share/website mode + +Origin: upstream, commit:48f31cfac077fcc9c04c67c2a6dbf87d956f5eec +Bug: https://github.com/onionshare/onionshare/security/advisories/GHSA-22p9-r2f5-22mf +Bug-Debian: https://bugs.debian.org/1139717 +--- + cli/onionshare_cli/web/send_base_mode.py | 73 +++++++-- + cli/onionshare_cli/web/share_mode.py | 35 ++-- + cli/tests/test_cli_web_symlink.py | 267 +++++++++++++++++++++++++++++++ + 3 files changed, 356 insertions(+), 19 deletions(-) + create mode 100644 cli/tests/test_cli_web_symlink.py + +diff --git a/cli/onionshare_cli/web/send_base_mode.py b/cli/onionshare_cli/web/send_base_mode.py +index e55841a..b9cff9a 100644 +--- a/cli/onionshare_cli/web/send_base_mode.py ++++ b/cli/onionshare_cli/web/send_base_mode.py +@@ -74,6 +74,12 @@ class SendBaseModeWeb: + """ + Build a data structure that describes the list of files + """ ++ # Store canonical paths of selected root directories for containment checking ++ self.selected_roots = [] ++ for filename in filenames: ++ if os.path.isdir(filename): ++ self.selected_roots.append(os.path.realpath(filename)) ++ + # If there's just one folder, replace filenames with a list of files inside that folder + if len(filenames) == 1 and os.path.isdir(filenames[0]): + filenames = [ +@@ -101,8 +107,15 @@ class SendBaseModeWeb: + for filename in filenames: + basename = os.path.basename(filename.rstrip(slash)) + ++ # Skip symlinks at the root level ++ if os.path.islink(filename): ++ continue ++ + # If it's a filename, add it + if os.path.isfile(filename): ++ # Verify the file is within selected roots (for symlink safety) ++ if not self._is_path_contained(filename): ++ continue + self.files[self.fix_windows_paths(basename)] = filename + self.root_files[self.fix_windows_paths(basename)] = filename + +@@ -110,7 +123,7 @@ class SendBaseModeWeb: + elif os.path.isdir(filename): + self.root_files[self.fix_windows_paths(basename)] = filename + +- for root, _, nested_filenames in os.walk(filename): ++ for root, _, nested_filenames in os.walk(filename, followlinks=False): + # Normalize the root path. So if the directory name is "/home/user/Documents/some_folder", + # and it has a nested folder foobar, the root is "/home/user/Documents/some_folder/foobar". + # The normalized_root should be "some_folder/foobar" +@@ -123,15 +136,39 @@ class SendBaseModeWeb: + + # Add the files in this dir + for nested_filename in nested_filenames: ++ full_path = os.path.join(root, nested_filename) ++ # Skip symlinks ++ if os.path.islink(full_path): ++ continue ++ # Verify the file is within selected roots ++ if not self._is_path_contained(full_path): ++ continue + self.files[ + self.fix_windows_paths( + os.path.join(normalized_root, nested_filename) + ) +- ] = os.path.join(root, nested_filename) ++ ] = full_path + + self.set_file_info_custom(filenames, processed_size_callback) + +- def directory_listing(self, filenames, path="", filesystem_path=None, add_trailing_slash=False): ++ def _is_path_contained(self, path): ++ """ ++ Check if the given path is contained within one of the selected root directories. ++ Uses realpath to resolve symlinks and ensure containment. ++ Returns False if the path is outside the selected roots (potential symlink escape). ++ """ ++ if not self.selected_roots: ++ return True ++ ++ resolved_path = os.path.realpath(path) ++ for root in self.selected_roots: ++ if resolved_path.startswith(root + os.sep) or resolved_path == root: ++ return True ++ return False ++ ++ def directory_listing( ++ self, filenames, path="", filesystem_path=None, add_trailing_slash=False ++ ): + """ + Display the front page of a share or index.html-less website, listing the files/directories. + """ +@@ -153,7 +190,9 @@ class SendBaseModeWeb: + breadcrumbs_leaf = breadcrumbs.pop()[0] + + # If filesystem_path is None, this is the root directory listing +- files, dirs = self.build_directory_listing(path, filenames, filesystem_path, add_trailing_slash) ++ files, dirs = self.build_directory_listing( ++ path, filenames, filesystem_path, add_trailing_slash ++ ) + + # Mark the request as done so we know we can close the share if in auto-stop mode. + self.web.done = True +@@ -163,7 +202,9 @@ class SendBaseModeWeb: + path, files, dirs, breadcrumbs, breadcrumbs_leaf + ) + +- def build_directory_listing(self, path, filenames, filesystem_path, add_trailing_slash=False): ++ def build_directory_listing( ++ self, path, filenames, filesystem_path, add_trailing_slash=False ++ ): + files = [] + dirs = [] + +@@ -178,11 +219,17 @@ class SendBaseModeWeb: + if is_dir: + if add_trailing_slash: + dirs.append( +- {"link": os.path.join(f"/{path}", quote(filename), ""), "basename": filename} ++ { ++ "link": os.path.join(f"/{path}", quote(filename), ""), ++ "basename": filename, ++ } + ) + else: + dirs.append( +- {"link": os.path.join(f"/{path}", quote(filename)), "basename": filename} ++ { ++ "link": os.path.join(f"/{path}", quote(filename)), ++ "basename": filename, ++ } + ) + else: + size = os.path.getsize(this_filesystem_path) +@@ -202,6 +249,12 @@ class SendBaseModeWeb: + Return a flask response that's streaming the download of an individual file, and gzip + compressing it if the browser supports it. + """ ++ # Verify the path is contained within selected roots (symlink safety check) ++ if not self._is_path_contained(filesystem_path): ++ history_id = self.cur_history_id ++ self.cur_history_id += 1 ++ return self.web.error404(history_id) ++ + use_gzip = self.should_use_gzip() + + # gzip compress the individual file, if it hasn't already been compressed +@@ -256,11 +309,13 @@ class SendBaseModeWeb: + if self.web.settings.get(self.web.mode, "log_filenames"): + # Decode and sanitize the path to remove newlines + decoded_path = unquote(path) +- decoded_path = decoded_path.replace("\r", "").replace("\n", "") ++ decoded_path = decoded_path.replace("\r", "").replace( ++ "\n", "" ++ ) + filename_str = f"{decoded_path} - " + else: + filename_str = "" +- ++ + sys.stdout.write( + "\r{0}{1:s}, {2:.2f}% ".format( + filename_str, +diff --git a/cli/onionshare_cli/web/share_mode.py b/cli/onionshare_cli/web/share_mode.py +index 49d8728..1a86c37 100644 +--- a/cli/onionshare_cli/web/share_mode.py ++++ b/cli/onionshare_cli/web/share_mode.py +@@ -300,9 +300,7 @@ class ShareModeWeb(SendBaseModeWeb): + + return range_, status_code + +- def generate( +- self, range_, file_to_download, path, history_id, filesize +- ): ++ def generate(self, range_, file_to_download, path, history_id, filesize): + # The user hasn't canceled the download + self.client_cancel = False + +@@ -349,7 +347,9 @@ class ShareModeWeb(SendBaseModeWeb): + if self.web.settings.get("share", "log_filenames"): + # Decode and sanitize the path to remove newlines + decoded_path = unquote(path) +- decoded_path = decoded_path.replace("\r", "").replace("\n", "") ++ decoded_path = decoded_path.replace("\r", "").replace( ++ "\n", "" ++ ) + filename_str = f"{decoded_path} - " + else: + filename_str = "" +@@ -576,6 +576,14 @@ class ZipWriter(object): + """ + Add a file to the zip archive. + """ ++ # Skip symlinks ++ if os.path.islink(filename): ++ return ++ # Verify the file is within selected roots (symlink safety check) ++ # Only check if web/share_mode is available (may be None in standalone usage) ++ if self.web and hasattr(self.web, "share_mode") and self.web.share_mode: ++ if not self.web.share_mode._is_path_contained(filename): ++ return + self.z.write(filename, os.path.basename(filename), zipfile.ZIP_DEFLATED) + self._size += os.path.getsize(filename) + self.processed_size_callback(self._size) +@@ -585,18 +593,25 @@ class ZipWriter(object): + Add a directory, and all of its children, to the zip archive. + """ + dir_to_strip = os.path.dirname(filename.rstrip("/")) + "/" +- for dirpath, dirnames, filenames in os.walk(filename): ++ for dirpath, dirnames, filenames in os.walk(filename, followlinks=False): + for f in filenames: + # Canceling early? + if self.cancel_compression: + return False + + full_filename = os.path.join(dirpath, f) +- if not os.path.islink(full_filename): +- arc_filename = full_filename[len(dir_to_strip) :] +- self.z.write(full_filename, arc_filename, zipfile.ZIP_DEFLATED) +- self._size += os.path.getsize(full_filename) +- self.processed_size_callback(self._size) ++ # Skip symlinks ++ if os.path.islink(full_filename): ++ continue ++ # Verify the file is within selected roots ++ # Only check if web/share_mode is available (may be None in standalone usage) ++ if self.web and hasattr(self.web, "share_mode") and self.web.share_mode: ++ if not self.web.share_mode._is_path_contained(full_filename): ++ continue ++ arc_filename = full_filename[len(dir_to_strip) :] ++ self.z.write(full_filename, arc_filename, zipfile.ZIP_DEFLATED) ++ self._size += os.path.getsize(full_filename) ++ self.processed_size_callback(self._size) + + return True + +diff --git a/cli/tests/test_cli_web_symlink.py b/cli/tests/test_cli_web_symlink.py +new file mode 100644 +index 0000000..526199b +--- /dev/null ++++ b/cli/tests/test_cli_web_symlink.py +@@ -0,0 +1,267 @@ ++import os ++import shutil ++import tempfile ++ ++import pytest ++ ++from onionshare_cli.common import Common ++from onionshare_cli.web import Web ++from onionshare_cli.settings import Settings ++from onionshare_cli.mode_settings import ModeSettings ++ ++ ++class TestSymlinkProtection: ++ """Tests for symlink vulnerabilities""" ++ ++ @pytest.fixture ++ def symlink_test_setup(self, temp_dir): ++ """ ++ Create a test environment with: ++ - A shared directory containing normal files and symlinks ++ - A secret file outside the shared directory ++ - Symlinks pointing to the secret file ++ """ ++ # Create a unique temp directory for this test setup ++ test_temp_dir = tempfile.TemporaryDirectory(dir=temp_dir.name) ++ ++ # Create the secret file outside the shared directory ++ outside_secret = os.path.join(test_temp_dir.name, "outside-secret.txt") ++ with open(outside_secret, "w") as f: ++ f.write("OUTSIDE_SECRET_MARKER") ++ ++ # Create the shared directory ++ site_dir = os.path.join(test_temp_dir.name, "site") ++ os.mkdir(site_dir) ++ ++ # Create a normal file in the shared directory ++ normal_file = os.path.join(site_dir, "normal.txt") ++ with open(normal_file, "w") as f: ++ f.write("NORMAL_FILE_CONTENT") ++ ++ # Create a symlink to the secret inside the shared directory ++ symlink_path = os.path.join(site_dir, "link.txt") ++ os.symlink(outside_secret, symlink_path) ++ ++ # Create a nested directory with files and symlinks ++ nested_dir = os.path.join(site_dir, "nested") ++ os.mkdir(nested_dir) ++ ++ nested_file = os.path.join(nested_dir, "nested.txt") ++ with open(nested_file, "w") as f: ++ f.write("NESTED_FILE_CONTENT") ++ ++ nested_symlink = os.path.join(nested_dir, "nested-link.txt") ++ os.symlink(outside_secret, nested_symlink) ++ ++ return { ++ "temp_dir": test_temp_dir, ++ "outside_secret": outside_secret, ++ "site_dir": site_dir, ++ "normal_file": normal_file, ++ "symlink_path": symlink_path, ++ "nested_dir": nested_dir, ++ "nested_file": nested_file, ++ "nested_symlink": nested_symlink, ++ } ++ ++ def test_symlink_excluded_from_file_inventory_website_mode( ++ self, symlink_test_setup, common_obj ++ ): ++ """Test that symlinks are excluded from file inventory in website mode""" ++ web = Web(common_obj, False, ModeSettings(common_obj), "website") ++ web.website_mode.set_file_info([symlink_test_setup["site_dir"]]) ++ ++ # Symlinks should not be in the files dictionary ++ assert "link.txt" not in web.website_mode.files ++ assert "nested/nested-link.txt" not in web.website_mode.files ++ ++ # Normal files should be in the files dictionary ++ assert "normal.txt" in web.website_mode.files ++ assert "nested/nested.txt" in web.website_mode.files ++ ++ def test_symlink_excluded_from_file_inventory_share_mode( ++ self, symlink_test_setup, common_obj ++ ): ++ """Test that symlinks are excluded from file inventory in share mode""" ++ web = Web(common_obj, False, ModeSettings(common_obj), "share") ++ web.share_mode.set_file_info([symlink_test_setup["site_dir"]]) ++ ++ # Symlinks should not be in the files dictionary ++ assert "link.txt" not in web.share_mode.files ++ assert "nested/nested-link.txt" not in web.share_mode.files ++ ++ # Normal files should be in the files dictionary ++ assert "normal.txt" in web.share_mode.files ++ assert "nested/nested.txt" in web.share_mode.files ++ ++ def test_is_path_contained_normal_files(self, symlink_test_setup, common_obj): ++ """Test that normal files are correctly identified as contained""" ++ web = Web(common_obj, False, ModeSettings(common_obj), "website") ++ web.website_mode.set_file_info([symlink_test_setup["site_dir"]]) ++ ++ # Normal files should be contained ++ assert web.website_mode._is_path_contained(symlink_test_setup["normal_file"]) ++ assert web.website_mode._is_path_contained(symlink_test_setup["nested_file"]) ++ ++ def test_is_path_contained_symlinks(self, symlink_test_setup, common_obj): ++ """Test that symlinks to outside are correctly identified as NOT contained""" ++ web = Web(common_obj, False, ModeSettings(common_obj), "website") ++ web.website_mode.set_file_info([symlink_test_setup["site_dir"]]) ++ ++ # Symlinks to outside should NOT be contained ++ assert not web.website_mode._is_path_contained(symlink_test_setup["symlink_path"]) ++ assert not web.website_mode._is_path_contained(symlink_test_setup["nested_symlink"]) ++ ++ def test_symlink_rejected_in_website_mode_request( ++ self, symlink_test_setup, common_obj ++ ): ++ """Test that requesting a symlink returns 404 in website mode""" ++ web = Web(common_obj, False, ModeSettings(common_obj), "website") ++ web.app.testing = True ++ web.website_mode.set_file_info([symlink_test_setup["site_dir"]]) ++ ++ with web.app.test_client() as c: ++ # Requesting symlink should return 404 ++ res = c.get("/link.txt") ++ assert res.status_code == 404 ++ ++ # Normal file should return 200 ++ res = c.get("/normal.txt") ++ assert res.status_code == 200 ++ assert b"NORMAL_FILE_CONTENT" in res.get_data() ++ ++ def test_symlink_rejected_in_share_mode_individual_download( ++ self, symlink_test_setup, common_obj ++ ): ++ """Test that individual file downloads reject symlinks in share mode""" ++ web = Web(common_obj, False, ModeSettings(common_obj), "share") ++ web.app.testing = True ++ web.settings.set("share", "autostop_sharing", False) ++ web.share_mode.set_file_info([symlink_test_setup["site_dir"]]) ++ ++ with web.app.test_client() as c: ++ # Requesting symlink should return 404 ++ res = c.get("/link.txt") ++ assert res.status_code == 404 ++ ++ # Normal file should return 200 ++ res = c.get("/normal.txt") ++ assert res.status_code == 200 ++ assert b"NORMAL_FILE_CONTENT" in res.get_data() ++ ++ def test_symlink_excluded_from_zip_archive(self, symlink_test_setup, common_obj): ++ """Test that symlinks are excluded from ZIP archives""" ++ import zipfile ++ from io import BytesIO ++ ++ web = Web(common_obj, False, ModeSettings(common_obj), "share") ++ web.app.testing = True ++ web.share_mode.set_file_info([symlink_test_setup["site_dir"]]) ++ ++ with web.app.test_client() as c: ++ res = c.get("/download") ++ assert res.status_code == 200 ++ ++ # Check ZIP contents ++ zip_data = BytesIO(res.get_data()) ++ with zipfile.ZipFile(zip_data, "r") as zf: ++ zip_contents = zf.namelist() ++ ++ # Symlinks should not be in the ZIP ++ assert "link.txt" not in zip_contents ++ assert "nested/nested-link.txt" not in zip_contents ++ ++ # Normal files should be in the ZIP ++ assert "normal.txt" in zip_contents ++ assert "nested/nested.txt" in zip_contents ++ ++ def test_root_level_symlink_handling(self, temp_dir, common_obj): ++ """Test that root-level symlinks (not in subdirectory) are handled correctly""" ++ # Create a directory with only a symlink (no normal files) ++ site_dir = os.path.join(temp_dir.name, "site-only-symlink") ++ os.mkdir(site_dir) ++ ++ outside_secret = os.path.join(temp_dir.name, "secret.txt") ++ with open(outside_secret, "w") as f: ++ f.write("SECRET") ++ ++ symlink_path = os.path.join(site_dir, "link.txt") ++ os.symlink(outside_secret, symlink_path) ++ ++ web = Web(common_obj, False, ModeSettings(common_obj), "website") ++ web.website_mode.set_file_info([site_dir]) ++ ++ # The symlink should not be in the file inventory ++ assert "link.txt" not in web.website_mode.files ++ ++ def test_nested_symlink_chain_handling(self, temp_dir, common_obj): ++ """Test that nested symlinks (symlinks within subdirectories) are handled""" ++ site_dir = os.path.join(temp_dir.name, "site") ++ os.mkdir(site_dir) ++ ++ outside_secret = os.path.join(temp_dir.name, "secret.txt") ++ with open(outside_secret, "w") as f: ++ f.write("SECRET") ++ ++ # Create nested directory structure with symlinks at various levels ++ level1 = os.path.join(site_dir, "level1") ++ os.mkdir(level1) ++ ++ level2 = os.path.join(level1, "level2") ++ os.mkdir(level2) ++ ++ # Symlink in level1 ++ symlink1 = os.path.join(level1, "link1.txt") ++ os.symlink(outside_secret, symlink1) ++ ++ # Symlink in level2 ++ symlink2 = os.path.join(level2, "link2.txt") ++ os.symlink(outside_secret, symlink2) ++ ++ # Normal file in level2 ++ normal_file = os.path.join(level2, "normal.txt") ++ with open(normal_file, "w") as f: ++ f.write("NORMAL") ++ ++ web = Web(common_obj, False, ModeSettings(common_obj), "website") ++ web.website_mode.set_file_info([site_dir]) ++ ++ # Symlinks should not be in the file inventory ++ assert "level1/link1.txt" not in web.website_mode.files ++ assert "level1/level2/link2.txt" not in web.website_mode.files ++ ++ # Normal file should be in the file inventory ++ assert "level1/level2/normal.txt" in web.website_mode.files ++ ++ def test_directory_symlink_handling(self, temp_dir, common_obj): ++ """Test that symlinks to directories are handled correctly""" ++ # Create a unique temp directory for this test ++ test_temp_dir = tempfile.TemporaryDirectory(dir=temp_dir.name) ++ ++ site_dir = os.path.join(test_temp_dir.name, "site") ++ os.mkdir(site_dir) ++ ++ outside_dir = os.path.join(temp_dir.name, "outside-dir") ++ os.mkdir(outside_dir) ++ ++ outside_file = os.path.join(outside_dir, "outside-file.txt") ++ with open(outside_file, "w") as f: ++ f.write("OUTSIDE") ++ ++ # Create a symlink to a directory ++ dir_symlink = os.path.join(site_dir, "link-dir") ++ os.symlink(outside_dir, dir_symlink) ++ ++ # Create a normal file ++ normal_file = os.path.join(site_dir, "normal.txt") ++ with open(normal_file, "w") as f: ++ f.write("NORMAL") ++ ++ web = Web(common_obj, False, ModeSettings(common_obj), "website") ++ web.website_mode.set_file_info([site_dir]) ++ ++ # The directory symlink should not be in root_files ++ assert "link-dir" not in web.website_mode.root_files ++ ++ # Normal file should be in root_files ++ assert "normal.txt" in web.website_mode.root_files diff -Nru onionshare-2.6.3/debian/patches/0009-Do-not-show-symlinks-that-go-outside-the-docroot-in-.patch onionshare-2.6.3/debian/patches/0009-Do-not-show-symlinks-that-go-outside-the-docroot-in-.patch --- onionshare-2.6.3/debian/patches/0009-Do-not-show-symlinks-that-go-outside-the-docroot-in-.patch 1970-01-01 01:00:00.000000000 +0100 +++ onionshare-2.6.3/debian/patches/0009-Do-not-show-symlinks-that-go-outside-the-docroot-in-.patch 2026-07-11 13:30:40.000000000 +0200 @@ -0,0 +1,122 @@ +From: Miguel Jacq <[email protected]> +Date: Sat, 30 May 2026 18:25:49 +1000 +Subject: Do not show symlinks that go outside the docroot in nested dirs + either + +Origin: upstream, commit:96827bdd0580bd34b921a7b269198f65434178d8 +Bug: https://github.com/onionshare/onionshare/security/advisories/GHSA-22p9-r2f5-22mf +Bug-Debian: https://bugs.debian.org/1139717 +--- + cli/onionshare_cli/web/send_base_mode.py | 4 +++ + cli/onionshare_cli/web/share_mode.py | 12 +++----- + cli/tests/test_cli_web_symlink.py | 48 ++++++++++++++++++++++++++++++-- + 3 files changed, 54 insertions(+), 10 deletions(-) + +diff --git a/cli/onionshare_cli/web/send_base_mode.py b/cli/onionshare_cli/web/send_base_mode.py +index b9cff9a..68267ce 100644 +--- a/cli/onionshare_cli/web/send_base_mode.py ++++ b/cli/onionshare_cli/web/send_base_mode.py +@@ -214,6 +214,10 @@ class SendBaseModeWeb: + else: + this_filesystem_path = self.files[filename] + ++ # Skip symlinks in directory listings ++ if os.path.islink(this_filesystem_path): ++ continue ++ + is_dir = os.path.isdir(this_filesystem_path) + + if is_dir: +diff --git a/cli/onionshare_cli/web/share_mode.py b/cli/onionshare_cli/web/share_mode.py +index 1a86c37..d6760de 100644 +--- a/cli/onionshare_cli/web/share_mode.py ++++ b/cli/onionshare_cli/web/share_mode.py +@@ -580,10 +580,8 @@ class ZipWriter(object): + if os.path.islink(filename): + return + # Verify the file is within selected roots (symlink safety check) +- # Only check if web/share_mode is available (may be None in standalone usage) +- if self.web and hasattr(self.web, "share_mode") and self.web.share_mode: +- if not self.web.share_mode._is_path_contained(filename): +- return ++ if not self.web.share_mode._is_path_contained(filename): ++ return + self.z.write(filename, os.path.basename(filename), zipfile.ZIP_DEFLATED) + self._size += os.path.getsize(filename) + self.processed_size_callback(self._size) +@@ -604,10 +602,8 @@ class ZipWriter(object): + if os.path.islink(full_filename): + continue + # Verify the file is within selected roots +- # Only check if web/share_mode is available (may be None in standalone usage) +- if self.web and hasattr(self.web, "share_mode") and self.web.share_mode: +- if not self.web.share_mode._is_path_contained(full_filename): +- continue ++ if not self.web.share_mode._is_path_contained(full_filename): ++ continue + arc_filename = full_filename[len(dir_to_strip) :] + self.z.write(full_filename, arc_filename, zipfile.ZIP_DEFLATED) + self._size += os.path.getsize(full_filename) +diff --git a/cli/tests/test_cli_web_symlink.py b/cli/tests/test_cli_web_symlink.py +index 526199b..86fb4bf 100644 +--- a/cli/tests/test_cli_web_symlink.py ++++ b/cli/tests/test_cli_web_symlink.py +@@ -109,8 +109,12 @@ class TestSymlinkProtection: + web.website_mode.set_file_info([symlink_test_setup["site_dir"]]) + + # Symlinks to outside should NOT be contained +- assert not web.website_mode._is_path_contained(symlink_test_setup["symlink_path"]) +- assert not web.website_mode._is_path_contained(symlink_test_setup["nested_symlink"]) ++ assert not web.website_mode._is_path_contained( ++ symlink_test_setup["symlink_path"] ++ ) ++ assert not web.website_mode._is_path_contained( ++ symlink_test_setup["nested_symlink"] ++ ) + + def test_symlink_rejected_in_website_mode_request( + self, symlink_test_setup, common_obj +@@ -265,3 +269,43 @@ class TestSymlinkProtection: + + # Normal file should be in root_files + assert "normal.txt" in web.website_mode.root_files ++ ++ def test_symlink_filtered_from_directory_listing( ++ self, symlink_test_setup, common_obj ++ ): ++ """Test that symlinks are filtered from directory listings (not just file inventory)""" ++ web = Web(common_obj, False, ModeSettings(common_obj), "website") ++ web.app.testing = True ++ web.website_mode.set_file_info([symlink_test_setup["site_dir"]]) ++ ++ with web.app.test_client() as c: ++ # Request root directory listing ++ res = c.get("/") ++ assert res.status_code == 200 ++ data = res.get_data(as_text=True) ++ ++ # Symlinks should not appear in directory listing ++ assert "link.txt" not in data or "nested-link.txt" not in data ++ ++ # Normal files should appear in directory listing ++ assert "normal.txt" in data ++ ++ def test_nested_symlink_filtered_from_directory_listing( ++ self, symlink_test_setup, common_obj ++ ): ++ """Test that nested symlinks are filtered from nested directory listings""" ++ web = Web(common_obj, False, ModeSettings(common_obj), "website") ++ web.app.testing = True ++ web.website_mode.set_file_info([symlink_test_setup["site_dir"]]) ++ ++ with web.app.test_client() as c: ++ # Request nested directory listing ++ res = c.get("/nested/") ++ assert res.status_code == 200 ++ data = res.get_data(as_text=True) ++ ++ # Nested symlink should not appear in directory listing ++ assert "nested-link.txt" not in data ++ ++ # Normal nested file should appear in directory listing ++ assert "nested.txt" in data diff -Nru onionshare-2.6.3/debian/patches/0010-Adds-additional-cheks-for-failing-condition.patch onionshare-2.6.3/debian/patches/0010-Adds-additional-cheks-for-failing-condition.patch --- onionshare-2.6.3/debian/patches/0010-Adds-additional-cheks-for-failing-condition.patch 1970-01-01 01:00:00.000000000 +0100 +++ onionshare-2.6.3/debian/patches/0010-Adds-additional-cheks-for-failing-condition.patch 2026-07-11 13:30:40.000000000 +0200 @@ -0,0 +1,33 @@ +From: Saptak S <[email protected]> +Date: Tue, 9 Jun 2026 12:06:01 +0530 +Subject: Adds additional cheks for failing condition + +Origin: upstream, commit:d0697fa3d05a193138c6052422c313612301d98a +Bug: https://github.com/onionshare/onionshare/security/advisories/GHSA-22p9-r2f5-22mf +Bug-Debian: https://bugs.debian.org/1139717 +--- + cli/onionshare_cli/web/share_mode.py | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/cli/onionshare_cli/web/share_mode.py b/cli/onionshare_cli/web/share_mode.py +index d6760de..bdf6087 100644 +--- a/cli/onionshare_cli/web/share_mode.py ++++ b/cli/onionshare_cli/web/share_mode.py +@@ -580,7 +580,7 @@ class ZipWriter(object): + if os.path.islink(filename): + return + # Verify the file is within selected roots (symlink safety check) +- if not self.web.share_mode._is_path_contained(filename): ++ if self.web and not self.web.share_mode._is_path_contained(filename): + return + self.z.write(filename, os.path.basename(filename), zipfile.ZIP_DEFLATED) + self._size += os.path.getsize(filename) +@@ -602,7 +602,7 @@ class ZipWriter(object): + if os.path.islink(full_filename): + continue + # Verify the file is within selected roots +- if not self.web.share_mode._is_path_contained(full_filename): ++ if self.web and not self.web.share_mode._is_path_contained(full_filename): + continue + arc_filename = full_filename[len(dir_to_strip) :] + self.z.write(full_filename, arc_filename, zipfile.ZIP_DEFLATED) diff -Nru onionshare-2.6.3/debian/patches/series onionshare-2.6.3/debian/patches/series --- onionshare-2.6.3/debian/patches/series 2025-03-05 02:53:15.000000000 +0100 +++ onionshare-2.6.3/debian/patches/series 2026-07-11 13:30:40.000000000 +0200 @@ -2,3 +2,9 @@ 0002-add-pytest.ini-to-make-sure-pytest-can-find-all-test.patch 0003-Do-not-install-onionshare_cli-script.patch 0004-Remove-cython-dependency.patch +0005-Prevent-writing-files-in-Receive-mode-when-file-uplo.patch +0006-Prevent-empty-folder-from-being-created-when-no-file.patch +0007-Updates-test-to-check-no-receive-directory-created-i.patch +0008-Prevent-symlink-traversal-in-share-website-mode.patch +0009-Do-not-show-symlinks-that-go-outside-the-docroot-in-.patch +0010-Adds-additional-cheks-for-failing-condition.patch

