Skip to content

[Auth] Harden HF_TOKEN_PATH and HF_STORED_TOKENS_PATH to 0o600 / 0o700#4234

Merged
Wauplin merged 6 commits into
huggingface:mainfrom
JAE0Y2N:harden-token-file-perms
May 20, 2026
Merged

[Auth] Harden HF_TOKEN_PATH and HF_STORED_TOKENS_PATH to 0o600 / 0o700#4234
Wauplin merged 6 commits into
huggingface:mainfrom
JAE0Y2N:harden-token-file-perms

Conversation

@JAE0Y2N

@JAE0Y2N JAE0Y2N commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

huggingface_hub writes the user's HF token files at Python's default file modes (0o666 masked by umask 022 → 0o644 on macOS Terminal and most Linux defaults), leaving them world-readable. Two writer sites:

  1. src/huggingface_hub/_login.py:441-445Path.write_text(token) for the single active token at HF_TOKEN_PATH (~/.cache/huggingface/token).
  2. src/huggingface_hub/utils/_auth.py:157-167_save_stored_tokens writes the multi-account INI to HF_STORED_TOKENS_PATH (~/.cache/huggingface/stored_tokens). Worst case impact-wise — the file contains every named token the user has saved via huggingface-cli auth login --token (personal + work + organization).

Any local account or process that can traverse the home directory can recover these tokens and act as the user against the Hub API — pulling or pushing private models and datasets, running Inference Endpoint predictions on the user's credits, accessing private orgs.

This PR adds mode=0o700 to the parent mkdir and path.chmod(0o600) / path.parent.chmod(0o700) after each write so pre-existing installations converge to safe perms on the next save. The chmod calls are wrapped in try/except for Windows (no POSIX modes).

How to verify

A small Python reproducer that mirrors the pre-PR call shape:

from pathlib import Path

p = Path('/tmp/hf-perm-test/token')
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text('hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')
print('file mode (pre-PR):', oct(p.stat().st_mode & 0o777))
print('dir mode (pre-PR):', oct(p.parent.stat().st_mode & 0o777))

Pre-PR: file mode (pre-PR): 0o644 / dir mode (pre-PR): 0o755. After applying the PR: 0o600 / 0o700.

Industry baseline

GitHub CLI (~/.config/gh/hosts.yml), AWS CLI (~/.aws/credentials), Google Cloud SDK (~/.config/gcloud/credentials.db), Stripe CLI, PlanetScale CLI (keyring + 0o600 fallback), Pulumi (StoreCredentials uses lockedfile.Write at 0o600).

Related disclosure

Reported privately to security@huggingface.co per .well-known/security.txt on 2026-05-19. Filing the PR in parallel to give maintainers the ready-to-merge fix.


Note

Medium Risk
Touches credential persistence paths (HF_TOKEN_PATH/HF_STORED_TOKENS_PATH) and changes how secrets are written to disk; while small, mistakes could break login persistence or permissions across platforms.

Overview
Hardens on-disk token persistence by introducing _write_secret to write Hub tokens with restrictive permissions (directory 0o700, file 0o600) on POSIX systems.

The login flow now uses _write_secret instead of Path.write_text for HF_TOKEN_PATH, and stored-tokens saving is refactored to write the INI content via an in-memory buffer and _write_secret for HF_STORED_TOKENS_PATH, with best-effort chmod handling on Windows.

Reviewed by Cursor Bugbot for commit fca1960. Bugbot is set up for automated code reviews on this repo. Configure here.

huggingface_hub writes the user's HF token files at default Python file
modes (0o666 masked by umask 022 → 0o644 on macOS Terminal and most
Linux defaults), leaving them world-readable. Two sites:

1. src/huggingface_hub/_login.py:441-445 — Path.write_text(token) for
   the single active token at HF_TOKEN_PATH (~/.cache/huggingface/token).

2. src/huggingface_hub/utils/_auth.py:157-167 — _save_stored_tokens
   writes the multi-account INI to HF_STORED_TOKENS_PATH
   (~/.cache/huggingface/stored_tokens). Worst-case impact, since the
   file contains every named token the user has saved via
   `huggingface-cli auth login --token` (personal + work + org).

Any local account or process that can traverse the home directory can
recover these tokens and act as the user against the Hub API — pulling
or pushing private models and datasets, running Inference Endpoint
predictions on the user's credits, accessing private orgs.

This commit:
- Passes `mode=0o700` to the parent `mkdir` and `path.chmod(0o600)` /
  `path.parent.chmod(0o700)` after each write so pre-existing
  installations converge to safe perms on the next save.
- Wraps the chmod calls in try/except for Windows (no POSIX modes).

Mirrors industry-baseline credential-file handling (GitHub CLI, AWS CLI,
Google Cloud SDK, Stripe CLI, PlanetScale, Pulumi StoreCredentials).

