Skip to content

Create the batch temp directory privately and fail if it exists - #127

Merged
sirbrillig merged 3 commits into
trunkfrom
fix/secure-batch-temp-dir
Aug 24, 2026
Merged

Create the batch temp directory privately and fail if it exists#127
sirbrillig merged 3 commits into
trunkfrom
fix/secure-batch-temp-dir

Conversation

@sirbrillig

Copy link
Copy Markdown
Owner

Fixes #120.

The batch scan built its temp root from uniqid(), ignored the mkdir() return value, and created every directory with the default 0777 & ~umask:

$tempDir = sys_get_temp_dir() . '/phpcs-changed-' . uniqid();
mkdir($tempDir);

uniqid() is derived from the current microtime, so the path is guessable. On a shared machine (CI runner, shared build box) another local user can pre-create the predicted directory with new and old as symlinks; because the failed mkdir() was ignored, the batch then writes git or svn file contents through those symlinks, or tampers with the copies between creation and the single phpcs run that gates a commit. Even with no attacker present, the 0777 directories exposed copies of the scanned source to other local users for the duration of the scan. CWE-377 / CWE-378.

Changes

Both batch entry points now share a createTempDir() helper that names the directory from random_bytes(16), creates it with mode 0700, and throws a ShellException if mkdir() fails rather than continuing into a directory it does not own. writeTempFile()'s nested mkdir($dir, 0777, true) is likewise 0700 and checked.

The temp files themselves are still created by shell redirection and so land at the umask default; the 0700 parent is what keeps them private.

Testing

Two tests in GitWorkflowTest, driven through the existing mocked-shell batch path:

  • testFullGitWorkflowCreatesBatchTempDirsPrivateToTheCurrentUser — asserts every directory in the batch tree is 0700.
  • testFullGitWorkflowNamesTheBatchTempDirUnpredictably — asserts the root matches phpcs-changed-[0-9a-f]{32}.

The batch deletes the whole tree in a finally before returning, so the directories cannot be inspected after the fact. TestShell/WindowsTestShell now record each temp directory's permissions as writeCommandOutputToFile is called, which is the point where the tree exists. Both tests fail on trunk and pass with this change.

composer precommit passes: 165 tests / 270 assertions, phpcs clean, psalm no errors.

Follow-up, not fixed here

Temp paths are built as $tempDir . '/new/' . ltrim($fileName, '/'), and file names reach that point exactly as typed on the command line — nothing calls realpath() on them. A relative argument containing .. therefore places the temp copy outside the batch directory:

/tmp/phpcs-changed-abc/new/../../evil.php

which resolves to /tmp/evil.php — outside the tree cleanupTempDir() removes, and able to clobber an existing file there. It is not a privilege boundary, since the user is scanning their own files as themselves, but it writes and leaks files outside the temp dir. Filed separately.

The batch scan built its temp root from uniqid(), ignored the mkdir()
return value, and created every directory with the default 0777 mask.

uniqid() is derived from the current microtime, so the path is
guessable. On a shared machine another local user could pre-create the
predicted directory with 'new' and 'old' as symlinks; because the
failed mkdir() was ignored, the batch would then write git or svn file
contents through those symlinks, or tamper with the copies between
creation and the single phpcs run that gates a commit. Even with no
attacker present, the 0777 directories exposed copies of the scanned
source to other local users for the duration of the scan.

Name the directory from random_bytes() instead, create it and its
nested directories with mode 0700, and treat a failed mkdir() as fatal
rather than continuing into a directory we do not own.

Note that the temp files themselves are still created by shell
redirection and so land at the umask default; the 0700 parent is what
keeps them private.

Fixes #120
assertMatchesRegularExpression() was added in PHPUnit 9.1, but the test
matrix runs PHP 7.2 on PHPUnit 8.5, where it does not exist and the
test errored.

Assert on preg_match() directly rather than switching to assertRegExp():
that works on every PHPUnit version composer.json allows (^6.4 through
^9.5) and does not depend on an assertion PHPUnit 10 removes.
Windows ignores mkdir()'s mode argument and reports 0777 for every
directory, since access there is governed by inherited ACLs rather than
POSIX permission bits, so the assertion failed on the Windows matrix.

Skip that test on Windows using the same PHP_OS check UnixShellTest
already uses. The 0700 mode is what protects the scanned files' temp
copies on the multi-user Unix machines the issue is about, so the
assertion is still worth making where it applies.

The temp directory naming test is platform independent and keeps
running everywhere.
@sirbrillig
sirbrillig merged commit 7d19b01 into trunk Aug 24, 2026
24 checks passed
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.

Batch scan creates temp dir with predictable name, unchecked mkdir, 0777 mode

1 participant