Skip to content

Commit 1ed8149

Browse files
authored
Merge pull request #1225 from PyThaiNLP/copilot/identify-security-issues
Security: Fix path traversal and symlink attacks in archive extraction
2 parents 034f16b + 5567f02 commit 1ed8149

6 files changed

Lines changed: 360 additions & 33 deletions

File tree

.python-version

Lines changed: 0 additions & 1 deletion
This file was deleted.

SECURITY.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,10 @@
1515
| 2.1.x | :x: |
1616
| 2.0.x | :x: |
1717
| < 2.0 | :x: |
18+
19+
## Future Security Recommendations
20+
21+
- Migrate from pickle to a safer serialization format like JSON or MessagePack.
22+
- Upgrade the hashing algorithm for integrity verification from MD5 to SHA-256 or SHA-3.
23+
- Implement digital signatures for corpus files to ensure authenticity.
24+
- Add version tracking to the corpus to prevent rollback attacks.

pythainlp/corpus/core.py

Lines changed: 155 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
# SPDX-FileCopyrightText: 2016-2026 PyThaiNLP Project
22
# SPDX-FileType: SOURCE
33
# SPDX-License-Identifier: Apache-2.0
4-
"""Corpus related functions.
5-
"""
4+
"""Corpus related functions."""
65

76
from __future__ import annotations
87

98
import json
109
import os
1110
import re
1211
import sys
12+
import tarfile
13+
import zipfile
1314
from importlib.resources import files
1415

1516
from pythainlp import __version__
@@ -44,13 +45,17 @@ def get_corpus_db(url: str):
4445
"""Get corpus catalog from server.
4546
4647
:param str url: URL corpus catalog
48+
49+
Security Note: Uses HTTPS with certificate validation enabled by default
50+
in Python's urllib. Only download corpus from trusted URLs.
4751
"""
4852
from urllib.error import HTTPError, URLError
4953
from urllib.request import Request, urlopen
5054

5155
corpus_db = None
5256
try:
5357
req = Request(url, headers={"User-Agent": _USER_AGENT})
58+
# SSL certificate verification is enabled by default
5459
with urlopen(req, timeout=10) as response:
5560
corpus_db = _ResponseWrapper(response)
5661
except HTTPError as http_err:
@@ -231,9 +236,7 @@ def get_corpus_default_db(name: str, version: str = "") -> str | None:
231236
return None
232237

233238

234-
def get_corpus_path(
235-
name: str, version: str = "", force: bool = False
236-
) -> str | None:
239+
def get_corpus_path(name: str, version: str = "", force: bool = False) -> str | None:
237240
"""Get corpus path.
238241
239242
:param str name: corpus name
@@ -310,12 +313,16 @@ def _download(url: str, dst: str) -> int:
310313
311314
@param: URL for downloading file
312315
@param: dst place to put the file into
316+
317+
Security Note: Downloads use HTTPS with SSL certificate validation.
318+
Files are verified using MD5 checksums after download.
313319
"""
314320
CHUNK_SIZE = 64 * 1024 # 64 KiB
315321

316322
from urllib.request import Request, urlopen
317323

318324
req = Request(url, headers={"User-Agent": _USER_AGENT})
325+
# SSL certificate verification is enabled by default
319326
with urlopen(req, timeout=10) as response:
320327
file_size = int(response.info().get("Content-Length", -1))
321328
with open(get_full_data_path(dst), "wb") as f:
@@ -356,9 +363,139 @@ def _check_hash(dst: str, md5: str) -> None:
356363
raise ValueError("Hash does not match expected.")
357364

358365