Related disclosure: security@huggingface.co (per .well-known/security.txt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/huggingface_hub/_login.py Outdated
@Wauplin

Wauplin commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Hi @JAE0Y2N thanks for reporting and fixing this! This looks like a valid report indeed. Could you move the "write content to a secret file" logic into its own helper to avoid duplicating logic? (+fix the TOCTOU issue as described in #4234 (comment), though I agree it's a very limited vulnerability scope)

_SECRET_FILE_MODE = 0o600
_SECRET_DIR_MODE = 0o700

def _write_secret(path: Path, content: str) -> None:
    """Write content to path, restricting to owner-only on POSIX."""
    path.parent.mkdir(parents=True, exist_ok=True, mode=_SECRET_DIR_MODE)
    fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _SECRET_FILE_MODE)
    with os.fdopen(fd, "w") as f:
        f.write(content)
    try:
        path.chmod(_SECRET_FILE_MODE)  # fix perms if file already existed
        path.parent.chmod(_SECRET_DIR_MODE)  # fix perms if dir already existed
    except (OSError, NotImplementedError):
        pass

Per @Wauplin's review on huggingface#4234: extract the "write content to a secret
file" logic into a single helper to avoid duplicating mode-handling
between _login.py and utils/_auth.py, and use os.open(O_CREAT, 0o600)
so the file is atomically created at the restricted mode (closing the
brief window the previous write_text + chmod implementation had where
the file existed at 0o644 between create and chmod).

The helper still chmod()s the file and parent after writing to handle
the case where they pre-existed at looser perms — that part can't be
fixed by O_CREAT alone since it only sets the mode on creation, not on
overwrite of an existing file.

Wauplin's exact suggested helper used verbatim (with a comment block
explaining the TOCTOU fix and why the post-write chmod is still needed
for the pre-existing-file case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JAE0Y2N

JAE0Y2N commented May 19, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the fast review @Wauplin! Pushed b38123d with your suggested helper.

Changes in this push:

  • Added _write_secret(path, content) in utils/_auth.py using os.open(O_WRONLY | O_CREAT | O_TRUNC, 0o600) so the file is atomically created at the restricted mode — no brief window at 0o644 between create and chmod.
  • The post-write chmod is kept (wrapped in try/except (OSError, NotImplementedError)) so pre-existing files/dirs at looser perms get tightened on the next save. Comment in-code explains both halves.
  • _login.py now calls _write_secret(Path(constants.HF_TOKEN_PATH), token) — single line replacing the 8-line block.
  • utils/_auth.py::_save_stored_tokens renders the INI into a io.StringIO() via config.write(buf) then calls _write_secret(stored_tokens_path, buf.getvalue()).

Empirical verification on macOS 14:

file mode: 0o600
dir mode: 0o700
PASS: file=0o600 dir=0o700 verified

(Standalone reproducer ran the extracted helper against a fresh path under tempfile.TemporaryDirectory() and asserted the modes.)

Let me know if you'd like further tweaks!

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default mode and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b38123d. Configure here.

with os.fdopen(fd, "w") as f:
f.write(content)
try:
path.chmod(_SECRET_FILE_MODE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TOCTOU window persists for pre-existing files with loose permissions

Medium Severity

When the token file already exists with loose permissions (e.g., 0o644 from a pre-PR installation), the mode parameter of os.open is ignored — it only applies when O_CREAT actually creates a new file. The token is written to the still-world-readable file, and path.chmod(_SECRET_FILE_MODE) only runs afterward. Adding os.fchmod(fd, _SECRET_FILE_MODE) between os.open and f.write(content) would eliminate this window for pre-existing files.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b38123d. Configure here.

@Wauplin Wauplin May 19, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

not a problem IMO, let's not be too paranoiac

Comment thread src/huggingface_hub/utils/_auth.py Outdated
Comment thread src/huggingface_hub/utils/_auth.py Outdated
Comment thread src/huggingface_hub/_login.py Outdated
Comment thread src/huggingface_hub/utils/_auth.py Outdated

@Wauplin Wauplin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good, thank you!

(will wait a bit before merging in case someone else has some feedback)

@bot-ci-comment

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@codecov

codecov Bot commented May 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.23529% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.10%. Comparing base (1daa48b) to head (fca1960).
⚠️ Report is 341 commits behind head on main.

Files with missing lines Patch % Lines
src/huggingface_hub/utils/_auth.py 87.50% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4234      +/-   ##
==========================================
+ Coverage   75.00%   77.10%   +2.10%     
==========================================
  Files         145      171      +26     
  Lines       13978    19543    +5565     
==========================================
+ Hits        10484    15069    +4585     
- Misses       3494     4474     +980     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Wauplin Wauplin merged commit 817e397 into huggingface:main May 20, 2026
17 checks passed
@huggingface-hub-bot

Copy link
Copy Markdown
Contributor

This PR has been shipped as part of the v1.16.0 release.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants