3454a55636
cffconvert / validate (push) Has been cancelled
ci-workflow / pre-commit (push) Has been cancelled
ci-workflow / Minimal NLTK Download Test (macos-latest) (push) Has been cancelled
ci-workflow / Minimal NLTK Download Test (ubuntu-latest) (push) Has been cancelled
ci-workflow / Minimal NLTK Download Test (windows-latest) (push) Has been cancelled
ci-workflow / Python 3.10 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.11 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.12 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.13 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.14 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.14t on macos-latest (push) Has been cancelled
ci-workflow / Python 3.10 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.11 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.12 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.13 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.14 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.14t on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.10 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.11 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.12 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.13 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.14 on windows-latest (push) Has been cancelled
161 lines
5.5 KiB
Python
161 lines
5.5 KiB
Python
"""Regression tests for path traversal in FramenetCorpusReader (CWE-22).
|
|
|
|
``doc()``, ``frame_by_name()`` and ``_lu_file()`` interpolate a caller- or
|
|
corpus-supplied name into an XML file path that is then read via
|
|
``XMLCorpusView``. A ``..`` sequence, an absolute path, or a Windows drive/UNC
|
|
prefix in that name must be rejected *before* any file is opened, while a
|
|
legitimate name must still resolve and read correctly through ``self.abspath()``
|
|
(the ``nltk.pathsec`` sandbox).
|
|
|
|
Paths are built with ``os.path.join`` / ``os.pardir`` so the tests behave the
|
|
same on POSIX and Windows.
|
|
"""
|
|
|
|
import builtins
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from nltk.corpus.reader.framenet import (
|
|
AttrDict,
|
|
FramenetCorpusReader,
|
|
FramenetError,
|
|
_reject_unsafe_path_component,
|
|
)
|
|
|
|
_FRAME_XML = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<frame xmlns="http://framenet.icsi.berkeley.edu" ID="42" name="{name}">\n'
|
|
"<definition>A minimal test frame.</definition>\n"
|
|
"</frame>\n"
|
|
)
|
|
|
|
|
|
def _make_corpus(tmp_path):
|
|
root = tmp_path / "framenet"
|
|
for d in ("frame", "fulltext", "lu"):
|
|
(root / d).mkdir(parents=True)
|
|
(root / "frameIndex.xml").write_text(
|
|
'<?xml version="1.0"?><frameIndex></frameIndex>'
|
|
)
|
|
(root / "frRelation.xml").write_text(
|
|
'<?xml version="1.0"?><frameRelations></frameRelations>'
|
|
)
|
|
return root
|
|
|
|
|
|
def _record_opens(monkeypatch):
|
|
"""Spy on builtin open() so a test can assert nothing outside the corpus
|
|
was ever opened."""
|
|
opened = []
|
|
real_open = builtins.open
|
|
|
|
def spy(file, *args, **kwargs):
|
|
opened.append(str(file))
|
|
return real_open(file, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "open", spy)
|
|
return opened
|
|
|
|
|
|
# --- legitimate flow still works (guards against the abspath() refactor) -------
|
|
|
|
|
|
def test_framenet_frame_reads_legitimate_file(tmp_path):
|
|
"""A real in-root frame must still parse after routing through abspath()."""
|
|
root = _make_corpus(tmp_path)
|
|
(root / "frame" / "TestFrame.xml").write_text(_FRAME_XML.format(name="TestFrame"))
|
|
|
|
fn = FramenetCorpusReader(str(root), [])
|
|
f = fn.frame("TestFrame")
|
|
assert f.name == "TestFrame"
|
|
assert str(f.ID) == "42"
|
|
|
|
|
|
def test_framenet_allows_normal_frame_name(tmp_path):
|
|
"""A normal but absent frame name fails as 'unknown', not 'invalid'."""
|
|
fn = FramenetCorpusReader(str(_make_corpus(tmp_path)), [])
|
|
with pytest.raises(FramenetError) as exc:
|
|
fn.frame("NoSuchFrame")
|
|
assert "Invalid frame name" not in str(exc.value)
|
|
assert "Unknown frame" in str(exc.value)
|
|
|
|
|
|
# --- frame_by_name(): traversal + Windows drive rejection ----------------------
|
|
|
|
|
|
def test_framenet_rejects_traversal_frame_name(tmp_path, monkeypatch):
|
|
"""A ../ traversal in the frame name is rejected before any file read."""
|
|
root = _make_corpus(tmp_path)
|
|
secret = tmp_path / "outside"
|
|
secret.mkdir()
|
|
(secret / "pwn.xml").write_text(_FRAME_XML.format(name="pwned"))
|
|
|
|
fn = FramenetCorpusReader(str(root), [])
|
|
opened = _record_opens(monkeypatch)
|
|
evil = os.path.join(os.pardir, os.pardir, "outside", "pwn")
|
|
with pytest.raises(FramenetError, match="Invalid frame name"):
|
|
fn.frame(evil)
|
|
assert not any("outside" in p for p in opened), "traversal reached the filesystem"
|
|
|
|
|
|
def test_framenet_rejects_drive_prefixed_frame_name(tmp_path):
|
|
"""A Windows drive-qualified name (no separator, no '..') is rejected."""
|
|
fn = FramenetCorpusReader(str(_make_corpus(tmp_path)), [])
|
|
with pytest.raises(FramenetError, match="Invalid frame name"):
|
|
fn.frame("C:evil")
|
|
|
|
|
|
# --- doc(): the fulltext filename comes from the (untrusted) corpus index ------
|
|
|
|
|
|
def test_framenet_doc_rejects_traversal_filename(tmp_path, monkeypatch):
|
|
root = _make_corpus(tmp_path)
|
|
fn = FramenetCorpusReader(str(root), [])
|
|
# Simulate a crafted corpus index whose document filename traverses out.
|
|
evil = os.path.join(os.pardir, os.pardir, "outside", "pwn")
|
|
fn._fulltext_idx = {7: AttrDict({"filename": evil})}
|
|
|
|
opened = _record_opens(monkeypatch)
|
|
with pytest.raises(FramenetError, match="Invalid document filename"):
|
|
fn.doc(7)
|
|
assert not any("outside" in p for p in opened), "traversal reached the filesystem"
|
|
|
|
|
|
def test_framenet_doc_rejects_drive_prefixed_filename(tmp_path):
|
|
fn = FramenetCorpusReader(str(_make_corpus(tmp_path)), [])
|
|
fn._fulltext_idx = {7: AttrDict({"filename": "C:evil"})}
|
|
with pytest.raises(FramenetError, match="Invalid document filename"):
|
|
fn.doc(7)
|
|
|
|
|
|
# --- _lu_file(): the LU id comes from corpus data ------------------------------
|
|
|
|
|
|
def test_framenet_lu_file_rejects_traversal_id(tmp_path, monkeypatch):
|
|
root = _make_corpus(tmp_path)
|
|
fn = FramenetCorpusReader(str(root), [])
|
|
lu = AttrDict({"ID": os.path.join(os.pardir, os.pardir, "outside", "SECRET")})
|
|
|
|
opened = _record_opens(monkeypatch)
|
|
with pytest.raises(FramenetError, match="Invalid LU id"):
|
|
fn._lu_file(lu)
|
|
assert not any("outside" in p for p in opened), "traversal reached the filesystem"
|
|
|
|
|
|
# --- the validation helper itself ---------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
["../x", "a/b", "a\\b", "..", "C:evil", r"\\host\share\x", "/abs.xml"],
|
|
)
|
|
def test_reject_unsafe_path_component_blocks(bad):
|
|
with pytest.raises(FramenetError):
|
|
_reject_unsafe_path_component(bad, "frame name")
|
|
|
|
|
|
@pytest.mark.parametrize("ok", ["TestFrame", "Apply_heat", "lu123", "a.b-c"])
|
|
def test_reject_unsafe_path_component_allows_normal(ok):
|
|
_reject_unsafe_path_component(ok, "frame name") # must not raise
|