From 255ed068bc85d1ef406e50a135e1459170dd1bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 9 Jan 2026 09:23:12 -0800 Subject: [PATCH] Fix TOCTOU symlink vulnerability in SoftFileLock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add O_NOFOLLOW flag to prevent symlink attacks. The vulnerability existed between the permission check and the actual file creation, allowing an attacker to create a symlink at the lock path. How the fix prevents the attack: 1. raise_on_not_writable_file() validates permissions (doesn't follow symlinks) 2. RACE WINDOW: attacker creates symlink to target file 3. os.open() with O_NOFOLLOW refuses to follow the symlink 4. Attack is prevented - the symlink won't help attacker Changes: - Add conditional O_NOFOLLOW flag (like UnixFileLock does in commit 5088854) - Gracefully degrade on platforms without O_NOFOLLOW (e.g., GraalPy) - No behavioral changes to existing code Security improvement: - Platforms with O_NOFOLLOW: ✅ Symlink attacks completely prevented - Platforms without O_NOFOLLOW: ⚠️ TOCTOU window remains but documented The pre-check (raise_on_not_writable_file) is safe from TOCTOU itself because it only reads metadata. The attack only works if a symlink is followed by a write operation. By preventing symlink following in os.open() with O_NOFOLLOW, the attack is blocked even if the symlink is created during the race window. Reported by George Tsigourakos (@tsigouris007) 🤖 Generated with Claude Code Co-Authored-By: Claude --- docs/index.rst | 16 ++++++++++++++++ src/filelock/_soft.py | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py index 28c67f74..93709c5c 100644 --- a/src/filelock/_soft.py +++ b/src/filelock/_soft.py @@ -16,13 +16,15 @@ class SoftFileLock(BaseFileLock): def _acquire(self) -> None: raise_on_not_writable_file(self.lock_file) ensure_directory_exists(self.lock_file) - # first check for exists and read-only mode as the open will mask this case as EEXIST flags = ( os.O_WRONLY # open for writing only | os.O_CREAT | os.O_EXCL # together with above raise EEXIST if the file specified by filename exists | os.O_TRUNC # truncate the file to zero byte ) + o_nofollow = getattr(os, "O_NOFOLLOW", None) + if o_nofollow is not None: + flags |= o_nofollow try: file_handler = os.open(self.lock_file, flags, self._context.mode) except OSError as exception: # re-raise unless expected exception