Securing pytest's tmp_path base directory

Solution space for issue #13669 (CVE-2025-71176) — bridging the merged minimal fix (#14343) and the proposed comprehensive fix (#14279). Design exploration for maintainer discussion; nothing here is implemented.

Where things stand

Residual gaps after 9.0.3

#14343 stats/chmods the rootdir with follow_symlinks=False and rejects a symlinked rootdir outright. What it deliberately leaves open:

✗ Open · Squat DoS

Any local user can pre-create /tmp/pytest-of-<victim>; the victim's runs then fail the ownership check with an OSError they cannot fix themselves. Acknowledged verbatim in the shipped code comment ("give the prankster a good scolding").

◐ Partial · TOCTOU window

Checks are path-based (stat → S_ISLNK → use), not fd-based. A race between check and use is narrowed, not eliminated — vs. #14279's O_NOFOLLOW|O_DIRECTORY + fstat/fchmod on a held fd.

✗ Open · The anti-pattern itself

A predictable name in world-writable /tmp remains the architecture. Every check added is cataloguing "bad things someone might do" instead of removing the shared-parent trust problem (orlitzky's core objection).

◐ Quiet edges · Coverage asymmetries

Where os.supports_follow_symlinks lacks support, the code silently falls back to following symlinks. The --basetemp branch bypasses all hardening. Checks are skipped entirely on Windows/Emscripten (mostly moot — see platform matrix below).

Constraints any bridge proposal must satisfy

Collected from maintainer feedback across #13669, #14279, and #14343 — these are the objections that decided the outcome last time:

bluetech's four review objections

  • No warnings users can't act on (avoids_symlink_attacks case).
  • Don't duplicate shutil.rmtree's own symlink refusal without justification.
  • Don't re-verify what mkdtemp already guarantees without justification.
  • Never scan all of /tmp per cleanup — the subdirectory existed precisely to bound that scan (2015, #105).

Retention & discoverability (webknjaz, Ronny)

  • "Last N runs" retention (tmp_path_retention_count, default 3) needs a way to find prior runs — pure randomness breaks GC.
  • Humans navigate to /tmp/pytest-of-me/pytest-current; that UX is an intentional feature.
  • Ronny's stated unlock: "if we provide a command to list/gc tmpdirs we are in a position to use non-obvious dirnames and avoid subdirs."

Compatibility budget

  • The template {temproot}/pytest-of-{user}/pytest-{num}/{testname}/ is documented public contract (tmp_path.rst); changing it needs a major release + deprecation story.
  • pytest-current symlinks, .lock files, and garbage-* rename-then-delete are incidental (undocumented) — changeable, but in-tree tests assert on some of them.
  • Breaking changes were the stated reason #14279 lost; "minimal until proven otherwise."

Threat-model honesty

  • Defend the user from other users, not from themselves (bluetech's framing).
  • Multi-user is real: shared dev clusters, web hosts, CI multi-tenancy, low-privilege service accounts (orlitzky, pgunn, The-Compiler).
  • But pytest is usually a dev tool on single-user boxes (Ronny) — proportionality matters, and perfect is not on the menu.

The approaches, compared

Marks pair a symbol with a word: ✓ closed · ◐ partial · ✗ open.

Security and cost profile of each candidate. "A+E", "D+E" etc. compose — E is an enabling layer, not a competitor.
Aspect A · Harden in place
fd-based checks + random fallback on squat
B · Flat random
#14279: pytest-of-user-XXXX/ per session
C · Nested random
hardened parent / random session dirs
D · Relocate base
XDG / user-private dir chain
Symlink attack ✓ closed (fd-based, no follow) ✓ closed (mkdtemp, by construction) ✓ closed (parent fd-verified + random children) ✓ closed (no shared parent at all)
TOCTOU race ✓ closed at rootdir (held fd) ✓ closed ✓ closed (if parent ops use dir_fd) ✓ closed (parent trusted by OS construction)
Squat DoS ◐ degraded — squat detected → warn + fall back to mkdtemp rootdir; run proceeds ✓ closed ◐ parent squattable — needs A's fallback to close ✓ closed where a private base exists; fallback path inherits its own column
Predictable-name anti-pattern ✗ remains (verified, but still shared-/tmp) ✓ removed ◐ parent remains ✓ removed — predictable name becomes safe inside a user-owned base
Breaking change ✓ none (fallback path only under attack) ✗ yes — documented template, pytest-current, per-session layout ◐ moderate — session dir names change; parent + nesting kept ◐ moderate — location changes, structure can stay identical
Findability / UX ✓ unchanged ✗ needs help — prefix + mtime sort, or E ◐ mostly kept — grouping dir survives; numbered names don't ✓ kept — stable, documentable per-platform path; pytest-current can stay
Retention / GC cost ✓ unchanged (scoped scan) ✗ temproot scan — bluetech's #4, unless E's registry ✓ scoped scan inside parent ✓ scoped scan; XDG_CACHE variant means pytest owns lifetime (it already does retention)
Complexity ◐ moderate — fd plumbing + fallback branch ◐ large — ~580 additions in #14279 ◐ large — everything in A plus layout migration ◐ moderate-large — base-selection chain + per-platform docs
Champion / origin bluetech's floor, extended; fallback idea bridges orlitzky's DoS point orlitzky, #14279 (laurac8r); The-Compiler opt. 2 nicoddemus, The-Compiler opt. 3 (with his own caveat) nirs (XDG_RUNTIME_DIR), The-Compiler opt. 4

A · Harden in place, degrade gracefully

/tmp/pytest-of-laura/          ← verified via O_NOFOLLOW fd
  pytest-0/  pytest-1/  pytest-current@
# under squat attack only:
/tmp/pytest-of-laura-x7Qz3a/   ← mkdtemp fallback + warning
  • Open rootdir with O_NOFOLLOW|O_DIRECTORY, verify with fstat, tighten with fchmod, resolve children via dir_fd — the TOCTOU window closes because the fd pins the object.
  • The new move: on ownership failure, don't hard-fail — fall back to a mkdtemp-random rootdir and warn. Converts the DoS into a degraded-but-working run. Directly answers orlitzky's "I can't fix it!" scenario.
  • Ships in a 9.x minor; zero behavior change for unattacked users. Unix-only plumbing (dir_fd unsupported on Windows) mirrors the existing supports_follow_symlinks gating — and Windows was never exposed.

B · Flat random per session (#14279's shape)

/tmp/pytest-of-laura-BorMuC/   ← session 1 (mkdtemp)
/tmp/pytest-of-laura-6YmIOO/   ← session 2
/tmp/pytest-of-laura-guESQy/   ← session 3
  • Strongest single-mechanism fix: nothing predictable to squat or symlink; battle-tested tempfile.mkdtemp does the security work.
  • Costs are exactly the ones that killed #14279: documented-template break, pytest-current gone, retention GC must scan the shared temproot (bluetech #4), more /tmp noise (nicoddemus).
  • Viable only at a major release, and only with the discovery/GC gap answered — i.e., B really means B+E.

C · Hardened parent, random children

/tmp/pytest-of-laura/          ← still predictable (squattable)
  pytest-1-N93sIT/  pytest-2-iKTFoY/  pytest-3-EUIRYH/
  • Keeps the grouping dir (GC scan stays scoped, /tmp stays tidy) and randomizes session dirs beneath it.
  • Honest assessment: inside a verified 0700 parent, an attacker can't write — so randomizing children adds little security beyond A. The parent squat (DoS) is untouched; The-Compiler's own caveat ("same issue pushed a level down") applies whenever parent verification is skipped or wrong.
  • Buys concurrency robustness (no numbered-suffix races under xdist/parallel sessions) more than attacker resistance. Pay the layout break only if that's the goal.

D · Move out of shared /tmp entirely

Linux:  $XDG_RUNTIME_DIR/pytest/pytest-0/   (0700, spec-guaranteed)
   or:  $XDG_CACHE_HOME/pytest/tmp/pytest-0/ (durable; pytest GCs)
macOS:  $TMPDIR → /var/folders/…/T/pytest-of-laura/   (already private)
Win:    %LOCALAPPDATA%\Temp\pytest-of-laura\          (already private)
else:   fall back to A (hardened /tmp scheme)
  • Dissolves the problem instead of defending it: predictable names are safe inside a parent only the user can write. macOS/Windows already work this way — the vulnerability is Unix-shared-/tmp-specific.
  • XDG_RUNTIME_DIR caveats: tmpfs (RAM-backed, ~10% RAM cap) — risky for large artifacts; 6-hour reaping rules; absent under cron, plain su, minimal containers. XDG_CACHE_HOME flips the tradeoff: durable disk, always present, but pytest owns cleanup (it already has retention) and NFS homes can hurt (locking, perf, quotas).
  • Needs a precedence story (--basetemp › PYTEST_DEBUG_TEMPROOT › ini opt-in › platform default) and a deprecation cycle for the documented location — the structure beneath can stay byte-identical.

E · Discovery & GC command — the enabling layer, not a competitor

pytest --tmpdirs list / --tmpdirs gc / --tmpdirs latest (spelling negotiable), backed by a small per-user registry (e.g. under $XDG_STATE_HOME/pytest/) recording recent rootdirs.

  • Answers Ronny's condition verbatim: a list/gc command is what makes "non-obvious dirnames" acceptable.
  • Answers webknjaz: retention reads the registry, not directory-name arithmetic — GC without any shared-temproot scan (bluetech #4 resolved for B too).
  • Replaces pytest-current where symlinks are unsafe/absent (--tmpdirs latest prints the path); keeps it where safe (inside D's private base).
  • Prior art for the pattern: go clean -cache + go env GOCACHE — findable via a documented command rather than a guessable path.

Recommended bridge: stage it

Phase 1 9.x minor — A + E groundwork

  • fd-based rootdir verification (close the TOCTOU residue) — no visible change.
  • Squat → warn + mkdtemp fallback (close the DoS as a felt problem) — visible only under attack.
  • Introduce the registry + --tmpdirs command; document the threat model and the --basetemp bypass asymmetry.
  • Each item answers a named objection without touching the documented path template — "minimal until proven otherwise" is respected.

Phase 2 next major — D by default, B semantics as fallback

  • Per-platform private base (macOS/Windows: unchanged; Linux: XDG chain with an ini opt-out) — keeps numbered dirs, retention, and pytest-current because the parent is now trustworthy.
  • Where no private base exists (bare containers, exotic Unix), use mkdtemp-random rootdirs in /tmp with registry-driven discovery/GC (B+E).
  • Deprecation cycle for the documented template; changelog + migration note for scripts that hardcode pytest-of-* paths.

Why this split: Phase 1 is shippable without re-litigating UX (it changes nothing visible), while Phase 2 preserves the UX everyone defended — predictable, navigable paths — by relocating them somewhere predictability is harmless. The sprint discussion can accept, reorder, or strike each line independently.

Best practices & prior art worth citing

Use the battle-tested primitive

tempfile.mkdtemp is atomic and race-free by construction; hand-rolled check sequences must catalogue every attack (hardlinks-to-dirs are POSIX implementation-defined; bind mounts defeat link counting). ssh-agent's /tmp/ssh-XXXXXXXXXX/ is the canonical shape. npm shipped the same predictable-name bug as CVE-2013-4116.

Warnings must be actionable

Both reviewers converged on this: don't warn about avoids_symlink_attacks when the user can do nothing. Either bail out with a clear error, degrade with a stated consequence, or stay silent and document.

Bound your scans

The 2015 subdirectory (#105) exists because suffix-scanning all of /tmp hurt once. Any design that reintroduces a shared-temproot scan per session will meet the same objection — scope scans to an owned directory or replace them with a registry.

Defense-in-depth must earn its lines

Re-verifying mkdtemp output or duplicating shutil.rmtree's symlink refusal reads as noise to reviewers unless each check names the concrete scenario it stops (e.g. exotic umask/ACL → one unconditional chmod 0700 is enough).

Platform matrix, stated up front

The bug class exists only where a world-writable parent is shared: Linux/BSD /tmp. macOS (/var/folders, 0700 per user) and Windows (per-user %TEMP% ACLs) are structurally immune — so Unix-only hardening code (dir_fd, O_NOFOLLOW) costs nothing where it's unavailable.

Advisory hygiene

The GHSA says "fixed in 9.0.3" while the issue thread disputes it. Whatever the sprint decides, feed the outcome back into the advisory (annotate residual DoS as accepted risk, or re-scope the CVE) — the June friction came from that gap, not from the code.

Open questions for the sprint