Root cause
Path & link handling
The rework unified the path guards into one helper and sent the three XML reads through the corpus reader's own path resolution, which compares normalized path text and never resolves a symlink.
How AI contributed: Flawed AI-written codePublished Aug 22, 2026 in
nltk/nltk
FrameNet path handling was reworked in one commit that unifies the per-method checks into a shared helper and routes the three XML reads through the corpus reader's own path resolution. The helper rejects separators, parent references and drive prefixes in the interpolated name, and the commit claims the change gains symlink and containment defenses, yet resolve_path remains a lexical join and the opens pass no required root, so the containment claim does not hold for a symlink. The repair adds a validation step that resolves both the constructed path and the corpus root and checks containment before any read, so a symlink whose name is ordinary is rejected with an error instead of being followed.
The name guards reject separators, parent references and Windows drive prefixes only in the string the caller supplies, and they never resolve the path that is finally opened. The reader builds the XML path through a lexical corpus-relative join, which compares normalized text prefixes and therefore accepts a symlink, and the file is then opened without a required root, so the corpus boundary is not enforced. A symlink planted inside the corpus's own frame, lu or fulltext subdirectory has an ordinary name and passes every check, so frame_by_name(), _lu_file() or doc() follows it and returns the parsed text of an XML file outside the corpus root to the caller.
Root cause
The rework unified the path guards into one helper and sent the three XML reads through the corpus reader's own path resolution, which compares normalized path text and never resolves a symlink.
Fix
A new validation step resolves the constructed path and the corpus root and checks containment before the file is opened, so a symlink that escapes the corpus root raises an error.
Case facts
@@ -756,6 +757,28 @@ class FramenetError(Exception): """An exception class for framenet-related errors.""" +def _reject_unsafe_path_component(value, kind):+ """Reject a corpus-/caller-supplied name that could escape the corpus root.++ ``doc()``, ``frame_by_name()`` and ``_lu_file()`` interpolate a name into an+ XML file path (CWE-22). Besides POSIX/Windows separators and ``..``, this+ also rejects a Windows drive- or UNC-qualified name such as ``C:evil`` or+ ``\\\\host\\share``: it contains no separator, yet ``os.path.join`` discards+ the corpus root when the name carries a drive on Windows.+ ``ntpath.splitdrive`` is used rather than ``os.path.splitdrive`` so the+ check applies on every platform (and stays testable off Windows).+ """+ value = str(value)+ if (+ os.sep in value+ or "/" in value+ or "\\" in value+ or ".." in value+ or ntpath.splitdrive(value)[0]+ ):+ raise FramenetError(f"Invalid {kind}: {value!r}")++ class AttrDict(dict): """A class that wraps a dict and allows accessing the keys of the dict as if they were attributes. Taken from here:@@ -1482,16 +1508,22 @@ warnings(True) to display corpus consistency warnings when loading data elif not self._frame_idx: self._buildframeindex() - # Security (CWE-22): the frame name is interpolated into the XML file- # path. Reject crafted names, then resolve through self.abspath() so the- # file is read via the PathPointer / nltk.pathsec sandbox rather than the- # builtin open() that a bare string path would use in XMLCorpusView.+ # Security (CWE-22 / CWE-59): the frame name is interpolated into the+ # XML file path. Reject crafted names, then resolve through+ # self.abspath() and validate_path() with the corpus root as+ # required_root -- the same symlink-resolving containment guard+ # CorpusReader.open() and NKJPCorpusReader use -- so the file is read+ # via the PathPointer / nltk.pathsec sandbox rather than the builtin+ # open() that a bare string path would use in XMLCorpusView, and a+ # symlink planted inside the frame directory cannot escape the corpus+ # root even though its own name contains no separators or "..". _reject_unsafe_path_component(fn_fname, "frame name") # construct the path name for the xml file containing the Frame info # Grab the xml for the frame try: locpath = self.abspath(os.path.join(self._frame_dir, fn_fname + ".xml"))+ _validate_in_root(locpath, self.root, "FramenetCorpusReader") with XMLCorpusView(locpath, "frame") as view: elt = view[0] except OSError as e:AI-assisted change 69ba76c1f111e7b1cddae8d05bf8b7f18e10d35bb3ae05e570c42c06ac039ac8 · Fix f559b00535d3747641bac0240db21ba264dfa4b84c4f52c8587d920991be80c3