Skip to main content

linkward

Read a local file while rejecting symbolic links in every path component, with optional owner, permission, and size checks.

Pure Python, zero runtime dependencies. Linux and macOS, Python 3.9+.

Use it when a Python service or CLI reads a credential or configuration file and must reject symlink redirection. linkward opens each directory relative to the previous directory's descriptor with O_NOFOLLOW, then opens and reads the final file through the same descriptor used for policy checks.

Read method Rejects a symlink at the final file Rejects symlinks in parent directories
open(path, "rb") No No
os.open(path, os.O_RDONLY | os.O_NOFOLLOW) Yes No
safe_open(path) Yes Yes

This prevents symlink traversal during this open operation. It does not make an attacker-writable regular file trustworthy or provide a snapshot of its contents.

Install

This project has not been published to PyPI. Install from the repository:

python -m pip install 'git+https://github.com/alohaaaaaaaaaaaaaa/linkward.git'

For a reproducible deployment, pin a reviewed commit after the .git URL with @<commit>.

Read a credential

import os
from pathlib import Path

from linkward import SafeOpenError, safe_open

# A configured absolute path with no symlinks in any component.
credential_path = Path("/srv/myapp/token")

try:
    token = safe_open(
        credential_path,
        require_owner=os.geteuid(),
        require_mode=0o600,
        max_bytes=64 * 1024,
    )
except SafeOpenError:
    # Abort startup rather than using a credential that failed these checks.
    raise

Owner and permission checks are off by default; enable them for your trust policy. The example requires the effective user's ownership and exactly 0600 ordinary permission bits. A matching owner and mode do not establish content authenticity, and ACLs or other writers may still permit changes.

Paths must be absolute str or os.PathLike[str] values. Relative paths, . or .. components, NUL characters, the filesystem root, and trailing slashes are rejected. Validation applies to the text returned by os.fspath(); a Path object may already have removed . components or trailing slashes. Bytes paths are unsupported.

Parent directories are opened read-only, so the caller needs directory read and search permissions as well as permission to read the file.

On macOS, /tmp, /var, and /etc commonly contain or are symlinks. Configure a trusted canonical path such as /private/etc/myapp/token if appropriate for your system. Do not call resolve() or realpath() on an untrusted path just to make it pass: those operations follow links, and a later open can encounter a changed path.

Run the attack demonstration

After installation, from a repository checkout:

python examples/symlink_attack.py

The demonstration compares ordinary open(), final-component-only O_NOFOLLOW, and safe_open(). It also performs a controlled swap between a separate path check and open. The timing is deliberate: this illustrates the failure mode and the protection, rather than measuring a probabilistic race's success rate.

API

safe_open(
    path,
    *,
    max_bytes=1024 * 1024,
    require_mode=None,
    require_owner=None,
    require_regular_file=True,
    require_single_hardlink=False,
) -> bytes

open_nofollow(path) -> int
safe_open option Behavior
max_bytes Nonnegative integer; reject an oversized file. Returned bytes never exceed this limit.
require_mode Optional integer from 0 to 0o777; require an exact match with st_mode & 0o777. Special mode bits and ACLs are not checked by this option.
require_owner Optional nonnegative user ID to match against st_uid, commonly os.geteuid().
require_regular_file Default True; reject directories, FIFOs, devices, sockets, and other nonregular files.
require_single_hardlink Default False; if enabled, require st_nlink == 1 at the initial inspection. This is a link-count check, not a guarantee of exclusive access.

Integer options reject booleans; the two boolean options require actual bool values. Policy checks use the initial descriptor metadata, followed by the before/after change check. Setting require_regular_file=False does not turn this into a general stream reader: size and read-length consistency checks still apply, so pseudo-files that report a size of zero can be rejected despite having content.

Size detection may read one byte beyond max_bytes before rejecting a growing file. Chunk storage and combining the result also require memory beyond the returned bytes; max_bytes is not a strict process-memory limit.

