linkward 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linkward-0.1.0/.github/workflows/ci.yml +58 -0
- linkward-0.1.0/.gitignore +11 -0
- linkward-0.1.0/LICENSE +21 -0
- linkward-0.1.0/PKG-INFO +254 -0
- linkward-0.1.0/README.md +203 -0
- linkward-0.1.0/SECURITY.md +37 -0
- linkward-0.1.0/examples/symlink_attack.py +91 -0
- linkward-0.1.0/pyproject.toml +55 -0
- linkward-0.1.0/src/linkward/__init__.py +27 -0
- linkward-0.1.0/src/linkward/core.py +353 -0
- linkward-0.1.0/src/linkward/py.typed +0 -0
- linkward-0.1.0/tests/test_linkward.py +629 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test:
|
|
17
|
+
name: ${{ matrix.os }} / Python ${{ matrix.python-version }}
|
|
18
|
+
timeout-minutes: 10
|
|
19
|
+
strategy:
|
|
20
|
+
fail-fast: false
|
|
21
|
+
matrix:
|
|
22
|
+
# Intel macOS also supports the CPython 3.9 binaries from setup-python.
|
|
23
|
+
os: [ubuntu-latest, macos-15-intel]
|
|
24
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
25
|
+
runs-on: ${{ matrix.os }}
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v5
|
|
28
|
+
- uses: actions/setup-python@v6
|
|
29
|
+
with:
|
|
30
|
+
python-version: ${{ matrix.python-version }}
|
|
31
|
+
- run: python -m pip install --upgrade pip
|
|
32
|
+
- run: python -m pip install -e '.[test]'
|
|
33
|
+
- run: python -m pytest
|
|
34
|
+
- run: python examples/symlink_attack.py
|
|
35
|
+
|
|
36
|
+
build:
|
|
37
|
+
name: Build and verify distributions
|
|
38
|
+
timeout-minutes: 10
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/checkout@v5
|
|
42
|
+
- uses: actions/setup-python@v6
|
|
43
|
+
with:
|
|
44
|
+
python-version: "3.14"
|
|
45
|
+
- run: python -m pip install build twine ruff mypy
|
|
46
|
+
- run: python -m ruff check src tests examples
|
|
47
|
+
- run: python -m ruff format --check src tests examples
|
|
48
|
+
- run: python -m mypy --strict src/safeopen
|
|
49
|
+
- run: python -m build
|
|
50
|
+
- run: python -m twine check dist/*
|
|
51
|
+
- name: Verify the installed wheel outside the checkout
|
|
52
|
+
shell: bash
|
|
53
|
+
run: |
|
|
54
|
+
python -m pip install --no-deps dist/*.whl
|
|
55
|
+
smoke_dir="$(mktemp -d)"
|
|
56
|
+
cd "$smoke_dir"
|
|
57
|
+
python -I -c "import safeopen; print(safeopen.__version__, safeopen.__file__)"
|
|
58
|
+
python -I "$GITHUB_WORKSPACE/examples/symlink_attack.py"
|
linkward-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elton Pang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
linkward-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: linkward
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read local files without following symlinks, with owner, mode, and size checks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/alohaaaaaaaaaaaaaa/safeopen
|
|
6
|
+
Project-URL: Issues, https://github.com/alohaaaaaaaaaaaaaa/safeopen/issues
|
|
7
|
+
Project-URL: Source, https://github.com/alohaaaaaaaaaaaaaa/safeopen
|
|
8
|
+
Author: Elton Pang
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Elton Pang
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
Keywords: file,o_nofollow,race-condition,security,symlink,toctou
|
|
31
|
+
Classifier: Development Status :: 4 - Beta
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
35
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
44
|
+
Classifier: Topic :: Security
|
|
45
|
+
Classifier: Topic :: System :: Filesystems
|
|
46
|
+
Classifier: Typing :: Typed
|
|
47
|
+
Requires-Python: >=3.9
|
|
48
|
+
Provides-Extra: test
|
|
49
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
50
|
+
Description-Content-Type: text/markdown
|
|
51
|
+
|
|
52
|
+
# linkward
|
|
53
|
+
|
|
54
|
+
**Read a local file while rejecting symbolic links in every path component, with optional owner, permission, and size checks.**
|
|
55
|
+
|
|
56
|
+
Pure Python, zero runtime dependencies. Linux and macOS, Python 3.9+.
|
|
57
|
+
|
|
58
|
+
Use it when a Python service or CLI reads a credential or configuration file and
|
|
59
|
+
must reject symlink redirection. `linkward` opens each directory relative to the
|
|
60
|
+
previous directory's descriptor with `O_NOFOLLOW`, then opens and reads the final
|
|
61
|
+
file through the same descriptor used for policy checks.
|
|
62
|
+
|
|
63
|
+
| Read method | Rejects a symlink at the final file | Rejects symlinks in parent directories |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `open(path, "rb")` | No | No |
|
|
66
|
+
| `os.open(path, os.O_RDONLY \| os.O_NOFOLLOW)` | Yes | No |
|
|
67
|
+
| `safe_open(path)` | Yes | Yes |
|
|
68
|
+
|
|
69
|
+
This prevents symlink traversal during this open operation. It does **not** make
|
|
70
|
+
an attacker-writable regular file trustworthy or provide a snapshot of its contents.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
This project has not been published to PyPI. Install from the repository:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
python -m pip install 'git+https://github.com/alohaaaaaaaaaaaaaa/linkward.git'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
For a reproducible deployment, pin a reviewed commit after the `.git` URL with
|
|
81
|
+
`@<commit>`.
|
|
82
|
+
|
|
83
|
+
## Read a credential
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import os
|
|
87
|
+
from pathlib import Path
|
|
88
|
+
|
|
89
|
+
from linkward import SafeOpenError, safe_open
|
|
90
|
+
|
|
91
|
+
# A configured absolute path with no symlinks in any component.
|
|
92
|
+
credential_path = Path("/srv/myapp/token")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
token = safe_open(
|
|
96
|
+
credential_path,
|
|
97
|
+
require_owner=os.geteuid(),
|
|
98
|
+
require_mode=0o600,
|
|
99
|
+
max_bytes=64 * 1024,
|
|
100
|
+
)
|
|
101
|
+
except SafeOpenError:
|
|
102
|
+
# Abort startup rather than using a credential that failed these checks.
|
|
103
|
+
raise
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Owner and permission checks are **off by default**; enable them for your trust
|
|
107
|
+
policy. The example requires the effective user's ownership and exactly `0600`
|
|
108
|
+
ordinary permission bits. A matching owner and mode do not establish content
|
|
109
|
+
authenticity, and ACLs or other writers may still permit changes.
|
|
110
|
+
|
|
111
|
+
Paths must be absolute `str` or `os.PathLike[str]` values. Relative paths, `.` or
|
|
112
|
+
`..` components, NUL characters, the filesystem root, and trailing slashes are
|
|
113
|
+
rejected. Validation applies to the text returned by `os.fspath()`; a `Path`
|
|
114
|
+
object may already have removed `.` components or trailing slashes. Bytes paths
|
|
115
|
+
are unsupported.
|
|
116
|
+
|
|
117
|
+
Parent directories are opened read-only, so the caller needs directory read and
|
|
118
|
+
search permissions as well as permission to read the file.
|
|
119
|
+
|
|
120
|
+
On macOS, `/tmp`, `/var`, and `/etc` commonly contain or are symlinks. Configure a
|
|
121
|
+
trusted canonical path such as `/private/etc/myapp/token` if appropriate for your
|
|
122
|
+
system. Do not call `resolve()` or `realpath()` on an untrusted path just to make it
|
|
123
|
+
pass: those operations follow links, and a later open can encounter a changed path.
|
|
124
|
+
|
|
125
|
+
## Run the attack demonstration
|
|
126
|
+
|
|
127
|
+
After installation, from a repository checkout:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python examples/symlink_attack.py
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The demonstration compares ordinary `open()`, final-component-only `O_NOFOLLOW`,
|
|
134
|
+
and `safe_open()`. It also performs a controlled swap between a separate path
|
|
135
|
+
check and open. The timing is deliberate: this illustrates the failure mode and
|
|
136
|
+
the protection, rather than measuring a probabilistic race's success rate.
|
|
137
|
+
|
|
138
|
+
## API
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
safe_open(
|
|
142
|
+
path,
|
|
143
|
+
*,
|
|
144
|
+
max_bytes=1024 * 1024,
|
|
145
|
+
require_mode=None,
|
|
146
|
+
require_owner=None,
|
|
147
|
+
require_regular_file=True,
|
|
148
|
+
require_single_hardlink=False,
|
|
149
|
+
) -> bytes
|
|
150
|
+
|
|
151
|
+
open_nofollow(path) -> int
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| `safe_open` option | Behavior |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `max_bytes` | Nonnegative integer; reject an oversized file. Returned bytes never exceed this limit. |
|
|
157
|
+
| `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. |
|
|
158
|
+
| `require_owner` | Optional nonnegative user ID to match against `st_uid`, commonly `os.geteuid()`. |
|
|
159
|
+
| `require_regular_file` | Default `True`; reject directories, FIFOs, devices, sockets, and other nonregular files. |
|
|
160
|
+
| `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. |
|
|
161
|
+
|
|
162
|
+
Integer options reject booleans; the two boolean options require actual `bool`
|
|
163
|
+
values. Policy checks use the initial descriptor metadata, followed by the
|
|
164
|
+
before/after change check. Setting `require_regular_file=False` does not turn this
|
|
165
|
+
into a general stream reader: size and read-length consistency checks still apply,
|
|
166
|
+
so pseudo-files that report a size of zero can be rejected despite having content.
|
|
167
|
+
|
|
168
|
+
Size detection may read one byte beyond `max_bytes` before rejecting a growing
|
|
169
|
+
file. Chunk storage and combining the result also require memory beyond the
|
|
170
|
+
returned bytes; `max_bytes` is not a strict process-memory limit.
|
|
171
|
+
|
|
172
|
+
`open_nofollow()` only provides the path traversal guarantee. It does not enforce
|
|
173
|
+
file type, owner, permissions, size, link count, or content stability. Its descriptor
|
|
174
|
+
is opened read-only and nonblocking and is not inherited across `exec`; the caller
|
|
175
|
+
must close it:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
import os
|
|
179
|
+
from linkward import open_nofollow
|
|
180
|
+
|
|
181
|
+
fd = open_nofollow("/srv/myapp/token")
|
|
182
|
+
try:
|
|
183
|
+
metadata = os.fstat(fd)
|
|
184
|
+
# Apply your own policy and read from this descriptor.
|
|
185
|
+
finally:
|
|
186
|
+
os.close(fd)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
All library failures derive from `SafeOpenError`:
|
|
190
|
+
|
|
191
|
+
| Exception | Meaning |
|
|
192
|
+
| --- | --- |
|
|
193
|
+
| `SymlinkRejected` | A symbolic link was identified in a rejected path component. |
|
|
194
|
+
| `FilePolicyError` | The opened file did not meet the requested type, owner, mode, size, or link-count policy. |
|
|
195
|
+
| `FileChangedDuringRead` | Descriptor metadata or the amount read indicated a change during the read. |
|
|
196
|
+
| `SafeOpenError` | Invalid input, unsupported platform, or another filesystem failure. |
|
|
197
|
+
|
|
198
|
+
Filesystem `OSError`s are wrapped with the original exception in `__cause__` for
|
|
199
|
+
diagnostics. A concurrent path change can affect the exception classification;
|
|
200
|
+
the operation still rejects a component it cannot open safely.
|
|
201
|
+
|
|
202
|
+
## Security boundaries
|
|
203
|
+
|
|
204
|
+
- **Concurrent content writes:** the before/after `fstat` comparison is a
|
|
205
|
+
best-effort change detector. Writes through `mmap`, filesystem timestamp
|
|
206
|
+
behavior, or other concurrent writes can leave indistinguishable metadata.
|
|
207
|
+
A successful read can contain a mixture of bytes from different moments.
|
|
208
|
+
There is no snapshot or authenticity guarantee. Use an appropriate publication
|
|
209
|
+
or integrity scheme when those guarantees are required.
|
|
210
|
+
- **Renames and directory replacement:** once opened, a descriptor stays attached
|
|
211
|
+
to the selected object. Renaming a path does not make the reader follow the new
|
|
212
|
+
name and does not necessarily cause an error. Already-opened directories can
|
|
213
|
+
also be moved; traversal is not confinement within a pathname boundary.
|
|
214
|
+
- **Other redirection and writers:** malicious regular files, hard links, bind
|
|
215
|
+
mounts, mount changes, privileged attackers, and other processes with write
|
|
216
|
+
access are outside the symlink guarantee. Protect files and their directories
|
|
217
|
+
according to your threat model. This library is not a sandbox or `chroot`.
|
|
218
|
+
- **Platform and I/O:** Windows is unsupported. Filesystem behavior still matters;
|
|
219
|
+
nonblocking open avoids the usual FIFO-open wait but does not guarantee a time
|
|
220
|
+
limit on file operations or safe reads from arbitrary devices. Keep the default
|
|
221
|
+
regular-file requirement unless you explicitly handle other file types.
|
|
222
|
+
|
|
223
|
+
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
|
|
224
|
+
|
|
225
|
+
## Related work
|
|
226
|
+
|
|
227
|
+
Safe path handling predates this project. Other approaches include
|
|
228
|
+
[`pathrs`](https://pypi.org/project/pathrs/) and
|
|
229
|
+
[`google/linkward`](https://github.com/google/linkward); their APIs, dependencies,
|
|
230
|
+
and confinement semantics differ. This project's focus is a small Python API for
|
|
231
|
+
rejecting every symlink component and checking the descriptor being read.
|
|
232
|
+
|
|
233
|
+
The `filelock` advisories
|
|
234
|
+
[CVE-2025-68146](https://github.com/tox-dev/filelock/security/advisories/GHSA-w853-jp5j-5j7f)
|
|
235
|
+
and [CVE-2026-22701](https://github.com/tox-dev/filelock/security/advisories/GHSA-qmgc-5h2g-mvrw)
|
|
236
|
+
illustrate related symlink risks in lock-file creation and locking. `linkward` is
|
|
237
|
+
read-only; it does not implement those locking operations or replace their fixes.
|
|
238
|
+
|
|
239
|
+
## Development
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
git clone https://github.com/alohaaaaaaaaaaaaaa/linkward.git
|
|
243
|
+
cd linkward
|
|
244
|
+
python -m pip install --upgrade pip
|
|
245
|
+
python -m pip install -e '.[test]'
|
|
246
|
+
python -m pytest
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The tests cover accepted reads as well as rejected paths and policies, controlled
|
|
250
|
+
concurrent changes, and descriptor cleanup.
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
MIT. See [LICENSE](LICENSE).
|
linkward-0.1.0/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# linkward
|
|
2
|
+
|
|
3
|
+
**Read a local file while rejecting symbolic links in every path component, with optional owner, permission, and size checks.**
|
|
4
|
+
|
|
5
|
+
Pure Python, zero runtime dependencies. Linux and macOS, Python 3.9+.
|
|
6
|
+
|
|
7
|
+
Use it when a Python service or CLI reads a credential or configuration file and
|
|
8
|
+
must reject symlink redirection. `linkward` opens each directory relative to the
|
|
9
|
+
previous directory's descriptor with `O_NOFOLLOW`, then opens and reads the final
|
|
10
|
+
file through the same descriptor used for policy checks.
|
|
11
|
+
|
|
12
|
+
| Read method | Rejects a symlink at the final file | Rejects symlinks in parent directories |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| `open(path, "rb")` | No | No |
|
|
15
|
+
| `os.open(path, os.O_RDONLY \| os.O_NOFOLLOW)` | Yes | No |
|
|
16
|
+
| `safe_open(path)` | Yes | Yes |
|
|
17
|
+
|
|
18
|
+
This prevents symlink traversal during this open operation. It does **not** make
|
|
19
|
+
an attacker-writable regular file trustworthy or provide a snapshot of its contents.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
This project has not been published to PyPI. Install from the repository:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m pip install 'git+https://github.com/alohaaaaaaaaaaaaaa/linkward.git'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For a reproducible deployment, pin a reviewed commit after the `.git` URL with
|
|
30
|
+
`@<commit>`.
|
|
31
|
+
|
|
32
|
+
## Read a credential
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import os
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from linkward import SafeOpenError, safe_open
|
|
39
|
+
|
|
40
|
+
# A configured absolute path with no symlinks in any component.
|
|
41
|
+
credential_path = Path("/srv/myapp/token")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
token = safe_open(
|
|
45
|
+
credential_path,
|
|
46
|
+
require_owner=os.geteuid(),
|
|
47
|
+
require_mode=0o600,
|
|
48
|
+
max_bytes=64 * 1024,
|
|
49
|
+
)
|
|
50
|
+
except SafeOpenError:
|
|
51
|
+
# Abort startup rather than using a credential that failed these checks.
|
|
52
|
+
raise
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Owner and permission checks are **off by default**; enable them for your trust
|
|
56
|
+
policy. The example requires the effective user's ownership and exactly `0600`
|
|
57
|
+
ordinary permission bits. A matching owner and mode do not establish content
|
|
58
|
+
authenticity, and ACLs or other writers may still permit changes.
|
|
59
|
+
|
|
60
|
+
Paths must be absolute `str` or `os.PathLike[str]` values. Relative paths, `.` or
|
|
61
|
+
`..` components, NUL characters, the filesystem root, and trailing slashes are
|
|
62
|
+
rejected. Validation applies to the text returned by `os.fspath()`; a `Path`
|
|
63
|
+
object may already have removed `.` components or trailing slashes. Bytes paths
|
|
64
|
+
are unsupported.
|
|
65
|
+
|
|
66
|
+
Parent directories are opened read-only, so the caller needs directory read and
|
|
67
|
+
search permissions as well as permission to read the file.
|
|
68
|
+
|
|
69
|
+
On macOS, `/tmp`, `/var`, and `/etc` commonly contain or are symlinks. Configure a
|
|
70
|
+
trusted canonical path such as `/private/etc/myapp/token` if appropriate for your
|
|
71
|
+
system. Do not call `resolve()` or `realpath()` on an untrusted path just to make it
|
|
72
|
+
pass: those operations follow links, and a later open can encounter a changed path.
|
|
73
|
+
|
|
74
|
+
## Run the attack demonstration
|
|
75
|
+
|
|
76
|
+
After installation, from a repository checkout:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python examples/symlink_attack.py
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The demonstration compares ordinary `open()`, final-component-only `O_NOFOLLOW`,
|
|
83
|
+
and `safe_open()`. It also performs a controlled swap between a separate path
|
|
84
|
+
check and open. The timing is deliberate: this illustrates the failure mode and
|
|
85
|
+
the protection, rather than measuring a probabilistic race's success rate.
|
|
86
|
+
|
|
87
|
+
## API
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
safe_open(
|
|
91
|
+
path,
|
|
92
|
+
*,
|
|
93
|
+
max_bytes=1024 * 1024,
|
|
94
|
+
require_mode=None,
|
|
95
|
+
require_owner=None,
|
|
96
|
+
require_regular_file=True,
|
|
97
|
+
require_single_hardlink=False,
|
|
98
|
+
) -> bytes
|
|
99
|
+
|
|
100
|
+
open_nofollow(path) -> int
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| `safe_open` option | Behavior |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `max_bytes` | Nonnegative integer; reject an oversized file. Returned bytes never exceed this limit. |
|
|
106
|
+
| `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. |
|
|
107
|
+
| `require_owner` | Optional nonnegative user ID to match against `st_uid`, commonly `os.geteuid()`. |
|
|
108
|
+
| `require_regular_file` | Default `True`; reject directories, FIFOs, devices, sockets, and other nonregular files. |
|
|
109
|
+
| `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. |
|
|
110
|
+
|
|
111
|
+
Integer options reject booleans; the two boolean options require actual `bool`
|
|
112
|
+
values. Policy checks use the initial descriptor metadata, followed by the
|
|
113
|
+
before/after change check. Setting `require_regular_file=False` does not turn this
|
|
114
|
+
into a general stream reader: size and read-length consistency checks still apply,
|
|
115
|
+
so pseudo-files that report a size of zero can be rejected despite having content.
|
|
116
|
+
|
|
117
|
+
Size detection may read one byte beyond `max_bytes` before rejecting a growing
|
|
118
|
+
file. Chunk storage and combining the result also require memory beyond the
|
|
119
|
+
returned bytes; `max_bytes` is not a strict process-memory limit.
|
|
120
|
+
|
|
121
|
+
`open_nofollow()` only provides the path traversal guarantee. It does not enforce
|
|
122
|
+
file type, owner, permissions, size, link count, or content stability. Its descriptor
|
|
123
|
+
is opened read-only and nonblocking and is not inherited across `exec`; the caller
|
|
124
|
+
must close it:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
import os
|
|
128
|
+
from linkward import open_nofollow
|
|
129
|
+
|
|
130
|
+
fd = open_nofollow("/srv/myapp/token")
|
|
131
|
+
try:
|
|
132
|
+
metadata = os.fstat(fd)
|
|
133
|
+
# Apply your own policy and read from this descriptor.
|
|
134
|
+
finally:
|
|
135
|
+
os.close(fd)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
All library failures derive from `SafeOpenError`:
|
|
139
|
+
|
|
140
|
+
| Exception | Meaning |
|
|
141
|
+
| --- | --- |
|
|
142
|
+
| `SymlinkRejected` | A symbolic link was identified in a rejected path component. |
|
|
143
|
+
| `FilePolicyError` | The opened file did not meet the requested type, owner, mode, size, or link-count policy. |
|
|
144
|
+
| `FileChangedDuringRead` | Descriptor metadata or the amount read indicated a change during the read. |
|
|
145
|
+
| `SafeOpenError` | Invalid input, unsupported platform, or another filesystem failure. |
|
|
146
|
+
|
|
147
|
+
Filesystem `OSError`s are wrapped with the original exception in `__cause__` for
|
|
148
|
+
diagnostics. A concurrent path change can affect the exception classification;
|
|
149
|
+
the operation still rejects a component it cannot open safely.
|
|
150
|
+
|
|
151
|
+
## Security boundaries
|
|
152
|
+
|
|
153
|
+
- **Concurrent content writes:** the before/after `fstat` comparison is a
|
|
154
|
+
best-effort change detector. Writes through `mmap`, filesystem timestamp
|
|
155
|
+
behavior, or other concurrent writes can leave indistinguishable metadata.
|
|
156
|
+
A successful read can contain a mixture of bytes from different moments.
|
|
157
|
+
There is no snapshot or authenticity guarantee. Use an appropriate publication
|
|
158
|
+
or integrity scheme when those guarantees are required.
|
|
159
|
+
- **Renames and directory replacement:** once opened, a descriptor stays attached
|
|
160
|
+
to the selected object. Renaming a path does not make the reader follow the new
|
|
161
|
+
name and does not necessarily cause an error. Already-opened directories can
|
|
162
|
+
also be moved; traversal is not confinement within a pathname boundary.
|
|
163
|
+
- **Other redirection and writers:** malicious regular files, hard links, bind
|
|
164
|
+
mounts, mount changes, privileged attackers, and other processes with write
|
|
165
|
+
access are outside the symlink guarantee. Protect files and their directories
|
|
166
|
+
according to your threat model. This library is not a sandbox or `chroot`.
|
|
167
|
+
- **Platform and I/O:** Windows is unsupported. Filesystem behavior still matters;
|
|
168
|
+
nonblocking open avoids the usual FIFO-open wait but does not guarantee a time
|
|
169
|
+
limit on file operations or safe reads from arbitrary devices. Keep the default
|
|
170
|
+
regular-file requirement unless you explicitly handle other file types.
|
|
171
|
+
|
|
172
|
+
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
|
|
173
|
+
|
|
174
|
+
## Related work
|
|
175
|
+
|
|
176
|
+
Safe path handling predates this project. Other approaches include
|
|
177
|
+
[`pathrs`](https://pypi.org/project/pathrs/) and
|
|
178
|
+
[`google/linkward`](https://github.com/google/linkward); their APIs, dependencies,
|
|
179
|
+
and confinement semantics differ. This project's focus is a small Python API for
|
|
180
|
+
rejecting every symlink component and checking the descriptor being read.
|
|
181
|
+
|
|
182
|
+
The `filelock` advisories
|
|
183
|
+
[CVE-2025-68146](https://github.com/tox-dev/filelock/security/advisories/GHSA-w853-jp5j-5j7f)
|
|
184
|
+
and [CVE-2026-22701](https://github.com/tox-dev/filelock/security/advisories/GHSA-qmgc-5h2g-mvrw)
|
|
185
|
+
illustrate related symlink risks in lock-file creation and locking. `linkward` is
|
|
186
|
+
read-only; it does not implement those locking operations or replace their fixes.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
git clone https://github.com/alohaaaaaaaaaaaaaa/linkward.git
|
|
192
|
+
cd linkward
|
|
193
|
+
python -m pip install --upgrade pip
|
|
194
|
+
python -m pip install -e '.[test]'
|
|
195
|
+
python -m pytest
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The tests cover accepted reads as well as rejected paths and policies, controlled
|
|
199
|
+
concurrent changes, and descriptor cleanup.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Report a vulnerability
|
|
4
|
+
|
|
5
|
+
Please report suspected security vulnerabilities through
|
|
6
|
+
[GitHub's private vulnerability reporting form](https://github.com/alohaaaaaaaaaaaaaa/safeopen/security/advisories/new).
|
|
7
|
+
Avoid posting exploit details or credentials in a public issue before maintainers
|
|
8
|
+
have had an opportunity to investigate.
|
|
9
|
+
|
|
10
|
+
If private reporting is unavailable, open a public issue asking for a private
|
|
11
|
+
reporting channel, without vulnerability details or sensitive data.
|
|
12
|
+
|
|
13
|
+
Include the affected commit or version, Python and operating-system versions,
|
|
14
|
+
filesystem details where relevant, and a minimal reproduction. Explain the
|
|
15
|
+
expected guarantee and the behavior you observed. Use synthetic files and data.
|
|
16
|
+
|
|
17
|
+
This is an early-stage project. There is no fixed response-time commitment or
|
|
18
|
+
long-term support schedule. Use a reviewed revision and track subsequent security
|
|
19
|
+
changes when adopting it.
|
|
20
|
+
|
|
21
|
+
## What the library protects
|
|
22
|
+
|
|
23
|
+
`safeopen` rejects symbolic links in every path component during descriptor-based
|
|
24
|
+
traversal. `safe_open()` reads the same descriptor used for file-policy checks;
|
|
25
|
+
`open_nofollow()` supplies only the traversal primitive.
|
|
26
|
+
|
|
27
|
+
Before/after metadata checks detect some concurrent changes. They do not provide
|
|
28
|
+
an atomic snapshot, detect every write, authenticate content, or ensure exclusive
|
|
29
|
+
access. In particular, memory-mapped writes and filesystem metadata behavior can
|
|
30
|
+
defeat change detection. Renaming a selected object does not redirect its open
|
|
31
|
+
descriptor and need not be detected.
|
|
32
|
+
|
|
33
|
+
The library does not provide directory confinement, a process sandbox, or
|
|
34
|
+
protection against malicious regular files, hard links, bind mounts, mount changes,
|
|
35
|
+
privileged attackers, or writers with access to the opened file. Callers must
|
|
36
|
+
establish a suitable trust policy for both files and directories. See the
|
|
37
|
+
[README](README.md#security-boundaries) for API defaults and additional limits.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Run with ``python examples/symlink_attack.py`` after installing linkward.
|
|
2
|
+
|
|
3
|
+
All attacks use private temporary fixtures. Swaps happen between explicit steps,
|
|
4
|
+
so this demonstrates a vulnerable interleaving without relying on scheduler luck.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from linkward import SymlinkRejected, safe_open
|
|
14
|
+
|
|
15
|
+
EXPECTED = b"expected configuration\n"
|
|
16
|
+
ATTACKER = b"attacker-controlled configuration\n"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def leaf_only_read(path: Path) -> bytes:
|
|
20
|
+
"""A common incomplete defense: O_NOFOLLOW protects only the final name."""
|
|
21
|
+
fd = os.open(str(path), os.O_RDONLY | os.O_NOFOLLOW)
|
|
22
|
+
try:
|
|
23
|
+
return os.read(fd, 4096)
|
|
24
|
+
finally:
|
|
25
|
+
os.close(fd)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def expect_rejection(path: Path) -> None:
|
|
29
|
+
try:
|
|
30
|
+
safe_open(str(path))
|
|
31
|
+
except SymlinkRejected:
|
|
32
|
+
return
|
|
33
|
+
raise AssertionError(f"safe_open followed a symlink: {path}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> None:
|
|
37
|
+
print("Controlled local demo: each outcome is asserted; no racing threads.")
|
|
38
|
+
with tempfile.TemporaryDirectory(prefix="linkward-demo-") as temporary:
|
|
39
|
+
# Resolve only the trusted fixture root: macOS /var is itself a symlink.
|
|
40
|
+
# Never resolve an attacker-controlled input before passing it to safe_open.
|
|
41
|
+
root = Path(temporary).resolve(strict=True)
|
|
42
|
+
normal = root / "normal.conf"
|
|
43
|
+
normal.write_bytes(EXPECTED)
|
|
44
|
+
hostile_directory = root / "attacker"
|
|
45
|
+
hostile_directory.mkdir()
|
|
46
|
+
hostile = hostile_directory / "config"
|
|
47
|
+
hostile.write_bytes(ATTACKER)
|
|
48
|
+
|
|
49
|
+
assert safe_open(str(normal)) == EXPECTED
|
|
50
|
+
print("[PASS] Regular file: safe_open returns the expected bytes.")
|
|
51
|
+
|
|
52
|
+
leaf_link = root / "leaf-link.conf"
|
|
53
|
+
leaf_link.symlink_to(hostile)
|
|
54
|
+
assert leaf_link.read_bytes() == ATTACKER
|
|
55
|
+
expect_rejection(leaf_link)
|
|
56
|
+
print("[PASS] Final symlink: open follows it; safe_open rejects it.")
|
|
57
|
+
|
|
58
|
+
directory_link = root / "directory-link"
|
|
59
|
+
directory_link.symlink_to(hostile_directory, target_is_directory=True)
|
|
60
|
+
through_directory = directory_link / "config"
|
|
61
|
+
assert through_directory.read_bytes() == ATTACKER
|
|
62
|
+
assert leaf_only_read(through_directory) == ATTACKER
|
|
63
|
+
expect_rejection(through_directory)
|
|
64
|
+
print(
|
|
65
|
+
"[PASS] Directory symlink: leaf-only O_NOFOLLOW follows it; safe_open rejects it."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
checked_directory = root / "checked"
|
|
69
|
+
checked_directory.mkdir()
|
|
70
|
+
candidate = checked_directory / "config"
|
|
71
|
+
candidate.write_bytes(EXPECTED)
|
|
72
|
+
checked = candidate.resolve(strict=True)
|
|
73
|
+
assert checked.parent == checked_directory
|
|
74
|
+
assert checked.read_bytes() == EXPECTED
|
|
75
|
+
|
|
76
|
+
# Simulate an attacker acting after validation but before the later open.
|
|
77
|
+
checked_directory.rename(root / "checked-original")
|
|
78
|
+
checked_directory.symlink_to(hostile_directory, target_is_directory=True)
|
|
79
|
+
assert checked.read_bytes() == ATTACKER
|
|
80
|
+
expect_rejection(checked)
|
|
81
|
+
print(
|
|
82
|
+
"[PASS] Check then swap: a previously resolved path is redirected; safe_open rejects it."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
print(
|
|
86
|
+
"All checks passed. This demonstrates symlink rejection, not a content snapshot."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|