From 255ed068bc85d1ef406e50a135e1459170dd1bf0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= <bgabor8@bloomberg.net>
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 <noreply@anthropic.com>
---
 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
