66import logging
77import os
88import platform
9+ import stat
910import threading
1011from abc import ABC , abstractmethod
1112from dataclasses import dataclass
1718from cryptography .hazmat .primitives import serialization
1819from filelock import BaseFileLock , FileLock
1920
21+ from .compat import IS_WINDOWS
22+
2023logger = logging .getLogger (__name__ )
2124
2225
@@ -200,21 +203,26 @@ class CRLFileCache(CRLCache):
200203 """
201204
202205 def __init__ (
203- self , cache_dir : Path | None = None , removal_delay : timedelta | None = None
206+ self ,
207+ cache_dir : Path | None = None ,
208+ removal_delay : timedelta | None = None ,
209+ unsafe_skip_file_permissions_check : bool = False ,
204210 ):
205211 """
206212 Initialize the file cache.
207213
208214 Args:
209215 cache_dir: Directory to store cached CRLs
210216 removal_delay: How long to wait before removing expired files
217+ unsafe_skip_file_permissions_check: Skip file permission validation for security
211218
212219 Raises:
213220 OSError: If cache directory cannot be created
214221 """
215222 self ._cache_file_lock_timeout = 5.0
216223 self ._cache_dir = cache_dir or _get_default_crl_cache_path ()
217224 self ._removal_delay = removal_delay or timedelta (days = 7 )
225+ self ._unsafe_skip_file_permissions_check = unsafe_skip_file_permissions_check
218226
219227 self ._ensure_cache_directory_exists ()
220228
@@ -248,6 +256,40 @@ def _get_crl_file_lock(self, crl_cache_file: Path) -> BaseFileLock:
248256 timeout = self ._cache_file_lock_timeout ,
249257 )
250258
259+ def _check_file_permissions (self , file_path : Path ) -> None :
260+ """
261+ Check that the CRL cache file has secure permissions (owner-only access).
262+
263+ Note: This check is only performed on Unix-like systems. Windows file
264+ permissions work differently and are not checked.
265+
266+ Args:
267+ file_path: Path to the file to check
268+
269+ Raises:
270+ PermissionError: If file permissions are too wide or file has wrong owner
271+ """
272+ # Skip permission checks on Windows as they work differently
273+ if IS_WINDOWS :
274+ return
275+
276+ try :
277+ stat_info = file_path .stat ()
278+ actual_permissions = stat .S_IMODE (stat_info .st_mode )
279+
280+ # Check that file is readable/writable only by owner (0o600 or more restrictive)
281+ if (
282+ actual_permissions & 0o077 != 0
283+ ): # Check if group or others have any permission
284+ raise PermissionError (
285+ f"CRL cache file { file_path } has insecure permissions: { oct (actual_permissions )} . "
286+ f"File must be accessible only by the owner (e.g., 0o600 or 0o400)."
287+ )
288+
289+ except FileNotFoundError :
290+ # File doesn't exist yet, this is fine
291+ pass
292+
251293 def get (self , crl_url : str ) -> CRLCacheEntry | None :
252294 """
253295 Get a CRL cache entry from disk.
@@ -264,6 +306,14 @@ def get(self, crl_url: str) -> CRLCacheEntry | None:
264306 if crl_file_path .exists ():
265307 logger .debug (f"Found CRL on disk for { crl_file_path } " )
266308
309+ # Check file permissions before reading
310+ if not self ._unsafe_skip_file_permissions_check :
311+ self ._check_file_permissions (crl_file_path )
312+ else :
313+ logger .warning (
314+ f"Skipping file permissions check for { crl_file_path } "
315+ )
316+
267317 # Get file modification time as download time
268318 stat_info = crl_file_path .stat ()
269319 download_time = datetime .fromtimestamp (
@@ -277,6 +327,11 @@ def get(self, crl_url: str) -> CRLCacheEntry | None:
277327 crl = x509 .load_der_x509_crl (crl_data , backend = default_backend ())
278328 return CRLCacheEntry (crl , download_time )
279329
330+ except PermissionError as e :
331+ logger .error (
332+ f"Permission error reading CRL from disk cache for { crl_url } : { e } "
333+ )
334+ return None
280335 except Exception as e :
281336 logger .warning (f"Failed to read CRL from disk cache for { crl_url } : { e } " )
282337
@@ -296,9 +351,15 @@ def put(self, crl_url: str, entry: CRLCacheEntry) -> None:
296351 # Serialize the CRL to DER format
297352 crl_data = entry .crl .public_bytes (serialization .Encoding .DER )
298353
299- # Write to file
300- with open (crl_file_path , "wb" ) as f :
301- f .write (crl_data )
354+ # Write to file with secure permissions (owner read/write only)
355+ # Using os.open with 0o600 ensures the file is created with secure permissions
356+ fd = os .open (
357+ crl_file_path , os .O_WRONLY | os .O_CREAT | os .O_TRUNC , 0o600
358+ )
359+ try :
360+ os .write (fd , crl_data )
361+ finally :
362+ os .close (fd )
302363
303364 # Set file modification time to download time
304365 download_timestamp = entry .download_time .timestamp ()
@@ -453,25 +514,34 @@ def get_memory_cache(cls, cache_validity_time: timedelta) -> CRLInMemoryCache:
453514
454515 @classmethod
455516 def get_file_cache (
456- cls , cache_dir : Path | None = None , removal_delay : timedelta | None = None
517+ cls ,
518+ cache_dir : Path | None = None ,
519+ removal_delay : timedelta | None = None ,
520+ unsafe_skip_file_permissions_check : bool = False ,
457521 ) -> CRLFileCache :
458522 """
459523 Get or create a singleton CRLFileCache instance.
460524
461525 Args:
462526 cache_dir: Directory to store cached CRLs
463527 removal_delay: How long to wait before removing expired files
528+ unsafe_skip_file_permissions_check: Skip file permission validation for security
464529
465530 Returns:
466531 The singleton CRLFileCache instance
467532 """
468533 with cls ._instance_lock :
469534 if cls ._file_cache_instance is None :
470- cls ._file_cache_instance = CRLFileCache (cache_dir , removal_delay )
535+ cls ._file_cache_instance = CRLFileCache (
536+ cache_dir , removal_delay , unsafe_skip_file_permissions_check
537+ )
471538 else :
472539 # Check if parameters differ from existing instance
473540 existing_cache_dir = cls ._file_cache_instance ._cache_dir
474541 existing_removal_delay = cls ._file_cache_instance ._removal_delay
542+ existing_skip_check = (
543+ cls ._file_cache_instance ._unsafe_skip_file_permissions_check
544+ )
475545 requested_cache_dir = cache_dir or _get_default_crl_cache_path ()
476546 requested_removal_delay = removal_delay or timedelta (days = 7 )
477547
@@ -485,6 +555,11 @@ def get_file_cache(
485555 f"CRLs file cache has already been initialized with removal delay of { existing_removal_delay } , "
486556 f"ignoring new removal delay of { requested_removal_delay } "
487557 )
558+ if existing_skip_check != unsafe_skip_file_permissions_check :
559+ logger .warning (
560+ f"CRLs file cache has already been initialized with unsafe_skip_file_permissions_check={ existing_skip_check } , "
561+ f"ignoring new value { unsafe_skip_file_permissions_check } "
562+ )
488563 return cls ._file_cache_instance
489564
490565 @classmethod
0 commit comments