Skip to content

store file-based discovery cache in a private per-user directory - #2841

Open
farkhalit wants to merge 1 commit into
googleapis:mainfrom
farkhalit:secure-discovery-cache-dir
Open

farkhalit wants to merge 1 commit into
googleapis:mainfrom
farkhalit:secure-discovery-cache-dir

Conversation

@farkhalit

Copy link
Copy Markdown

The file based discovery cache computes its path as os.path.join(tempfile.gettempdir(), FILENAME) with a fixed FILENAME. On a multi-user host the system temporary directory (for example /tmp on 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, whose rootUrl decides 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_doc checks the cache before anything else and autodetect() returns the file cache whenever oauth2client < 4 is importable.

_get_cache_directory() now places the cache inside a per-user directory created with 0o700 and 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, and oauth2client >= 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 a 0o700 directory owned by the caller, and a symlink planted at the directory path is rejected.

  • Make sure to open an issue as a bug/issue before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea
  • Ensure the tests and linter pass
  • Code coverage does not decrease (if any source code was changed)
  • Appropriate docs were updated (if necessary)

@farkhalit
farkhalit requested a review from a team as a code owner October 15, 2026 07:16
@product-auto-label product-auto-label Bot added the size: m Pull request size is medium. label Oct 15, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +62 to +79
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
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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:

  1. Create the directory with restrictive permissions (mode=0o700) from the start in os.makedirs.
  2. Perform the os.lstat check before calling os.chmod to ensure the path is a real directory owned by the current user.
  3. Only call os.chmod if 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
  1. Prefer using f-strings for string interpolation in Python.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size: m Pull request size is medium.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant