[Pkg-privacy-maintainers] Bug#1141858: trixie-pu: package onionshare/2.6.3+deb13u1
Hefee
hefee at debian.org
Sat Jul 11 13:28:03 BST 2026
Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: onionshare at packages.debian.org, Debian Security Team <team at security.debian.org>, carnil at debian.org, hefee at debian.org
Control: affects -1 + src:onionshare
User: release.debian.org at packages.debian.org
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
-------------- next part --------------
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? <hefee at debian.org> 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 <mig at mig5.net>
+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 <mig at mig5.net>
+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 <saptak013 at gmail.com>
+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 <mig at mig5.net>
+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 <mig at mig5.net>
+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 <saptak013 at gmail.com>
+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
More information about the Pkg-privacy-maintainers
mailing list