Skip to content

Commit ab472d4

Browse files
file permissions check
1 parent 7a6528c commit ab472d4

File tree

4 files changed

+186
-7
lines changed

4 files changed

+186
-7
lines changed

src/snowflake/connector/connection.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,13 @@ def crl_download_max_size(self) -> int | None:
797797
return self._crl_download_max_size
798798
return self._crl_config.crl_download_max_size
799799

800+
@property
801+
def skip_file_permissions_check(self) -> bool | None:
802+
"""Whether to skip file permission checks for CRL cache files."""
803+
if not self._crl_config:
804+
return self._skip_file_permissions_check
805+
return self._crl_config.skip_file_permissions_check
806+
800807
@property
801808
def session_id(self) -> int:
802809
return self._session_id

src/snowflake/connector/crl.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class CRLConfig:
6565
crl_cache_cleanup_interval_hours: int = 1
6666
crl_cache_start_cleanup: bool = False
6767
crl_download_max_size: int = 200 * 1024 * 1024 # 200 MB
68+
unsafe_skip_file_permissions_check: bool = False
6869

6970
@classmethod
7071
def from_connection(cls, sf_connection) -> CRLConfig:
@@ -164,6 +165,10 @@ def from_connection(cls, sf_connection) -> CRLConfig:
164165
if sf_connection.crl_download_max_size is None
165166
else int(sf_connection.crl_download_max_size)
166167
)
168+
# Use the existing unsafe_skip_file_permissions_check flag from connection
169+
unsafe_skip_file_permissions_check = bool(
170+
sf_connection._unsafe_skip_file_permissions_check
171+
)
167172

168173
return cls(
169174
cert_revocation_check_mode=cert_revocation_check_mode,
@@ -178,6 +183,7 @@ def from_connection(cls, sf_connection) -> CRLConfig:
178183
crl_cache_cleanup_interval_hours=crl_cache_cleanup_interval_hours,
179184
crl_cache_start_cleanup=crl_cache_start_cleanup,
180185
crl_download_max_size=crl_download_max_size,
186+
unsafe_skip_file_permissions_check=unsafe_skip_file_permissions_check,
181187
)
182188

183189

@@ -246,7 +252,9 @@ def from_config(
246252
if config.enable_crl_file_cache:
247253
removal_delay = timedelta(days=config.crl_cache_removal_delay_days)
248254
file_cache = CRLCacheFactory.get_file_cache(
249-
cache_dir=config.crl_cache_dir, removal_delay=removal_delay
255+
cache_dir=config.crl_cache_dir,
256+
removal_delay=removal_delay,
257+
unsafe_skip_file_permissions_check=config.unsafe_skip_file_permissions_check,
250258
)
251259
else:
252260
from snowflake.connector.crl_cache import NoopCRLCache

src/snowflake/connector/crl_cache.py

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
import os
88
import platform
9+
import stat
910
import threading
1011
from abc import ABC, abstractmethod
1112
from dataclasses import dataclass
@@ -17,6 +18,8 @@
1718
from cryptography.hazmat.primitives import serialization
1819
from filelock import BaseFileLock, FileLock
1920

21+
from .compat import IS_WINDOWS
22+
2023
logger = 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

test/unit/test_crl_cache.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,3 +618,92 @@ def test_atexit_handler_error_handling(cache_factory):
618618
cache_factory._atexit_cleanup_handler()
619619
except Exception as e:
620620
pytest.fail(f"Atexit handler should not raise exceptions: {e}")
621+
622+
623+
# File permission tests
624+
import os
625+
import stat
626+
627+
from snowflake.connector.compat import IS_WINDOWS
628+
629+
630+
@pytest.mark.skipif(
631+
IS_WINDOWS, reason="File permission checks not applicable on Windows"
632+
)
633+
def test_file_cache_creates_files_with_secure_permissions(crl, download_time):
634+
"""Test that CRL cache files are created with owner-only permissions (0o600)"""
635+
with tempfile.TemporaryDirectory() as temp_dir:
636+
cache = CRLFileCache(Path(temp_dir), timedelta(hours=1))
637+
crl_url = "http://test.com/secure_crl"
638+
entry = CRLCacheEntry(crl, download_time)
639+
640+
cache.put(crl_url, entry)
641+
642+
# Get the file path and check its permissions
643+
file_path = cache._get_crl_file_path(crl_url)
644+
file_stat = file_path.stat()
645+
permissions = stat.S_IMODE(file_stat.st_mode)
646+
647+
# Should be 0o600 (read/write for owner only)
648+
assert permissions == 0o600, f"Expected 0o600, got {oct(permissions)}"
649+
650+
651+
@pytest.mark.skipif(
652+
IS_WINDOWS, reason="File permission checks not applicable on Windows"
653+
)
654+
@pytest.mark.parametrize(
655+
"chmod_permissions,skip_check,result_should_be_none",
656+
[
657+
(
658+
0o644,
659+
False,
660+
True,
661+
), # rw-r--r-- (world-readable) with checks - should be rejected
662+
(
663+
0o640,
664+
False,
665+
True,
666+
), # rw-r----- (group-readable) with checks - should be rejected
667+
(
668+
0o400,
669+
False,
670+
False,
671+
), # r-------- (owner read-only) with checks - should be accepted
672+
(
673+
0o644,
674+
True,
675+
False,
676+
), # rw-r--r-- (world-readable) without checks - should be accepted
677+
],
678+
ids=["world-readable", "group-readable", "owner-read-only", "skip-check-flag"],
679+
)
680+
def test_file_cache_permission_validation(
681+
crl, download_time, chmod_permissions, skip_check, result_should_be_none
682+
):
683+
"""Test that file cache validates file permissions correctly and respects skip flag"""
684+
with tempfile.TemporaryDirectory() as temp_dir:
685+
cache = CRLFileCache(
686+
Path(temp_dir),
687+
timedelta(hours=1),
688+
unsafe_skip_file_permissions_check=skip_check,
689+
)
690+
crl_url = "http://test.com/test_crl"
691+
entry = CRLCacheEntry(crl, download_time)
692+
693+
# Write the file first
694+
cache.put(crl_url, entry)
695+
696+
# Change file permissions
697+
file_path = cache._get_crl_file_path(crl_url)
698+
os.chmod(file_path, chmod_permissions)
699+
700+
# Try to read the file
701+
result = cache.get(crl_url)
702+
703+
if result_should_be_none:
704+
# Insecure permissions - should return None
705+
assert result is None
706+
else:
707+
# Secure permissions or checks disabled - should read successfully
708+
assert result is not None
709+
assert result.crl is not None

0 commit comments

Comments
 (0)