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
76from __future__ import annotations
87
98import json
109import os
1110import re
1211import sys
12+ import tarfile
13+ import zipfile
1314from importlib .resources import files
1415
1516from 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
0 commit comments