359-
def _version2int(v: str) -> int:
360-
"""X.X.X => X0X0X
366+
def _is_within_directory(directory: str, target: str) -> bool:
367+
"""Check if target path is within directory (prevent path traversal).
368+
369+
@param: directory base directory path
370+
@param: target target file path to check
371+
@return: True if target is within directory, False otherwise
372+
373+
Security Note: This function normalizes paths using os.path.abspath()
374+
to handle relative paths and .. sequences. It does NOT follow symlinks
375+
(unlike os.path.realpath()), because:
376+
- Symlink validation is handled separately in extraction functions
377+
- We want to check if the path string itself is safe, not where it points
378+
- This prevents false negatives when symlinks don't exist yet
379+
380+
For symlink security, use the extraction function's symlink validation.
381+
"""
382+
# Use abspath to normalize paths but NOT realpath (which follows symlinks)
383+
abs_directory = os.path.abspath(directory)
384+
abs_target = os.path.abspath(target)
385+
386+
# Ensure directory ends with separator for proper prefix check
387+
# This prevents /foo/bar from matching /foo/barz
388+
if not abs_directory.endswith(os.sep):
389+
abs_directory += os.sep
390+
391+
return abs_target.startswith(abs_directory) or abs_target == abs_directory.rstrip(
392+
os.sep
393+
)
394+
395+
396+
def _safe_extract_tar(tar: tarfile.TarFile, path: str) -> None:
397+
"""Safely extract tar archive, preventing path traversal attacks.
398+
399+
@param: tar tarfile object
400+
@param: path destination path for extraction
401+
402+
Security Note: This function prevents path traversal attacks including:
403+
- Files with .. in their path
404+
- Symlinks pointing outside the extraction directory
405+
- Files extracted through malicious symlinks
406+
407+
For Python 3.12+, uses tarfile.data_filter for additional protection.
408+
For Python 3.9-3.11, implements custom validation of all members.
409+
"""
410+
# Check if data_filter is available (Python 3.12+)
411+
if hasattr(tarfile, "data_filter"):
412+
# Use built-in filter which handles symlinks and other security issues
413+
try:
414+
tar.extractall(path=path, filter="data")
415+
except (
416+
tarfile.OutsideDestinationError,
417+
tarfile.LinkOutsideDestinationError,
418+
) as e:
419+
# Re-raise as ValueError for consistency with older Python versions
420+
raise ValueError(str(e))
421+
else:
422+
# Manual validation for older Python versions
423+
for member in tar.getmembers():
424+
# Check the member's target path
425+
member_path = os.path.join(path, member.name)
426+
if not _is_within_directory(path, member_path):
427+
raise ValueError(f"Attempted path traversal in tar file: {member.name}")
428+
429+
# For symlinks, also validate the link target
430+
if member.issym() or member.islnk():
431+
# Get the link target (can be absolute or relative)
432+
link_target = member.linkname
433+
434+
# If it's a relative symlink, resolve it relative to the member's directory
435+
if not os.path.isabs(link_target):
436+
member_dir = os.path.dirname(member_path)
437+
link_target = os.path.join(member_dir, link_target)
438+
else:
439+
# Absolute symlinks are dangerous - make them relative to extraction path
440+
link_target = os.path.join(path, link_target.lstrip(os.sep))
441+
442+
# Check if the resolved symlink target is within the directory
443+
if not _is_within_directory(path, link_target):
444+
raise ValueError(
445+
f"Symlink {member.name} points outside extraction directory: {member.linkname}"
446+
)
447+
448+
tar.extractall(path=path)
449+
450+
451+
def _safe_extract_zip(zip_file: zipfile.ZipFile, path: str) -> None:
452+
"""Safely extract zip archive, preventing path traversal attacks.
453+
454+
@param: zip_file zipfile object
455+
@param: path destination path for extraction
456+
457+
Security Note: This function prevents path traversal attacks including:
458+
- Files with .. in their path
459+
- Symlinks pointing outside the extraction directory (on Unix systems)
460+
461+
Note: ZIP format has limited symlink support. Symlinks are primarily
462+
created by Unix-based archiving tools and may not be portable.
361463
"""
464+
for member in zip_file.namelist():
465+
member_path = os.path.join(path, member)
466+
if not _is_within_directory(path, member_path):
467+
raise ValueError(f"Attempted path traversal in zip file: {member}")
468+
469+
# Check for potential symlinks in ZIP files
470+
# ZIP files can contain symlinks on Unix systems (external_attr indicates this)
471+
info = zip_file.getinfo(member)
472+
# Check if this is a symlink (Unix: external_attr with S_IFLNK set)
473+
# The high 16 bits of external_attr contain Unix file mode
474+
is_symlink = (info.external_attr >> 16) & 0o170000 == 0o120000
475+
476+
if is_symlink:
477+
# Read the symlink target from the file content
478+
link_target = zip_file.read(member).decode("utf-8")
479+
480+
# Resolve the link target relative to the member's directory
481+
if not os.path.isabs(link_target):
482+
member_dir = os.path.dirname(member_path)
483+
resolved_target = os.path.join(member_dir, link_target)
484+
else:
485+
# Absolute symlinks - make them relative to extraction path
486+
resolved_target = os.path.join(path, link_target.lstrip(os.sep))
487+
488+
# Check if the symlink target is within the directory
489+
if not _is_within_directory(path, resolved_target):
490+
raise ValueError(
491+
f"Symlink {member} points outside extraction directory: {link_target}"
492+
)
493+
494+
zip_file.extractall(path=path)
495+
496+
497+
def _version2int(v: str) -> int:
498+
"""X.X.X => X0X0X"""
362499
if "-" in v:
363500
v = v.split("-")[0]
364501
if v.endswith(".*"):
@@ -418,9 +555,7 @@ def _check_version(cause: str) -> bool:
418555
return check
419556

420557

421-
def download(
422-
name: str, force: bool = False, url: str = "", version: str = ""
423-
) -> bool:
558+
def download(name: str, force: bool = False, url: str = "", version: str = "") -> bool:
424559
"""Download corpus.
425560
426561
The available corpus names can be seen in this file:
@@ -478,10 +613,7 @@ def download(
478613
if version not in corpus["versions"]:
479614
print("Corpus not found.")
480615
return False
481-
elif (
482-
_check_version(corpus["versions"][version]["pythainlp_version"])
483-
is False
484-
):
616+
elif _check_version(corpus["versions"][version]["pythainlp_version"]) is False:
485617
print("Corpus version not supported.")
486618
return False
487619
corpus_versions = corpus["versions"][version]
@@ -510,25 +642,19 @@ def download(
510642
foldername = None
511643

512644
if corpus_versions["is_tar_gz"] == "True":
513-
import tarfile
514-
515645
is_folder = True
516646
foldername = name + "_" + str(version)
517647
if not os.path.exists(get_full_data_path(foldername)):
518648
os.mkdir(get_full_data_path(foldername))
519649
with tarfile.open(get_full_data_path(file_name)) as tar:
520-
tar.extractall(path=get_full_data_path(foldername))
650+
_safe_extract_tar(tar, get_full_data_path(foldername))
521651
elif corpus_versions["is_zip"] == "True":
522-
import zipfile
523-
524652
is_folder = True
525653
foldername = name + "_" + str(version)
526654
if not os.path.exists(get_full_data_path(foldername)):
527655
os.mkdir(get_full_data_path(foldername))
528-
with zipfile.ZipFile(
529-
get_full_data_path(file_name), "r"
530-
) as zip_file:
531-
zip_file.extractall(path=get_full_data_path(foldername))
656+
with zipfile.ZipFile(get_full_data_path(file_name), "r") as zip_file:
657+
_safe_extract_zip(zip_file, get_full_data_path(foldername))
532658

533659
if found:
534660
local_db["_default"][found]["version"] = version
@@ -601,9 +727,7 @@ def remove(name: str) -> bool:
601727
return False
602728
with open(corpus_db_path(), encoding="utf-8-sig") as f:
603729
db = json.load(f)
604-
data = [
605-
corpus for corpus in db["_default"].values() if corpus["name"] == name
606-
]
730+
data = [corpus for corpus in db["_default"].values() if corpus["name"] == name]
607731

608732
if data:
609733
path = get_corpus_path(name)
@@ -681,10 +805,12 @@ def get_hf_hub(repo_id: str, filename: str = "") -> str:
681805
try:
682806
from huggingface_hub import hf_hub_download, snapshot_download
683807
except ModuleNotFoundError:
684-
raise ModuleNotFoundError("""
808+
raise ModuleNotFoundError(
809+
"""
685810
huggingface-hub isn't found!
686811
Please installing the package via 'pip install huggingface-hub'.
687-
""")
812+
"""
813+
)
688814
except Exception as e:
689815
raise RuntimeError(f"An unexpected error occurred: {e}") from e
690816
hf_root = get_full_data_path("hf_models")
@@ -695,7 +821,5 @@ def get_hf_hub(repo_id: str, filename: str = "") -> str:
695821
repo_id=repo_id, filename=filename, local_dir=root_project
696822
)
697823
else:
698-
output_path = snapshot_download(
699-
repo_id=repo_id, local_dir=root_project
700-
)
824+
output_path = snapshot_download(repo_id=repo_id, local_dir=root_project)
701825
return output_path

pythainlp/generate/thai2fit.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@
4343
# get vocab
4444
thwiki = THWIKI_LSTM
4545

46-
thwiki_itos = pickle.load(open(thwiki["itos_fname"], "rb"))
46+
# Security Note: This loads a pickle file from PyThaiNLP's trusted corpus.
47+
# The file is downloaded from PyThaiNLP's official repository with MD5 verification.
48+
# Users should only use corpus files from trusted sources.
49+
# WARNING: Pickle deserialization can execute arbitrary code if the file is malicious.
50+
with open(thwiki["itos_fname"], "rb") as f:
51+
thwiki_itos = pickle.load(f) # noqa: S301
4752
thwiki_vocab = fastai.text.transform.Vocab(thwiki_itos)
4853

4954
# dummy databunch

tests/core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"tests.core.test_generate",
1717
"tests.core.test_khavee",
1818
"tests.core.test_morpheme",
19+
"tests.core.test_security",
1920
"tests.core.test_soundex",
2021
"tests.core.test_spell",
2122
"tests.core.test_tag",

0 commit comments

Comments
 (0)