From 3aca45d63b440734b13a80d3bb5594d3365dff98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:42:09 +0000 Subject: [PATCH 01/15] Initial plan From 7795c2bbfe39933d068f4f18b0c325b2ce05d867 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:53:39 +0000 Subject: [PATCH 02/15] fix: prevent path traversal in get_full_data_path, path_pythainlp_corpus, and get_path_folder_corpus Co-authored-by: wannaphong <8536487+wannaphong@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ pythainlp/corpus/core.py | 48 ++++++++++++++++++++++++++++++++----- pythainlp/tools/path.py | 19 ++++++++++++--- tests/core/test_security.py | 31 ++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a351715..5c88643fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ This release focuses on security issues related to corpus file loading. - thai2fit: Use JSON model instead of pickle (#1325) - Defensive corpus loading: validate fields before processing (#1327) - w2p: Use npz model instead of pickle (#1328) +- Fix path traversal vulnerabilities: validate that paths constructed from + external input stay within their expected base directory; + `get_full_data_path()`, `path_pythainlp_corpus()`, and + `get_path_folder_corpus()` now raise `ValueError` on traversal attempts ## [5.3.0] - 2026-03-10 diff --git a/pythainlp/corpus/core.py b/pythainlp/corpus/core.py index e26ddf640..b933c622a 100644 --- a/pythainlp/corpus/core.py +++ b/pythainlp/corpus/core.py @@ -104,14 +104,26 @@ def get_corpus_db_detail(name: str, version: str = "") -> dict[str, Any]: def path_pythainlp_corpus(filename: str) -> str: - """Get path pythainlp.corpus data + """Get path to a file in the bundled :mod:`pythainlp.corpus` data. :param str filename: filename of the corpus to be read - :return: : path of corpus + :return: path of corpus :rtype: str + + :raises ValueError: if *filename* resolves to a location outside the + bundled corpus directory (path traversal attempt). """ - return os.path.join(corpus_path(), filename) + base = corpus_path() + full_path = os.path.join(base, filename) + abs_base = os.path.abspath(base) + abs_full = os.path.abspath(full_path) + if abs_full != abs_base and not abs_full.startswith(abs_base + os.sep): + raise ValueError( + f"Path traversal attempt detected: {filename!r} resolves outside " + "the bundled corpus directory." + ) + return full_path @lru_cache(maxsize=None) @@ -853,15 +865,39 @@ def remove(name: str) -> bool: def get_path_folder_corpus(name: str, version: str, *path: str) -> str: - corpus_path = get_corpus_path(name, version) - if not corpus_path: + """Get the path to a file or sub-directory inside a downloaded corpus folder. + + :param str name: corpus name + :param str version: corpus version + :param path: additional path components appended to the corpus folder + :type path: str + + :return: full path to the requested resource inside the corpus folder + :rtype: str + + :raises FileNotFoundError: if the corpus is not found locally. + :raises ValueError: if the resolved path escapes the corpus folder + (path traversal attempt). + """ + base_path = get_corpus_path(name, version) + if not base_path: raise FileNotFoundError( f"corpus-not-found name={name!r} version={version!r}\n" f" Corpus '{name}' (version {version}) not found.\n" f" Python: pythainlp.corpus.download('{name}')\n" f" CLI: thainlp data get {name}" ) - return os.path.join(corpus_path, *path) + full_path = os.path.join(base_path, *path) + # Validate only when extra path components are provided; + # base_path alone is already trusted (returned by get_corpus_path). + if path: + abs_base = os.path.abspath(base_path) + abs_full = os.path.abspath(full_path) + if abs_full != abs_base and not abs_full.startswith(abs_base + os.sep): + raise ValueError( + f"Path traversal attempt detected in path components: {path!r}" + ) + return full_path def make_safe_directory_name(name: str) -> str: diff --git a/pythainlp/tools/path.py b/pythainlp/tools/path.py index 2bd954c7d..5b7eee92c 100644 --- a/pythainlp/tools/path.py +++ b/pythainlp/tools/path.py @@ -160,12 +160,16 @@ def is_offline_mode() -> bool: def get_full_data_path(path: str) -> str: - """This function joins path of :mod:`pythainlp` data directory and the - given path, and returns the full path. + """Join the PyThaiNLP data directory path with *path* and return the result. + + :param str path: relative path or filename to append to the data directory. :return: full path given the name of dataset :rtype: str + :raises ValueError: if *path* resolves to a location outside the + PyThaiNLP data directory (path traversal attempt). + :Example: :: @@ -174,7 +178,16 @@ def get_full_data_path(path: str) -> str: get_full_data_path("ttc_freq.txt") # output: '/root/pythainlp-data/ttc_freq.txt' """ - return os.path.join(get_pythainlp_data_path(), path) + base = get_pythainlp_data_path() + full_path = os.path.join(base, path) + abs_base = os.path.abspath(base) + abs_full = os.path.abspath(full_path) + if abs_full != abs_base and not abs_full.startswith(abs_base + os.sep): + raise ValueError( + f"Path traversal attempt detected: {path!r} resolves outside " + "the PyThaiNLP data directory." + ) + return full_path def get_pythainlp_data_path() -> str: diff --git a/tests/core/test_security.py b/tests/core/test_security.py index aed704858..7d5c6a85a 100644 --- a/tests/core/test_security.py +++ b/tests/core/test_security.py @@ -15,7 +15,9 @@ _is_within_directory, _safe_extract_tar, _safe_extract_zip, + path_pythainlp_corpus, ) +from pythainlp.tools import get_full_data_path class SecurityTestCase(unittest.TestCase): @@ -187,5 +189,34 @@ def test_is_within_directory_with_symlinks(self): # which check where symlinks actually point to. + def test_get_full_data_path_safe(self): + """Test that get_full_data_path accepts safe filenames.""" + result = get_full_data_path("ttc_freq.txt") + self.assertTrue(result.endswith("ttc_freq.txt")) + + def test_get_full_data_path_rejects_traversal(self): + """Test that get_full_data_path rejects path traversal attempts.""" + with self.assertRaises(ValueError) as ctx: + get_full_data_path("../../etc/passwd") + self.assertIn("path traversal", str(ctx.exception).lower()) + + def test_get_full_data_path_rejects_multiple_traversal(self): + """Test that get_full_data_path rejects multiple parent directory traversal.""" + with self.assertRaises(ValueError) as ctx: + get_full_data_path("../../../root/.ssh/id_rsa") + self.assertIn("path traversal", str(ctx.exception).lower()) + + def test_path_pythainlp_corpus_safe(self): + """Test that path_pythainlp_corpus accepts safe filenames.""" + result = path_pythainlp_corpus("negations_th.txt") + self.assertTrue(result.endswith("negations_th.txt")) + + def test_path_pythainlp_corpus_rejects_traversal(self): + """Test that path_pythainlp_corpus rejects path traversal attempts.""" + with self.assertRaises(ValueError) as ctx: + path_pythainlp_corpus("../../etc/passwd") + self.assertIn("path traversal", str(ctx.exception).lower()) + + if __name__ == "__main__": unittest.main() From 503f2c212cb6fe1acb043512aa125989ac4bac76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:00:35 +0000 Subject: [PATCH 03/15] refactor: consolidate path traversal check into _safe_path_join; fix tests Co-authored-by: bact <128572+bact@users.noreply.github.com> --- pythainlp/corpus/core.py | 34 ++++++++++---------------------- pythainlp/tools/path.py | 39 ++++++++++++++++++++++++++----------- tests/core/test_security.py | 33 +++++++++++++++++++++++++++---- 3 files changed, 67 insertions(+), 39 deletions(-) diff --git a/pythainlp/corpus/core.py b/pythainlp/corpus/core.py index b933c622a..84a17e192 100644 --- a/pythainlp/corpus/core.py +++ b/pythainlp/corpus/core.py @@ -18,7 +18,11 @@ from pythainlp import __version__ from pythainlp.corpus import corpus_db_path, corpus_db_url, corpus_path from pythainlp.tools import get_full_data_path -from pythainlp.tools.path import is_offline_mode, is_read_only_mode +from pythainlp.tools.path import ( + _safe_path_join, + is_offline_mode, + is_read_only_mode, +) if TYPE_CHECKING: from http.client import HTTPMessage, HTTPResponse @@ -108,22 +112,13 @@ def path_pythainlp_corpus(filename: str) -> str: :param str filename: filename of the corpus to be read - :return: path of corpus + :return: normalized absolute path of the corpus file. :rtype: str :raises ValueError: if *filename* resolves to a location outside the bundled corpus directory (path traversal attempt). """ - base = corpus_path() - full_path = os.path.join(base, filename) - abs_base = os.path.abspath(base) - abs_full = os.path.abspath(full_path) - if abs_full != abs_base and not abs_full.startswith(abs_base + os.sep): - raise ValueError( - f"Path traversal attempt detected: {filename!r} resolves outside " - "the bundled corpus directory." - ) - return full_path + return _safe_path_join(corpus_path(), filename) @lru_cache(maxsize=None) @@ -872,7 +867,8 @@ def get_path_folder_corpus(name: str, version: str, *path: str) -> str: :param path: additional path components appended to the corpus folder :type path: str - :return: full path to the requested resource inside the corpus folder + :return: normalized absolute path to the requested resource inside the + corpus folder. :rtype: str :raises FileNotFoundError: if the corpus is not found locally. @@ -887,17 +883,7 @@ def get_path_folder_corpus(name: str, version: str, *path: str) -> str: f" Python: pythainlp.corpus.download('{name}')\n" f" CLI: thainlp data get {name}" ) - full_path = os.path.join(base_path, *path) - # Validate only when extra path components are provided; - # base_path alone is already trusted (returned by get_corpus_path). - if path: - abs_base = os.path.abspath(base_path) - abs_full = os.path.abspath(full_path) - if abs_full != abs_base and not abs_full.startswith(abs_base + os.sep): - raise ValueError( - f"Path traversal attempt detected in path components: {path!r}" - ) - return full_path + return _safe_path_join(base_path, *path) def make_safe_directory_name(name: str) -> str: diff --git a/pythainlp/tools/path.py b/pythainlp/tools/path.py index 5b7eee92c..d668dd09a 100644 --- a/pythainlp/tools/path.py +++ b/pythainlp/tools/path.py @@ -159,12 +159,38 @@ def is_offline_mode() -> bool: return val.strip().lower() not in ("", "0", "false", "no", "off") +def _safe_path_join(base: str, *parts: str) -> str: + """Join *base* with *parts*, verify containment, and return the normalized path. + + This is the single authoritative path-traversal guard used by + :func:`get_full_data_path`, :func:`pythainlp.corpus.core.path_pythainlp_corpus`, + and :func:`pythainlp.corpus.core.get_path_folder_corpus`. + + :param str base: base directory that the result must reside within. + :param parts: additional path components to append. + :type parts: str + + :return: normalized absolute path of the joined result. + :rtype: str + + :raises ValueError: if the resolved path escapes *base*. + """ + abs_base = os.path.abspath(base) + abs_full = os.path.abspath(os.path.join(abs_base, *parts)) + if abs_full != abs_base and not abs_full.startswith(abs_base + os.sep): + raise ValueError( + f"Path traversal attempt detected: resolved path {abs_full!r} " + f"is outside the base directory {abs_base!r}." + ) + return abs_full + + def get_full_data_path(path: str) -> str: """Join the PyThaiNLP data directory path with *path* and return the result. :param str path: relative path or filename to append to the data directory. - :return: full path given the name of dataset + :return: normalized absolute path within the PyThaiNLP data directory. :rtype: str :raises ValueError: if *path* resolves to a location outside the @@ -178,16 +204,7 @@ def get_full_data_path(path: str) -> str: get_full_data_path("ttc_freq.txt") # output: '/root/pythainlp-data/ttc_freq.txt' """ - base = get_pythainlp_data_path() - full_path = os.path.join(base, path) - abs_base = os.path.abspath(base) - abs_full = os.path.abspath(full_path) - if abs_full != abs_base and not abs_full.startswith(abs_base + os.sep): - raise ValueError( - f"Path traversal attempt detected: {path!r} resolves outside " - "the PyThaiNLP data directory." - ) - return full_path + return _safe_path_join(get_pythainlp_data_path(), path) def get_pythainlp_data_path() -> str: diff --git a/tests/core/test_security.py b/tests/core/test_security.py index 7d5c6a85a..d4fcd7bf0 100644 --- a/tests/core/test_security.py +++ b/tests/core/test_security.py @@ -9,15 +9,19 @@ import tarfile import tempfile import unittest +import unittest.mock import zipfile +from pythainlp.corpus import corpus_path from pythainlp.corpus.core import ( _is_within_directory, _safe_extract_tar, _safe_extract_zip, + get_path_folder_corpus, path_pythainlp_corpus, ) from pythainlp.tools import get_full_data_path +from pythainlp.tools.path import get_pythainlp_data_path class SecurityTestCase(unittest.TestCase): @@ -190,9 +194,9 @@ def test_is_within_directory_with_symlinks(self): def test_get_full_data_path_safe(self): - """Test that get_full_data_path accepts safe filenames.""" + """Test that get_full_data_path returns a path within the data directory.""" result = get_full_data_path("ttc_freq.txt") - self.assertTrue(result.endswith("ttc_freq.txt")) + self.assertTrue(_is_within_directory(get_pythainlp_data_path(), result)) def test_get_full_data_path_rejects_traversal(self): """Test that get_full_data_path rejects path traversal attempts.""" @@ -207,9 +211,9 @@ def test_get_full_data_path_rejects_multiple_traversal(self): self.assertIn("path traversal", str(ctx.exception).lower()) def test_path_pythainlp_corpus_safe(self): - """Test that path_pythainlp_corpus accepts safe filenames.""" + """Test that path_pythainlp_corpus returns a path within the corpus directory.""" result = path_pythainlp_corpus("negations_th.txt") - self.assertTrue(result.endswith("negations_th.txt")) + self.assertTrue(_is_within_directory(corpus_path(), result)) def test_path_pythainlp_corpus_rejects_traversal(self): """Test that path_pythainlp_corpus rejects path traversal attempts.""" @@ -217,6 +221,27 @@ def test_path_pythainlp_corpus_rejects_traversal(self): path_pythainlp_corpus("../../etc/passwd") self.assertIn("path traversal", str(ctx.exception).lower()) + def test_get_path_folder_corpus_safe(self): + """Test that get_path_folder_corpus returns a path within the corpus folder.""" + with tempfile.TemporaryDirectory() as tmpdir: + with unittest.mock.patch( + "pythainlp.corpus.core.get_corpus_path", + return_value=tmpdir, + ): + result = get_path_folder_corpus("testcorpus", "1.0", "model.txt") + self.assertTrue(_is_within_directory(tmpdir, result)) + + def test_get_path_folder_corpus_rejects_traversal(self): + """Test that get_path_folder_corpus rejects traversal in extra path components.""" + with tempfile.TemporaryDirectory() as tmpdir: + with unittest.mock.patch( + "pythainlp.corpus.core.get_corpus_path", + return_value=tmpdir, + ): + with self.assertRaises(ValueError) as ctx: + get_path_folder_corpus("testcorpus", "1.0", "../../etc/passwd") + self.assertIn("path traversal", str(ctx.exception).lower()) + if __name__ == "__main__": unittest.main() From c08e19926cff467dc8142bc7ef6b881e904f735d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:29:02 +0000 Subject: [PATCH 04/15] refactor: remove path_pythainlp_corpus and get_path_folder_corpus; use _safe_path_join inline Co-authored-by: bact <128572+bact@users.noreply.github.com> --- pythainlp/corpus/__init__.py | 4 --- pythainlp/corpus/core.py | 49 ++++------------------------- pythainlp/tag/wangchanberta_onnx.py | 25 +++++++++------ pythainlp/tools/path.py | 7 +++-- tests/core/test_security.py | 45 ++++++++++---------------- 5 files changed, 43 insertions(+), 87 deletions(-) diff --git a/pythainlp/corpus/__init__.py b/pythainlp/corpus/__init__.py index d5b48250e..70a60ef00 100644 --- a/pythainlp/corpus/__init__.py +++ b/pythainlp/corpus/__init__.py @@ -22,9 +22,7 @@ "get_corpus_db_detail", "get_corpus_default_db", "get_corpus_path", - "get_path_folder_corpus", "get_hf_hub", - "path_pythainlp_corpus", "provinces", "remove", "thai_dict", @@ -89,9 +87,7 @@ def corpus_db_path() -> str: get_corpus_default_db, get_corpus_path, get_hf_hub, - get_path_folder_corpus, make_safe_directory_name, - path_pythainlp_corpus, remove, ) from pythainlp.corpus.common import ( diff --git a/pythainlp/corpus/core.py b/pythainlp/corpus/core.py index 84a17e192..3c1c44978 100644 --- a/pythainlp/corpus/core.py +++ b/pythainlp/corpus/core.py @@ -107,19 +107,6 @@ def get_corpus_db_detail(name: str, version: str = "") -> dict[str, Any]: return {} -def path_pythainlp_corpus(filename: str) -> str: - """Get path to a file in the bundled :mod:`pythainlp.corpus` data. - - :param str filename: filename of the corpus to be read - - :return: normalized absolute path of the corpus file. - :rtype: str - - :raises ValueError: if *filename* resolves to a location outside the - bundled corpus directory (path traversal attempt). - """ - return _safe_path_join(corpus_path(), filename) - @lru_cache(maxsize=None) def get_corpus(filename: str, comments: bool = True) -> frozenset[str]: @@ -252,13 +239,15 @@ def get_corpus_default_db(name: str, version: str = "") -> Optional[str]: if name in corpus_db: if version in corpus_db[name]["versions"]: - return path_pythainlp_corpus( - corpus_db[name]["versions"][version]["filename"] + return _safe_path_join( + corpus_path(), + corpus_db[name]["versions"][version]["filename"], ) elif not version: # load latest version version = corpus_db[name]["latest_version"] - return path_pythainlp_corpus( - corpus_db[name]["versions"][version]["filename"] + return _safe_path_join( + corpus_path(), + corpus_db[name]["versions"][version]["filename"], ) return None @@ -859,32 +848,6 @@ def remove(name: str) -> bool: return False -def get_path_folder_corpus(name: str, version: str, *path: str) -> str: - """Get the path to a file or sub-directory inside a downloaded corpus folder. - - :param str name: corpus name - :param str version: corpus version - :param path: additional path components appended to the corpus folder - :type path: str - - :return: normalized absolute path to the requested resource inside the - corpus folder. - :rtype: str - - :raises FileNotFoundError: if the corpus is not found locally. - :raises ValueError: if the resolved path escapes the corpus folder - (path traversal attempt). - """ - base_path = get_corpus_path(name, version) - if not base_path: - raise FileNotFoundError( - f"corpus-not-found name={name!r} version={version!r}\n" - f" Corpus '{name}' (version {version}) not found.\n" - f" Python: pythainlp.corpus.download('{name}')\n" - f" CLI: thainlp data get {name}" - ) - return _safe_path_join(base_path, *path) - def make_safe_directory_name(name: str) -> str: """Make safe directory name diff --git a/pythainlp/tag/wangchanberta_onnx.py b/pythainlp/tag/wangchanberta_onnx.py index 4b76f2d49..1472d8fe3 100644 --- a/pythainlp/tag/wangchanberta_onnx.py +++ b/pythainlp/tag/wangchanberta_onnx.py @@ -11,7 +11,8 @@ import sentencepiece as spm from onnxruntime import InferenceSession, SessionOptions -from pythainlp.corpus import get_path_folder_corpus +from pythainlp.corpus import get_corpus_path +from pythainlp.tools.path import _safe_path_join class WngchanBerta_ONNX: @@ -47,24 +48,30 @@ def __init__( self.options.graph_optimization_level = ( GraphOptimizationLevel.ORT_ENABLE_ALL ) + _corpus_base = get_corpus_path(self.model_name, self.model_version) + if not _corpus_base: + raise FileNotFoundError( + f"corpus-not-found name={self.model_name!r} " + f"version={self.model_version!r}\n" + f" Corpus '{self.model_name}' " + f"(version {self.model_version}) not found.\n" + f" Python: pythainlp.corpus.download('{self.model_name}')\n" + f" CLI: thainlp data get {self.model_name}" + ) self.session = InferenceSession( - get_path_folder_corpus( - self.model_name, self.model_version, file_onnx - ), + _safe_path_join(_corpus_base, file_onnx), sess_options=self.options, providers=providers, ) self.session.disable_fallback() self.outputs_name = self.session.get_outputs()[0].name self.sp = spm.SentencePieceProcessor( - model_file=get_path_folder_corpus( - self.model_name, self.model_version, "sentencepiece.bpe.model" + model_file=_safe_path_join( + _corpus_base, "sentencepiece.bpe.model" ) ) with open( - get_path_folder_corpus( - self.model_name, self.model_version, "config.json" - ), + _safe_path_join(_corpus_base, "config.json"), encoding="utf-8-sig", ) as fh: self._json = json.load(fh) diff --git a/pythainlp/tools/path.py b/pythainlp/tools/path.py index d668dd09a..375ab6dab 100644 --- a/pythainlp/tools/path.py +++ b/pythainlp/tools/path.py @@ -162,9 +162,10 @@ def is_offline_mode() -> bool: def _safe_path_join(base: str, *parts: str) -> str: """Join *base* with *parts*, verify containment, and return the normalized path. - This is the single authoritative path-traversal guard used by - :func:`get_full_data_path`, :func:`pythainlp.corpus.core.path_pythainlp_corpus`, - and :func:`pythainlp.corpus.core.get_path_folder_corpus`. + This is the single authoritative path-traversal guard used throughout + the library wherever a base directory and external path components are + combined (e.g., :func:`get_full_data_path` and the internal corpus + path helpers in :mod:`pythainlp.corpus.core`). :param str base: base directory that the result must reside within. :param parts: additional path components to append. diff --git a/tests/core/test_security.py b/tests/core/test_security.py index d4fcd7bf0..391fd793e 100644 --- a/tests/core/test_security.py +++ b/tests/core/test_security.py @@ -9,7 +9,6 @@ import tarfile import tempfile import unittest -import unittest.mock import zipfile from pythainlp.corpus import corpus_path @@ -17,11 +16,9 @@ _is_within_directory, _safe_extract_tar, _safe_extract_zip, - get_path_folder_corpus, - path_pythainlp_corpus, ) from pythainlp.tools import get_full_data_path -from pythainlp.tools.path import get_pythainlp_data_path +from pythainlp.tools.path import _safe_path_join, get_pythainlp_data_path class SecurityTestCase(unittest.TestCase): @@ -210,37 +207,29 @@ def test_get_full_data_path_rejects_multiple_traversal(self): get_full_data_path("../../../root/.ssh/id_rsa") self.assertIn("path traversal", str(ctx.exception).lower()) - def test_path_pythainlp_corpus_safe(self): - """Test that path_pythainlp_corpus returns a path within the corpus directory.""" - result = path_pythainlp_corpus("negations_th.txt") + def test_safe_path_join_bundled_corpus_safe(self): + """Test _safe_path_join with corpus_path() base accepts safe filenames.""" + result = _safe_path_join(corpus_path(), "negations_th.txt") self.assertTrue(_is_within_directory(corpus_path(), result)) - def test_path_pythainlp_corpus_rejects_traversal(self): - """Test that path_pythainlp_corpus rejects path traversal attempts.""" + def test_safe_path_join_bundled_corpus_rejects_traversal(self): + """Test _safe_path_join with corpus_path() base rejects traversal.""" with self.assertRaises(ValueError) as ctx: - path_pythainlp_corpus("../../etc/passwd") + _safe_path_join(corpus_path(), "../../etc/passwd") self.assertIn("path traversal", str(ctx.exception).lower()) - def test_get_path_folder_corpus_safe(self): - """Test that get_path_folder_corpus returns a path within the corpus folder.""" + def test_safe_path_join_with_tmpdir_safe(self): + """Test _safe_path_join accepts safe sub-paths within a temp directory.""" with tempfile.TemporaryDirectory() as tmpdir: - with unittest.mock.patch( - "pythainlp.corpus.core.get_corpus_path", - return_value=tmpdir, - ): - result = get_path_folder_corpus("testcorpus", "1.0", "model.txt") - self.assertTrue(_is_within_directory(tmpdir, result)) - - def test_get_path_folder_corpus_rejects_traversal(self): - """Test that get_path_folder_corpus rejects traversal in extra path components.""" + result = _safe_path_join(tmpdir, "model.txt") + self.assertTrue(_is_within_directory(tmpdir, result)) + + def test_safe_path_join_with_tmpdir_rejects_traversal(self): + """Test _safe_path_join rejects traversal escape from a temp directory.""" with tempfile.TemporaryDirectory() as tmpdir: - with unittest.mock.patch( - "pythainlp.corpus.core.get_corpus_path", - return_value=tmpdir, - ): - with self.assertRaises(ValueError) as ctx: - get_path_folder_corpus("testcorpus", "1.0", "../../etc/passwd") - self.assertIn("path traversal", str(ctx.exception).lower()) + with self.assertRaises(ValueError) as ctx: + _safe_path_join(tmpdir, "../../etc/passwd") + self.assertIn("path traversal", str(ctx.exception).lower()) if __name__ == "__main__": From 6271e24239f3a4d77e95a4f939f1dd7f27a1d4d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:31:53 +0000 Subject: [PATCH 05/15] refactor: promote _safe_path_join to public safe_path_join in pythainlp.tools Co-authored-by: bact <128572+bact@users.noreply.github.com> --- pythainlp/corpus/core.py | 6 +++--- pythainlp/tag/wangchanberta_onnx.py | 8 ++++---- pythainlp/tools/__init__.py | 2 ++ pythainlp/tools/path.py | 12 ++++++------ tests/core/test_security.py | 20 ++++++++++---------- 5 files changed, 25 insertions(+), 23 deletions(-) diff --git a/pythainlp/corpus/core.py b/pythainlp/corpus/core.py index 3c1c44978..c2964ea57 100644 --- a/pythainlp/corpus/core.py +++ b/pythainlp/corpus/core.py @@ -19,9 +19,9 @@ from pythainlp.corpus import corpus_db_path, corpus_db_url, corpus_path from pythainlp.tools import get_full_data_path from pythainlp.tools.path import ( - _safe_path_join, is_offline_mode, is_read_only_mode, + safe_path_join, ) if TYPE_CHECKING: @@ -239,13 +239,13 @@ def get_corpus_default_db(name: str, version: str = "") -> Optional[str]: if name in corpus_db: if version in corpus_db[name]["versions"]: - return _safe_path_join( + return safe_path_join( corpus_path(), corpus_db[name]["versions"][version]["filename"], ) elif not version: # load latest version version = corpus_db[name]["latest_version"] - return _safe_path_join( + return safe_path_join( corpus_path(), corpus_db[name]["versions"][version]["filename"], ) diff --git a/pythainlp/tag/wangchanberta_onnx.py b/pythainlp/tag/wangchanberta_onnx.py index 1472d8fe3..e4fa34a27 100644 --- a/pythainlp/tag/wangchanberta_onnx.py +++ b/pythainlp/tag/wangchanberta_onnx.py @@ -12,7 +12,7 @@ from onnxruntime import InferenceSession, SessionOptions from pythainlp.corpus import get_corpus_path -from pythainlp.tools.path import _safe_path_join +from pythainlp.tools import safe_path_join class WngchanBerta_ONNX: @@ -59,19 +59,19 @@ def __init__( f" CLI: thainlp data get {self.model_name}" ) self.session = InferenceSession( - _safe_path_join(_corpus_base, file_onnx), + safe_path_join(_corpus_base, file_onnx), sess_options=self.options, providers=providers, ) self.session.disable_fallback() self.outputs_name = self.session.get_outputs()[0].name self.sp = spm.SentencePieceProcessor( - model_file=_safe_path_join( + model_file=safe_path_join( _corpus_base, "sentencepiece.bpe.model" ) ) with open( - _safe_path_join(_corpus_base, "config.json"), + safe_path_join(_corpus_base, "config.json"), encoding="utf-8-sig", ) as fh: self._json = json.load(fh) diff --git a/pythainlp/tools/__init__.py b/pythainlp/tools/__init__.py index fcb8d662d..cc718a93d 100644 --- a/pythainlp/tools/__init__.py +++ b/pythainlp/tools/__init__.py @@ -9,6 +9,7 @@ "is_offline_mode", "is_read_only_mode", "is_unsafe_pickle_allowed", + "safe_path_join", "safe_print", "warn_deprecation", ] @@ -22,4 +23,5 @@ is_offline_mode, is_read_only_mode, is_unsafe_pickle_allowed, + safe_path_join, ) diff --git a/pythainlp/tools/path.py b/pythainlp/tools/path.py index 375ab6dab..b92afa876 100644 --- a/pythainlp/tools/path.py +++ b/pythainlp/tools/path.py @@ -159,13 +159,13 @@ def is_offline_mode() -> bool: return val.strip().lower() not in ("", "0", "false", "no", "off") -def _safe_path_join(base: str, *parts: str) -> str: +def safe_path_join(base: str, *parts: str) -> str: """Join *base* with *parts*, verify containment, and return the normalized path. - This is the single authoritative path-traversal guard used throughout - the library wherever a base directory and external path components are - combined (e.g., :func:`get_full_data_path` and the internal corpus - path helpers in :mod:`pythainlp.corpus.core`). + This is the authoritative path-traversal guard used throughout the library + wherever a base directory and external path components are combined + (e.g., :func:`get_full_data_path` and the internal corpus path helpers + in :mod:`pythainlp.corpus.core`). :param str base: base directory that the result must reside within. :param parts: additional path components to append. @@ -205,7 +205,7 @@ def get_full_data_path(path: str) -> str: get_full_data_path("ttc_freq.txt") # output: '/root/pythainlp-data/ttc_freq.txt' """ - return _safe_path_join(get_pythainlp_data_path(), path) + return safe_path_join(get_pythainlp_data_path(), path) def get_pythainlp_data_path() -> str: diff --git a/tests/core/test_security.py b/tests/core/test_security.py index 391fd793e..8f9b48d0c 100644 --- a/tests/core/test_security.py +++ b/tests/core/test_security.py @@ -17,8 +17,8 @@ _safe_extract_tar, _safe_extract_zip, ) -from pythainlp.tools import get_full_data_path -from pythainlp.tools.path import _safe_path_join, get_pythainlp_data_path +from pythainlp.tools import get_full_data_path, safe_path_join +from pythainlp.tools.path import get_pythainlp_data_path class SecurityTestCase(unittest.TestCase): @@ -208,27 +208,27 @@ def test_get_full_data_path_rejects_multiple_traversal(self): self.assertIn("path traversal", str(ctx.exception).lower()) def test_safe_path_join_bundled_corpus_safe(self): - """Test _safe_path_join with corpus_path() base accepts safe filenames.""" - result = _safe_path_join(corpus_path(), "negations_th.txt") + """Test safe_path_join with corpus_path() base accepts safe filenames.""" + result = safe_path_join(corpus_path(), "negations_th.txt") self.assertTrue(_is_within_directory(corpus_path(), result)) def test_safe_path_join_bundled_corpus_rejects_traversal(self): - """Test _safe_path_join with corpus_path() base rejects traversal.""" + """Test safe_path_join with corpus_path() base rejects traversal.""" with self.assertRaises(ValueError) as ctx: - _safe_path_join(corpus_path(), "../../etc/passwd") + safe_path_join(corpus_path(), "../../etc/passwd") self.assertIn("path traversal", str(ctx.exception).lower()) def test_safe_path_join_with_tmpdir_safe(self): - """Test _safe_path_join accepts safe sub-paths within a temp directory.""" + """Test safe_path_join accepts safe sub-paths within a temp directory.""" with tempfile.TemporaryDirectory() as tmpdir: - result = _safe_path_join(tmpdir, "model.txt") + result = safe_path_join(tmpdir, "model.txt") self.assertTrue(_is_within_directory(tmpdir, result)) def test_safe_path_join_with_tmpdir_rejects_traversal(self): - """Test _safe_path_join rejects traversal escape from a temp directory.""" + """Test safe_path_join rejects traversal escape from a temp directory.""" with tempfile.TemporaryDirectory() as tmpdir: with self.assertRaises(ValueError) as ctx: - _safe_path_join(tmpdir, "../../etc/passwd") + safe_path_join(tmpdir, "../../etc/passwd") self.assertIn("path traversal", str(ctx.exception).lower()) From 10e981c0f9fa3660d9487c0b2de00600ba2d31f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:43:37 +0000 Subject: [PATCH 06/15] fix: remove duplicate FileNotFoundError guard; update CHANGELOG entry Co-authored-by: bact <128572+bact@users.noreply.github.com> --- CHANGELOG.md | 3 +-- pythainlp/tag/wangchanberta_onnx.py | 9 +-------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c88643fd..282e47a54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,7 @@ This release focuses on security issues related to corpus file loading. - w2p: Use npz model instead of pickle (#1328) - Fix path traversal vulnerabilities: validate that paths constructed from external input stay within their expected base directory; - `get_full_data_path()`, `path_pythainlp_corpus()`, and - `get_path_folder_corpus()` now raise `ValueError` on traversal attempts + `get_full_data_path()` now raises `ValueError` on traversal attempts ## [5.3.0] - 2026-03-10 diff --git a/pythainlp/tag/wangchanberta_onnx.py b/pythainlp/tag/wangchanberta_onnx.py index e4fa34a27..f1c9f7fe1 100644 --- a/pythainlp/tag/wangchanberta_onnx.py +++ b/pythainlp/tag/wangchanberta_onnx.py @@ -50,14 +50,7 @@ def __init__( ) _corpus_base = get_corpus_path(self.model_name, self.model_version) if not _corpus_base: - raise FileNotFoundError( - f"corpus-not-found name={self.model_name!r} " - f"version={self.model_version!r}\n" - f" Corpus '{self.model_name}' " - f"(version {self.model_version}) not found.\n" - f" Python: pythainlp.corpus.download('{self.model_name}')\n" - f" CLI: thainlp data get {self.model_name}" - ) + raise FileNotFoundError(self.model_name) self.session = InferenceSession( safe_path_join(_corpus_base, file_onnx), sess_options=self.options, From cad6cdd34c60b11963d074e3169375fc1a58327e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:01:42 +0000 Subject: [PATCH 07/15] style: apply black formatting; fix flake8 E303 in test_security.py Co-authored-by: bact <128572+bact@users.noreply.github.com> --- pythainlp/tag/wangchanberta_onnx.py | 30 +++++++++-------------------- tests/core/test_security.py | 1 - 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/pythainlp/tag/wangchanberta_onnx.py b/pythainlp/tag/wangchanberta_onnx.py index f1c9f7fe1..d7a6aeed0 100644 --- a/pythainlp/tag/wangchanberta_onnx.py +++ b/pythainlp/tag/wangchanberta_onnx.py @@ -45,9 +45,7 @@ def __init__( self.model_name = model_name self.model_version = model_version self.options = SessionOptions() - self.options.graph_optimization_level = ( - GraphOptimizationLevel.ORT_ENABLE_ALL - ) + self.options.graph_optimization_level = GraphOptimizationLevel.ORT_ENABLE_ALL _corpus_base = get_corpus_path(self.model_name, self.model_version) if not _corpus_base: raise FileNotFoundError(self.model_name) @@ -59,9 +57,7 @@ def __init__( self.session.disable_fallback() self.outputs_name = self.session.get_outputs()[0].name self.sp = spm.SentencePieceProcessor( - model_file=safe_path_join( - _corpus_base, "sentencepiece.bpe.model" - ) + model_file=safe_path_join(_corpus_base, "sentencepiece.bpe.model") ) with open( safe_path_join(_corpus_base, "config.json"), @@ -76,9 +72,7 @@ def build_tokenizer(self, sent: str) -> dict[str, "np.ndarray"]: _t = [5] + [i + 4 for i in self.sp.encode(sent)] + [6] model_inputs = {} model_inputs["input_ids"] = np.array([_t], dtype=np.int64) - model_inputs["attention_mask"] = np.array( - [[1] * len(_t)], dtype=np.int64 - ) + model_inputs["attention_mask"] = np.array([[1] * len(_t)], dtype=np.int64) return model_inputs def postprocess(self, logits_data: "np.ndarray") -> "np.ndarray": @@ -90,9 +84,7 @@ def postprocess(self, logits_data: "np.ndarray") -> "np.ndarray": scores = shifted_exp / shifted_exp.sum(axis=-1, keepdims=True) return scores - def clean_output( - self, list_text: list[tuple[str, str]] - ) -> list[tuple[str, str]]: + def clean_output(self, list_text: list[tuple[str, str]]) -> list[tuple[str, str]]: return list_text def totag(self, post: np.ndarray, sent: str) -> list[tuple[str, str]]: @@ -102,25 +94,21 @@ def totag(self, post: np.ndarray, sent: str) -> list[tuple[str, str]]: tag.append( ( _s[i], - self.id2tag[ - str(list(post[i + 1]).index(max(list(post[i + 1])))) - ], + self.id2tag[str(list(post[i + 1]).index(max(list(post[i + 1]))))], ) ) return tag - def _config( - self, list_ner: list[tuple[str, str]] - ) -> list[tuple[str, str]]: + def _config(self, list_ner: list[tuple[str, str]]) -> list[tuple[str, str]]: return list_ner def get_ner( self, text: str, tag: bool = False ) -> Union[str, list[tuple[str, str]]]: self._s: dict[str, "np.ndarray"] = self.build_tokenizer(text) - logits = self.session.run( - output_names=[self.outputs_name], input_feed=self._s - )[0] + logits = self.session.run(output_names=[self.outputs_name], input_feed=self._s)[ + 0 + ] _tag = self.clean_output(self.totag(self.postprocess(logits), text)) if tag: _tag = self._config(_tag) diff --git a/tests/core/test_security.py b/tests/core/test_security.py index 8f9b48d0c..07095b942 100644 --- a/tests/core/test_security.py +++ b/tests/core/test_security.py @@ -189,7 +189,6 @@ def test_is_within_directory_with_symlinks(self): # in the _safe_extract_tar and _safe_extract_zip functions, # which check where symlinks actually point to. - def test_get_full_data_path_safe(self): """Test that get_full_data_path returns a path within the data directory.""" result = get_full_data_path("ttc_freq.txt") From 57d2839e2d55efcbb3124755e48d53a1e00b3784 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Thu, 19 Mar 2026 09:10:16 +0000 Subject: [PATCH 08/15] Update pythainlp/tag/wangchanberta_onnx.py --- pythainlp/tag/wangchanberta_onnx.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pythainlp/tag/wangchanberta_onnx.py b/pythainlp/tag/wangchanberta_onnx.py index d7a6aeed0..ec56fabbe 100644 --- a/pythainlp/tag/wangchanberta_onnx.py +++ b/pythainlp/tag/wangchanberta_onnx.py @@ -45,7 +45,9 @@ def __init__( self.model_name = model_name self.model_version = model_version self.options = SessionOptions() - self.options.graph_optimization_level = GraphOptimizationLevel.ORT_ENABLE_ALL + self.options.graph_optimization_level = ( + GraphOptimizationLevel.ORT_ENABLE_ALL + ) _corpus_base = get_corpus_path(self.model_name, self.model_version) if not _corpus_base: raise FileNotFoundError(self.model_name) From cde810ffff127ce904c5523f662ec5f2d52f3c36 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Thu, 19 Mar 2026 09:10:23 +0000 Subject: [PATCH 09/15] Update pythainlp/tag/wangchanberta_onnx.py --- pythainlp/tag/wangchanberta_onnx.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pythainlp/tag/wangchanberta_onnx.py b/pythainlp/tag/wangchanberta_onnx.py index ec56fabbe..c4c561be2 100644 --- a/pythainlp/tag/wangchanberta_onnx.py +++ b/pythainlp/tag/wangchanberta_onnx.py @@ -74,7 +74,9 @@ def build_tokenizer(self, sent: str) -> dict[str, "np.ndarray"]: _t = [5] + [i + 4 for i in self.sp.encode(sent)] + [6] model_inputs = {} model_inputs["input_ids"] = np.array([_t], dtype=np.int64) - model_inputs["attention_mask"] = np.array([[1] * len(_t)], dtype=np.int64) + model_inputs["attention_mask"] = np.array( + [[1] * len(_t)], dtype=np.int64 + ) return model_inputs def postprocess(self, logits_data: "np.ndarray") -> "np.ndarray": From a61e4e6091fb3f76292b8d9e60b723c07eca97b5 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Thu, 19 Mar 2026 09:10:32 +0000 Subject: [PATCH 10/15] Update pythainlp/tag/wangchanberta_onnx.py --- pythainlp/tag/wangchanberta_onnx.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pythainlp/tag/wangchanberta_onnx.py b/pythainlp/tag/wangchanberta_onnx.py index c4c561be2..958174024 100644 --- a/pythainlp/tag/wangchanberta_onnx.py +++ b/pythainlp/tag/wangchanberta_onnx.py @@ -88,7 +88,9 @@ def postprocess(self, logits_data: "np.ndarray") -> "np.ndarray": scores = shifted_exp / shifted_exp.sum(axis=-1, keepdims=True) return scores - def clean_output(self, list_text: list[tuple[str, str]]) -> list[tuple[str, str]]: + def clean_output( + self, list_text: list[tuple[str, str]] + ) -> list[tuple[str, str]]: return list_text def totag(self, post: np.ndarray, sent: str) -> list[tuple[str, str]]: From 504216d5aafb5551646a8f92b2c379c6ea2bc9ad Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Thu, 19 Mar 2026 09:10:38 +0000 Subject: [PATCH 11/15] Update pythainlp/tag/wangchanberta_onnx.py --- pythainlp/tag/wangchanberta_onnx.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pythainlp/tag/wangchanberta_onnx.py b/pythainlp/tag/wangchanberta_onnx.py index 958174024..d59c2e20a 100644 --- a/pythainlp/tag/wangchanberta_onnx.py +++ b/pythainlp/tag/wangchanberta_onnx.py @@ -100,7 +100,9 @@ def totag(self, post: np.ndarray, sent: str) -> list[tuple[str, str]]: tag.append( ( _s[i], - self.id2tag[str(list(post[i + 1]).index(max(list(post[i + 1]))))], + self.id2tag[ + str(list(post[i + 1]).index(max(list(post[i + 1])))) + ], ) ) return tag From d0ccffbafb1a73f449c85e1c889602defdcd8d6a Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Thu, 19 Mar 2026 09:10:46 +0000 Subject: [PATCH 12/15] Update pythainlp/tag/wangchanberta_onnx.py --- pythainlp/tag/wangchanberta_onnx.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pythainlp/tag/wangchanberta_onnx.py b/pythainlp/tag/wangchanberta_onnx.py index d59c2e20a..e7d5ed0f3 100644 --- a/pythainlp/tag/wangchanberta_onnx.py +++ b/pythainlp/tag/wangchanberta_onnx.py @@ -107,7 +107,9 @@ def totag(self, post: np.ndarray, sent: str) -> list[tuple[str, str]]: ) return tag - def _config(self, list_ner: list[tuple[str, str]]) -> list[tuple[str, str]]: + def _config( + self, list_ner: list[tuple[str, str]] + ) -> list[tuple[str, str]]: return list_ner def get_ner( From c2881ae20b81ed7fa0cc944ab332b008dacfaddb Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Thu, 19 Mar 2026 09:10:53 +0000 Subject: [PATCH 13/15] Update pythainlp/tag/wangchanberta_onnx.py --- pythainlp/tag/wangchanberta_onnx.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pythainlp/tag/wangchanberta_onnx.py b/pythainlp/tag/wangchanberta_onnx.py index e7d5ed0f3..cf62104c0 100644 --- a/pythainlp/tag/wangchanberta_onnx.py +++ b/pythainlp/tag/wangchanberta_onnx.py @@ -116,9 +116,9 @@ def get_ner( self, text: str, tag: bool = False ) -> Union[str, list[tuple[str, str]]]: self._s: dict[str, "np.ndarray"] = self.build_tokenizer(text) - logits = self.session.run(output_names=[self.outputs_name], input_feed=self._s)[ - 0 - ] + logits = self.session.run( + output_names=[self.outputs_name], input_feed=self._s + )[0] _tag = self.clean_output(self.totag(self.postprocess(logits), text)) if tag: _tag = self._config(_tag) From c01ad3300ff7d5f7096785810afbb35d403540a1 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Thu, 19 Mar 2026 09:13:12 +0000 Subject: [PATCH 14/15] Update CHANGELOG for release 5.3.2 This release addresses security issues related to path traversal by validating that paths stay within their expected base directory. --- CHANGELOG.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 282e47a54..bb9b8e119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Full release notes: -- Commit history: +- Commit history: + +## [5.3.2] - 2026-03-20 + +This release focuses on security issues related to path traversal. + +### Security + +- Prevent path traversal: validate that paths stay within + their expected base directory (#1342) ## [5.3.1] - 2026-03-14 @@ -26,9 +35,6 @@ This release focuses on security issues related to corpus file loading. - thai2fit: Use JSON model instead of pickle (#1325) - Defensive corpus loading: validate fields before processing (#1327) - w2p: Use npz model instead of pickle (#1328) -- Fix path traversal vulnerabilities: validate that paths constructed from - external input stay within their expected base directory; - `get_full_data_path()` now raises `ValueError` on traversal attempts ## [5.3.0] - 2026-03-10 @@ -215,6 +221,7 @@ The minimum requirement is now Python 3.9. - See +[5.3.2]: https://github.com/PyThaiNLP/pythainlp/compare/v5.3.1...v5.3.2 [5.3.1]: https://github.com/PyThaiNLP/pythainlp/compare/v5.3.0...v5.3.1 [5.3.0]: https://github.com/PyThaiNLP/pythainlp/compare/v5.2.0...v5.3.0 [5.2.0]: https://github.com/PyThaiNLP/pythainlp/compare/v5.1.2...v5.2.0 From 54d11e606cb54babe3214dd640793615a77ffb82 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Thu, 19 Mar 2026 09:16:23 +0000 Subject: [PATCH 15/15] Update AGENTS.md about path join --- AGENTS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab126798e..6d225a119 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ . The document list test categories, their dependency sets, and test naming conventions. -- [ ] Use reStructuredText for docstring (PEP 287), targetting Sphinx. +- [ ] Use reStructuredText for docstring (PEP 287), targeting Sphinx. - [ ] When possible, follow NLTK established convention of submodule name (tend to be a verb or a generic noun), function name, and configuration. Communicate this to the users during code review. @@ -20,7 +20,7 @@ the repo. Read its usage and information it generates at . Mind that the analyzer can create false positives, - please refer to Python tyep specification when in doubt. + please refer to Python type specification when in doubt. - [ ] Complete type annotations for function, method, class, variable, etc. Maintain near-100% type annotation coverage. - [ ] Add tests for new functionality or behavior. @@ -31,6 +31,9 @@ - [ ] Major changes should be logged in the change log at . Provide issue number or PR number if available. +- [ ] Do not use os.path.join(); + always use pythainlp.tools.safe_path_join() instead, + to prevent path traversal vulnerabilities (CWE-22). ## Project contribution guidelines