Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a private, per-user directory for file-based discovery caches to prevent local cache poisoning attacks in shared temporary directories. A security concern was raised regarding the order of operations in _get_cache_directory(), where calling os.chmod before verifying the path via os.lstat could allow symlink-following exploits. It is recommended to create the directory with restrictive permissions directly in os.makedirs, perform the os.lstat check first, and use f-strings for string formatting.
| os.makedirs(cache_dir, exist_ok=True) | ||
|
|
||
| if hasattr(os, "getuid"): | ||
| try: | ||
| os.chmod(cache_dir, 0o700) | ||
| except OSError: | ||
| pass | ||
| info = os.lstat(cache_dir) | ||
| if ( | ||
| not stat.S_ISDIR(info.st_mode) | ||
| or info.st_uid != os.getuid() | ||
| or info.st_mode & 0o077 | ||
| ): | ||
| raise OSError( | ||
| "Refusing to use discovery cache directory %r: it must be a " | ||
| "directory owned by the current user with no group or other " | ||
| "access" % cache_dir | ||
| ) |
There was a problem hiding this comment.
Calling os.chmod before verifying that the path is a real directory (and not a symlink) introduces a security vulnerability. If an attacker pre-creates the cache directory path as a symlink pointing to a sensitive directory owned by the current user, os.chmod will follow the symlink and modify the permissions of that target directory.
To prevent this, we should:
- Create the directory with restrictive permissions (
mode=0o700) from the start inos.makedirs. - Perform the
os.lstatcheck before callingos.chmodto ensure the path is a real directory owned by the current user. - Only call
os.chmodif the directory exists and has incorrect permissions, and then re-verify.
Additionally, we should use f-strings for string interpolation per the general rules.
os.makedirs(cache_dir, mode=0o700, exist_ok=True)
if hasattr(os, "getuid"):
info = os.lstat(cache_dir)
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid():
raise OSError(
f"Refusing to use discovery cache directory {cache_dir!r}: it must be a "
"directory owned by the current user with no group or other "
"access"
)
if info.st_mode & 0o077:
try:
os.chmod(cache_dir, 0o700)
except OSError:
pass
info = os.lstat(cache_dir)
if info.st_mode & 0o077:
raise OSError(
f"Refusing to use discovery cache directory {cache_dir!r}: it must be a "
"directory owned by the current user with no group or other "
"access"
)References
- Prefer using f-strings for string interpolation in Python.
The file based discovery cache computes its path as
os.path.join(tempfile.gettempdir(), FILENAME)with a fixedFILENAME. On a multi-user host the system temporary directory (for example/tmpon Linux) is world-writable, and the fully predictable file name lets any local user pre-create, replace, read, or symlink the cache file. Those bytes are later read back and trusted as a discovery document, whoserootUrldecides where every subsequent authenticated request, including the caller's OAuth token, is sent, so a poisoned entry becomes credential theft and request/response MITM (CWE-377, CWE-59). The lookup runs by default:_retrieve_discovery_docchecks the cache before anything else andautodetect()returns the file cache wheneveroauth2client < 4is importable._get_cache_directory()now places the cache inside a per-user directory created with0o700and refuses to use it unless it is a real directory owned by the current user with no group or other access, so other local users can no longer read, write, or symlink it. The cache file name is unchanged, andoauth2client >= 4/ google-auth users (where the file cache is disabled) are unaffected.Verified with a new regression test in
tests/test_discovery_cache.py: the cache now lives in a0o700directory owned by the caller, and a symlink planted at the directory path is rejected.