open_nofollow() only provides the path traversal guarantee. It does not enforce file type, owner, permissions, size, link count, or content stability. Its descriptor is opened read-only and nonblocking and is not inherited across exec; the caller must close it:

import os
from linkward import open_nofollow

fd = open_nofollow("/srv/myapp/token")
try:
    metadata = os.fstat(fd)
    # Apply your own policy and read from this descriptor.
finally:
    os.close(fd)

All library failures derive from SafeOpenError:

Exception Meaning
SymlinkRejected A symbolic link was identified in a rejected path component.
FilePolicyError The opened file did not meet the requested type, owner, mode, size, or link-count policy.
FileChangedDuringRead Descriptor metadata or the amount read indicated a change during the read.
SafeOpenError Invalid input, unsupported platform, or another filesystem failure.

Filesystem OSErrors are wrapped with the original exception in __cause__ for diagnostics. A concurrent path change can affect the exception classification; the operation still rejects a component it cannot open safely.

Security boundaries

  • Concurrent content writes: the before/after fstat comparison is a best-effort change detector. Writes through mmap, filesystem timestamp behavior, or other concurrent writes can leave indistinguishable metadata. A successful read can contain a mixture of bytes from different moments. There is no snapshot or authenticity guarantee. Use an appropriate publication or integrity scheme when those guarantees are required.
  • Renames and directory replacement: once opened, a descriptor stays attached to the selected object. Renaming a path does not make the reader follow the new name and does not necessarily cause an error. Already-opened directories can also be moved; traversal is not confinement within a pathname boundary.
  • Other redirection and writers: malicious regular files, hard links, bind mounts, mount changes, privileged attackers, and other processes with write access are outside the symlink guarantee. Protect files and their directories according to your threat model. This library is not a sandbox or chroot.
  • Platform and I/O: Windows is unsupported. Filesystem behavior still matters; nonblocking open avoids the usual FIFO-open wait but does not guarantee a time limit on file operations or safe reads from arbitrary devices. Keep the default regular-file requirement unless you explicitly handle other file types.

See SECURITY.md for reporting vulnerabilities.

Safe path handling predates this project. Other approaches include pathrs and google/linkward; their APIs, dependencies, and confinement semantics differ. This project's focus is a small Python API for rejecting every symlink component and checking the descriptor being read.

The filelock advisories CVE-2025-68146 and CVE-2026-22701 illustrate related symlink risks in lock-file creation and locking. linkward is read-only; it does not implement those locking operations or replace their fixes.

Development

git clone https://github.com/alohaaaaaaaaaaaaaa/linkward.git
cd linkward
python -m pip install --upgrade pip
python -m pip install -e '.[test]'
python -m pytest

The tests cover accepted reads as well as rejected paths and policies, controlled concurrent changes, and descriptor cleanup.

License

MIT. See LICENSE.

Metadata

Release files for linkward 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for linkward 0.1.0
File Size Uploaded
linkward-0.1.0.tar.gz 16.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for linkward 0.1.0
File Interpreter ABI Platform
linkward-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 27.8 kB

Release files / linkward-0.1.0.tar.gz

Download URL linkward-0.1.0.tar.gz
Size 16.5 kB
Tags Source
SHA-256 checksum
How to use checksums
4943e80bb7b3c1a2ea5e14b37de2c30fbd4e85cc8a7b6502af2ab8d35961a4f2
BLAKE2b-256 checksum
How to use checksums
92bb34ea2efba8436d3b64c24db2db541780d8ea50a4ded3dacfa3bfed457a81
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.9.6

Release files / linkward-0.1.0-py3-none-any.whl

Download URL linkward-0.1.0-py3-none-any.whl
Size 11.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f7b7ed5e26b2f29a34b37d6f9efe792464396b3c962f9fab82a6ae32f85f0dc8
BLAKE2b-256 checksum
How to use checksums
e7bf5c83ce60a790ea2902381b2ae928772c5d16a60e3cd8d85e8378cc512e35
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.9.6

Release history Release notifications | RSS feed

0.1.1

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page