Skip to content

Commit 2bae5be

Browse files
authored
Merge pull request #1342 from PyThaiNLP/copilot/fix-security-path-manipulation
fix: prevent path traversal (CWE-22) in corpus and data path functions
2 parents fcb9210 + 54d11e6 commit 2bae5be

8 files changed

Lines changed: 116 additions & 47 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<https://github.com/PyThaiNLP/pythainlp/blob/dev/tests/README.md>.
77
The document list test categories, their dependency sets,
88
and test naming conventions.
9-
- [ ] Use reStructuredText for docstring (PEP 287), targetting Sphinx.
9+
- [ ] Use reStructuredText for docstring (PEP 287), targeting Sphinx.
1010
- [ ] When possible, follow NLTK established convention of submodule
1111
name (tend to be a verb or a generic noun), function name, and
1212
configuration. Communicate this to the users during code review.
@@ -20,7 +20,7 @@
2020
the repo. Read its usage and information it generates at
2121
<https://github.com/PyThaiNLP/pythainlp/blob/dev/build_tools/analysis/README.md>.
2222
Mind that the analyzer can create false positives,
23-
please refer to Python tyep specification when in doubt.
23+
please refer to Python type specification when in doubt.
2424
- [ ] Complete type annotations for function, method, class, variable, etc.
2525
Maintain near-100% type annotation coverage.
2626
- [ ] Add tests for new functionality or behavior.
@@ -31,6 +31,9 @@
3131
- [ ] Major changes should be logged in the change log at
3232
<https://github.com/PyThaiNLP/pythainlp/blob/dev/CHANGELOG.md>.
3333
Provide issue number or PR number if available.
34+
- [ ] Do not use os.path.join();
35+
always use pythainlp.tools.safe_path_join() instead,
36+
to prevent path traversal vulnerabilities (CWE-22).
3437

3538
## Project contribution guidelines
3639

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,16 @@ and this project adheres to
1515
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1616

1717
- Full release notes: <https://github.com/PyThaiNLP/pythainlp/releases>
18-
- Commit history: <https://github.com/PyThaiNLP/pythainlp/compare/v5.3.0...v5.3.1>
18+
- Commit history: <https://github.com/PyThaiNLP/pythainlp/compare/v5.3.1...v5.3.2>
19+
20+
## [5.3.2] - 2026-03-20
21+
22+
This release focuses on security issues related to path traversal.
23+
24+
### Security
25+
26+
- Prevent path traversal: validate that paths stay within
27+
their expected base directory (#1342)
1928

2029
## [5.3.1] - 2026-03-14
2130

@@ -212,6 +221,7 @@ The minimum requirement is now Python 3.9.
212221

213222
- See <https://github.com/PyThaiNLP/pythainlp/releases/tag/v5.0.0>
214223

224+
[5.3.2]: https://github.com/PyThaiNLP/pythainlp/compare/v5.3.1...v5.3.2
215225
[5.3.1]: https://github.com/PyThaiNLP/pythainlp/compare/v5.3.0...v5.3.1
216226
[5.3.0]: https://github.com/PyThaiNLP/pythainlp/compare/v5.2.0...v5.3.0
217227
[5.2.0]: https://github.com/PyThaiNLP/pythainlp/compare/v5.1.2...v5.2.0

pythainlp/corpus/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@
2222
"get_corpus_db_detail",
2323
"get_corpus_default_db",
2424
"get_corpus_path",
25-
"get_path_folder_corpus",
2625
"get_hf_hub",
27-
"path_pythainlp_corpus",
2826
"provinces",
2927
"remove",
3028
"thai_dict",
@@ -89,9 +87,7 @@ def corpus_db_path() -> str:
8987
get_corpus_default_db,
9088
get_corpus_path,
9189
get_hf_hub,
92-
get_path_folder_corpus,
9390
make_safe_directory_name,
94-
path_pythainlp_corpus,
9591
remove,
9692
)
9793
from pythainlp.corpus.common import (

pythainlp/corpus/core.py

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
from pythainlp import __version__
1919
from pythainlp.corpus import corpus_db_path, corpus_db_url, corpus_path
2020
from pythainlp.tools import get_full_data_path
21-
from pythainlp.tools.path import is_offline_mode, is_read_only_mode
21+
from pythainlp.tools.path import (
22+
is_offline_mode,
23+
is_read_only_mode,
24+
safe_path_join,
25+
)
2226

2327
if TYPE_CHECKING:
2428
from http.client import HTTPMessage, HTTPResponse
@@ -103,16 +107,6 @@ def get_corpus_db_detail(name: str, version: str = "") -> dict[str, Any]:
103107
return {}
104108

105109

106-
def path_pythainlp_corpus(filename: str) -> str:
107-
"""Get path pythainlp.corpus data
108-
109-
:param str filename: filename of the corpus to be read
110-
111-
:return: : path of corpus
112-
:rtype: str
113-
"""
114-
return os.path.join(corpus_path(), filename)
115-
116110

117111
@lru_cache(maxsize=None)
118112
def get_corpus(filename: str, comments: bool = True) -> frozenset[str]:
@@ -245,13 +239,15 @@ def get_corpus_default_db(name: str, version: str = "") -> Optional[str]:
245239

246240
if name in corpus_db:
247241
if version in corpus_db[name]["versions"]:
248-
return path_pythainlp_corpus(
249-
corpus_db[name]["versions"][version]["filename"]
242+
return safe_path_join(
243+
corpus_path(),
244+
corpus_db[name]["versions"][version]["filename"],
250245
)
251246
elif not version: # load latest version
252247
version = corpus_db[name]["latest_version"]
253-
return path_pythainlp_corpus(
254-
corpus_db[name]["versions"][version]["filename"]
248+
return safe_path_join(
249+
corpus_path(),
250+
corpus_db[name]["versions"][version]["filename"],
255251
)
256252

257253
return None
@@ -852,17 +848,6 @@ def remove(name: str) -> bool:
852848
return False
853849

854850

855-
def get_path_folder_corpus(name: str, version: str, *path: str) -> str:
856-
corpus_path = get_corpus_path(name, version)
857-
if not corpus_path:
858-
raise FileNotFoundError(
859-
f"corpus-not-found name={name!r} version={version!r}\n"
860-
f" Corpus '{name}' (version {version}) not found.\n"
861-
f" Python: pythainlp.corpus.download('{name}')\n"
862-
f" CLI: thainlp data get {name}"
863-
)
864-
return os.path.join(corpus_path, *path)
865-
866851

867852
def make_safe_directory_name(name: str) -> str:
868853
"""Make safe directory name

pythainlp/tag/wangchanberta_onnx.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
import sentencepiece as spm
1212
from onnxruntime import InferenceSession, SessionOptions
1313

14-
from pythainlp.corpus import get_path_folder_corpus
14+
from pythainlp.corpus import get_corpus_path
15+
from pythainlp.tools import safe_path_join
1516

1617

1718
class WngchanBerta_ONNX:
@@ -47,24 +48,21 @@ def __init__(
4748
self.options.graph_optimization_level = (
4849
GraphOptimizationLevel.ORT_ENABLE_ALL
4950
)
51+
_corpus_base = get_corpus_path(self.model_name, self.model_version)
52+
if not _corpus_base:
53+
raise FileNotFoundError(self.model_name)
5054
self.session = InferenceSession(
51-
get_path_folder_corpus(
52-
self.model_name, self.model_version, file_onnx
53-
),
55+
safe_path_join(_corpus_base, file_onnx),
5456
sess_options=self.options,
5557
providers=providers,
5658
)
5759
self.session.disable_fallback()
5860
self.outputs_name = self.session.get_outputs()[0].name
5961
self.sp = spm.SentencePieceProcessor(
60-
model_file=get_path_folder_corpus(
61-
self.model_name, self.model_version, "sentencepiece.bpe.model"
62-
)
62+
model_file=safe_path_join(_corpus_base, "sentencepiece.bpe.model")
6363
)
6464
with open(
65-
get_path_folder_corpus(
66-
self.model_name, self.model_version, "config.json"
67-
),
65+
safe_path_join(_corpus_base, "config.json"),
6866
encoding="utf-8-sig",
6967
) as fh:
7068
self._json = json.load(fh)

pythainlp/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"is_offline_mode",
1010
"is_read_only_mode",
1111
"is_unsafe_pickle_allowed",
12+
"safe_path_join",
1213
"safe_print",
1314
"warn_deprecation",
1415
]
@@ -22,4 +23,5 @@
2223
is_offline_mode,
2324
is_read_only_mode,
2425
is_unsafe_pickle_allowed,
26+
safe_path_join,
2527
)

pythainlp/tools/path.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,44 @@ def is_offline_mode() -> bool:
159159
return val.strip().lower() not in ("", "0", "false", "no", "off")
160160

161161

162+
def safe_path_join(base: str, *parts: str) -> str:
163+
"""Join *base* with *parts*, verify containment, and return the normalized path.
164+
165+
This is the authoritative path-traversal guard used throughout the library
166+
wherever a base directory and external path components are combined
167+
(e.g., :func:`get_full_data_path` and the internal corpus path helpers
168+
in :mod:`pythainlp.corpus.core`).
169+
170+
:param str base: base directory that the result must reside within.
171+
:param parts: additional path components to append.
172+
:type parts: str
173+
174+
:return: normalized absolute path of the joined result.
175+
:rtype: str
176+
177+
:raises ValueError: if the resolved path escapes *base*.
178+
"""
179+
abs_base = os.path.abspath(base)
180+
abs_full = os.path.abspath(os.path.join(abs_base, *parts))
181+
if abs_full != abs_base and not abs_full.startswith(abs_base + os.sep):
182+
raise ValueError(
183+
f"Path traversal attempt detected: resolved path {abs_full!r} "
184+
f"is outside the base directory {abs_base!r}."
185+
)
186+
return abs_full
187+
188+
162189
def get_full_data_path(path: str) -> str:
163-
"""This function joins path of :mod:`pythainlp` data directory and the
164-
given path, and returns the full path.
190+
"""Join the PyThaiNLP data directory path with *path* and return the result.
191+
192+
:param str path: relative path or filename to append to the data directory.
165193
166-
:return: full path given the name of dataset
194+
:return: normalized absolute path within the PyThaiNLP data directory.
167195
:rtype: str
168196
197+
:raises ValueError: if *path* resolves to a location outside the
198+
PyThaiNLP data directory (path traversal attempt).
199+
169200
:Example:
170201
::
171202
@@ -174,7 +205,7 @@ def get_full_data_path(path: str) -> str:
174205
get_full_data_path("ttc_freq.txt")
175206
# output: '/root/pythainlp-data/ttc_freq.txt'
176207
"""
177-
return os.path.join(get_pythainlp_data_path(), path)
208+
return safe_path_join(get_pythainlp_data_path(), path)
178209

179210

180211
def get_pythainlp_data_path() -> str:

tests/core/test_security.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111
import unittest
1212
import zipfile
1313

14+
from pythainlp.corpus import corpus_path
1415
from pythainlp.corpus.core import (
1516
_is_within_directory,
1617
_safe_extract_tar,
1718
_safe_extract_zip,
1819
)
20+
from pythainlp.tools import get_full_data_path, safe_path_join
21+
from pythainlp.tools.path import get_pythainlp_data_path
1922

2023

2124
class SecurityTestCase(unittest.TestCase):
@@ -186,6 +189,47 @@ def test_is_within_directory_with_symlinks(self):
186189
# in the _safe_extract_tar and _safe_extract_zip functions,
187190
# which check where symlinks actually point to.
188191

192+
def test_get_full_data_path_safe(self):
193+
"""Test that get_full_data_path returns a path within the data directory."""
194+
result = get_full_data_path("ttc_freq.txt")
195+
self.assertTrue(_is_within_directory(get_pythainlp_data_path(), result))
196+
197+
def test_get_full_data_path_rejects_traversal(self):
198+
"""Test that get_full_data_path rejects path traversal attempts."""
199+
with self.assertRaises(ValueError) as ctx:
200+
get_full_data_path("../../etc/passwd")
201+
self.assertIn("path traversal", str(ctx.exception).lower())
202+
203+
def test_get_full_data_path_rejects_multiple_traversal(self):
204+
"""Test that get_full_data_path rejects multiple parent directory traversal."""
205+
with self.assertRaises(ValueError) as ctx:
206+
get_full_data_path("../../../root/.ssh/id_rsa")
207+
self.assertIn("path traversal", str(ctx.exception).lower())
208+
209+
def test_safe_path_join_bundled_corpus_safe(self):
210+
"""Test safe_path_join with corpus_path() base accepts safe filenames."""
211+
result = safe_path_join(corpus_path(), "negations_th.txt")
212+
self.assertTrue(_is_within_directory(corpus_path(), result))
213+
214+
def test_safe_path_join_bundled_corpus_rejects_traversal(self):
215+
"""Test safe_path_join with corpus_path() base rejects traversal."""
216+
with self.assertRaises(ValueError) as ctx:
217+
safe_path_join(corpus_path(), "../../etc/passwd")
218+
self.assertIn("path traversal", str(ctx.exception).lower())
219+
220+
def test_safe_path_join_with_tmpdir_safe(self):
221+
"""Test safe_path_join accepts safe sub-paths within a temp directory."""
222+
with tempfile.TemporaryDirectory() as tmpdir:
223+
result = safe_path_join(tmpdir, "model.txt")
224+
self.assertTrue(_is_within_directory(tmpdir, result))
225+
226+
def test_safe_path_join_with_tmpdir_rejects_traversal(self):
227+
"""Test safe_path_join rejects traversal escape from a temp directory."""
228+
with tempfile.TemporaryDirectory() as tmpdir:
229+
with self.assertRaises(ValueError) as ctx:
230+
safe_path_join(tmpdir, "../../etc/passwd")
231+
self.assertIn("path traversal", str(ctx.exception).lower())
232+
189233

190234
if __name__ == "__main__":
191235
unittest.main()

0 commit comments

Comments
 (0)