Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,71 @@

All notable changes to this project are documented in this file.

## [v1.7.1] - Unreleased

Changes since [v1.7.0](https://github.com/gitonomy/gitlib/releases/tag/v1.7.0).

1.7.1 has the security fixes of 2.0 that break nothing of 1.6. Where 2.0 refuses every option,
1.7.1 only refuses what reads or writes a file: the other options of `git log` as revisions stay
deprecated, and work. The checks of a URL keep returning `false`, and clones keep throwing a
`RuntimeException`, where 2.0 throws an `InvalidArgumentException`.

### Security

- Refuse `--output` as a revision, which made git write a file: `getRevision('--output=/path')`
and everything reading a revision through it, `getLog()`, `getDiff()`, `getMergeBase()` or
`Branch::moveTo()` for instance.
- Refuse the options of `git blame` reading or writing a file as a revision:
`getBlame('--contents=/path', 'README.md')` returned the lines of any file, and the error of
`--ignore-revs-file` or `-S` printed its first line.
- Refuse a URL starting with a dash, which git read as an option:
`Admin::isValidRepository('--upload-pack=...')` ran the command in a repository with a remote,
`Admin::isValidRepositoryAndBranch()` and the clones when the second argument was a
repository. A path may start with a dash: `init()` and the clones end the options of git with
`--`, as `cloneFrom($url, '--upload-pack=...')` ran the command too.
- Refuse a hash starting with a dash in `ReferenceBag::createBranch()` and `createTag()`:
`createBranch('foo', '-d')` deleted the branch. So do the branch and the start point of
`WorkingCopy::checkoutNewBranch()`.
- Refuse an `fd::` URL, which makes git talk through a file descriptor it inherits from PHP, like
the socket of a database, and wait on it.
- Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like
`ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an
option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash
is refused too: git passes it to the command of `core.gitProxy`.
- Hide the credentials of URLs (`https://user:token@host`), a password holding a `#` or a `?`
included, in the messages of `ProcessException` and of failed clones, in the logs, and in the
messages of the exceptions of Symfony Process, a timeout for instance, which keep their class.
From PHP 8.2, the traces hide them too (`#[\SensitiveParameter]`).
- Refuse a hook name leading out of the hooks directory: `Hooks::set('../../file', ...)`,
`remove('../config')` and `get('../config')` wrote, deleted and read files of the repository.
The names staying in the directory, like `post-receive.d/notify`, still work.
- Fix `Hooks` writing through a symbolic link to a missing file, out of the hooks directory:
`has()` returned `false` for it, and `set()` followed it.
- Ignore `core.fsmonitor`: git ran the command the configuration of a repository names there to
read the working tree, and even to diff a commit.
- Fix deleting a symbolic reference, which deleted the branch it points to instead:
`ReferenceBag::delete('refs/remotes/origin/HEAD')` deleted `origin/main`.
- Fix `ReferenceBag::delete('HEAD')` deleting a detached `HEAD`, which left no repository: it
throws an `InvalidArgumentException`.
- Require `symfony/process` 5.4.51, 6.4.33, 7.3.11, 7.4.5 or 8.0.5 and later: the older versions
escape the arguments wrongly on Windows (CVE-2024-51736, CVE-2026-24739).

### Not fixed

These fixes of 2.0 would break code written for 1.6. An application giving gitlib values it can't
trust has to check them itself:

- `ext::` URLs stay accepted, as an application may use this transport on purpose. git refuses
them by default since 2.12: before that, `ext::sh -c ...` runs the command.
- The replacements of objects (`git replace`, `refs/replace/`) stay read, as `git replace --graft`
relies on them: whoever can push such a reference changes the message of a commit, or the
content of a blob, gitlib reads for a hash. Set `GIT_NO_REPLACE_OBJECTS` to `1` in the
`environmentVariables` of `CliDriver` to ignore them.
- `WorkingCopy::checkout()` still takes a path or an option: `checkout('README.md')` discards the
changes of the file, and `checkout('--force')` all of them.
- Moving a symbolic reference, with `Branch::moveTo()` or `ReferenceBag::update()`, still moves
the branch it points to.

## [v1.7.0] - 2026-10-15

Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0).
Expand Down Expand Up @@ -100,5 +165,6 @@ _Earlier versions are not documented here. See the
[GitHub releases](https://github.com/gitonomy/gitlib/releases) and
[commit history](https://github.com/gitonomy/gitlib/commits/v1.6.0) for prior changes._

[v1.7.1]: https://github.com/gitonomy/gitlib/compare/v1.7.0...v1.7.1
[v1.7.0]: https://github.com/gitonomy/gitlib/compare/v1.6.0...v1.7.0
[v1.6.0]: https://github.com/gitonomy/gitlib/releases/tag/v1.6.0
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"ext-pcre": "*",
"symfony/deprecation-contracts": "^2.5 || ^3",
"symfony/polyfill-mbstring": "^1.7",
"symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0"
"symfony/process": "^5.4.51 || ^6.4.33 || ~7.3.11 || ^7.4.5 || ^8.0.5"
},
"require-dev": {
"ext-fileinfo": "*",
Expand Down
18 changes: 18 additions & 0 deletions doc/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ option is the second argument, and its `logger`, `command`,
`CliDriver`. Setting its `debug` option to `false` is deprecated: gitlib
2.0 always throws when git fails.

Ignore the replacements of objects
----------------------------------

git reads the replacements of a repository (`git replace`): whoever can
push one changes the message of a commit, or the content of a blob, you
read for a hash. For a repository you don't trust, ignore them:

```php
use Gitonomy\Git\Driver\Cli\CliDriver;
use Gitonomy\Git\Repository;

$repository = new Repository('/path/to/repo', driver: new CliDriver(
environmentVariables: ['GIT_NO_REPLACE_OBJECTS' => '1'],
));
```

gitlib 2.0 always ignores them.

Test if a repository is bare
----------------------------

Expand Down
28 changes: 19 additions & 9 deletions src/Gitonomy/Git/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@

use Gitonomy\Git\Driver\Cli\CliDriver;
use Gitonomy\Git\Exception\RuntimeException;
use Gitonomy\Git\Util\Credentials;
use Gitonomy\Git\Util\LegacyOverride;
use Gitonomy\Git\Util\RemoteUrl;

/**
* Administration class for Git repositories.
Expand Down Expand Up @@ -68,14 +70,18 @@ public static function init($path, $bare = true, array $options = [])
*
* @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepository() instead
*/
public static function isValidRepository($url, array $options = [])
public static function isValidRepository(#[\SensitiveParameter] $url, array $options = [])
{
trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class);

if (null !== RemoteUrl::unsafeReason((string) $url)) {
return false;
}

// Unlike Repository::isValidRepository(), in the working directory of PHP, where a relative path may name a remote.
$process = CliDriver::fromOptions($options)->createProcess('ls-remote', [$url]);

$process->run();
Credentials::runProcess($process);

return $process->isSuccessFul();
}
Expand All @@ -96,15 +102,19 @@ public static function isValidRepository($url, array $options = [])
*
* @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepositoryAndBranch() instead
*/
public static function isValidRepositoryAndBranch($url, $branchName, array $options = [])
public static function isValidRepositoryAndBranch(#[\SensitiveParameter] $url, $branchName, array $options = [])
{
trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class);

if (null !== RemoteUrl::unsafeReason((string) $url)) {
return false;
}

// Unlike Repository::isValidRepositoryAndBranch(), "release" matches "feature/release", and "rel" "release",
// in the working directory of PHP.
$process = CliDriver::fromOptions($options)->createProcess('ls-remote', ['--heads', $url, $branchName]);

$process->run();
Credentials::runProcess($process);
$processOutput = $process->getOutput();

return $process->isSuccessFul() && strpos($processOutput, $branchName) !== false;
Expand All @@ -122,7 +132,7 @@ public static function isValidRepositoryAndBranch($url, $branchName, array $opti
*
* @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() instead
*/
public static function cloneTo($path, $url, $bare = true, array $options = [])
public static function cloneTo($path, #[\SensitiveParameter] $url, $bare = true, array $options = [])
{
trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom');

Expand All @@ -144,7 +154,7 @@ public static function cloneTo($path, $url, $bare = true, array $options = [])
*
* @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead
*/
public static function cloneBranchTo($path, $url, $branch, $bare = true, $options = [])
public static function cloneBranchTo($path, #[\SensitiveParameter] $url, $branch, $bare = true, $options = [])
{
trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom');

Expand All @@ -167,7 +177,7 @@ public static function cloneBranchTo($path, $url, $branch, $bare = true, $option
*
* @deprecated since gitonomy/gitlib 1.7, use Repository::mirrorFrom() instead
*/
public static function mirrorTo($path, $url, array $options = [])
public static function mirrorTo($path, #[\SensitiveParameter] $url, array $options = [])
{
trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class);

Expand All @@ -186,7 +196,7 @@ public static function mirrorTo($path, $url, array $options = [])
*
* @deprecated since gitonomy/gitlib 1.7, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead
*/
public static function cloneRepository($path, $url, array $args = [], array $options = [])
public static function cloneRepository($path, #[\SensitiveParameter] $url, array $args = [], array $options = [])
{
trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class);

Expand All @@ -195,7 +205,7 @@ public static function cloneRepository($path, $url, array $args = [], array $opt
return new Repository($path, $options);
}

private static function cloneWith($path, $url, array $args, array $options)
private static function cloneWith($path, #[\SensitiveParameter] $url, array $args, array $options)
{
// Like in 1.6, a subclass overriding cloneRepository() clones the repository.
if (LegacyOverride::isOverridden(static::class, self::class, 'cloneRepository')) {
Expand Down
3 changes: 3 additions & 0 deletions src/Gitonomy/Git/Blame.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Gitonomy\Git\Blame\Line;
use Gitonomy\Git\Exception\InvalidArgumentException;
use Gitonomy\Git\Parser\BlameParser;
use Gitonomy\Git\Util\LeadingDash;

/**
* @author Alexandre Salomé <alexandre.salome@gmail.com>
Expand Down Expand Up @@ -167,6 +168,8 @@ public function getLines()
return $this->lines;
}

LeadingDash::assertNoBlameFileOption($this->revision->getRevision());

$args = ['-p'];

if (null !== $this->lineRange) {
Expand Down
43 changes: 30 additions & 13 deletions src/Gitonomy/Git/Driver/Cli/CliDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
namespace Gitonomy\Git\Driver\Cli;

use Gitonomy\Git\Exception\RuntimeException;
use Gitonomy\Git\Util\Credentials;
use Gitonomy\Git\Util\RemoteUrl;
use Psr\Log\LoggerInterface;
use Symfony\Component\Process\Process;

Expand Down Expand Up @@ -77,7 +79,8 @@ public function getOptions(): array
*/
public function init(string $path): void
{
$this->initRepository(['-q', $path]);
// --: a path may start with a dash.
$this->initRepository(['-q', '--', $path]);
}

/**
Expand All @@ -87,15 +90,15 @@ public function init(string $path): void
*/
public function initBare(string $path): void
{
$this->initRepository(['-q', '--bare', $path]);
$this->initRepository(['-q', '--bare', '--', $path]);
}

/**
* @internal For Repository::cloneFrom()
*
* @throws RuntimeException
*/
public function cloneFrom(string $url, string $path, ?string $branch): void
public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void
{
$this->cloneRepository($url, $path, null === $branch ? [] : ['--branch', $branch]);
}
Expand All @@ -105,7 +108,7 @@ public function cloneFrom(string $url, string $path, ?string $branch): void
*
* @throws RuntimeException
*/
public function cloneBareFrom(string $url, string $path, ?string $branch): void
public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void
{
$this->cloneRepository($url, $path, null === $branch ? ['--bare'] : ['--branch', $branch, '--bare']);
}
Expand All @@ -115,30 +118,38 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void
*
* @throws RuntimeException
*/
public function mirrorFrom(string $url, string $path): void
public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): void
{
$this->cloneRepository($url, $path, ['--mirror']);
}

/**
* @internal For Repository::isValidRepository()
*/
public function isValidRepository(string $url): bool
public function isValidRepository(#[\SensitiveParameter] string $url): bool
{
if (null !== RemoteUrl::unsafeReason($url)) {
return false;
}

$process = $this->createLsRemoteProcess([], $url, []);
$process->run();
Credentials::runProcess($process);

return $process->isSuccessful();
}

/**
* @internal For Repository::isValidRepositoryAndBranch()
*/
public function isValidRepositoryAndBranch(string $url, string $branch): bool
public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool
{
if (null !== RemoteUrl::unsafeReason($url)) {
return false;
}

// git lists the branches whose name ends with the pattern: "release" matches "feature/release".
$process = $this->createLsRemoteProcess(['--heads'], $url, ['refs/heads/'.$branch]);
$process->run();
Credentials::runProcess($process);
if (!$process->isSuccessful()) {
return false;
}
Expand All @@ -159,13 +170,19 @@ public function isValidRepositoryAndBranch(string $url, string $branch): bool
*
* @throws RuntimeException
*/
public function cloneRepository(string $url, string $path, array $args): void
public function cloneRepository(#[\SensitiveParameter] string $url, string $path, array $args): void
{
$process = $this->createProcess('clone', array_merge(['-q'], $args, [$url, $path]));
$process->run();
$reason = RemoteUrl::unsafeReason($url);
if (null !== $reason) {
throw new RuntimeException(sprintf('Error while initializing repository: %s', $reason));
}

// --: a path may start with a dash.
$process = $this->createProcess('clone', array_merge(['-q'], $args, ['--', $url, $path]));
Credentials::runProcess($process);

if (!$process->isSuccessful()) {
throw new RuntimeException(sprintf('Error while initializing repository: %s', $process->getErrorOutput()));
throw new RuntimeException(sprintf('Error while initializing repository: %s', Credentials::redact($process->getErrorOutput())));
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/Gitonomy/Git/Exception/ProcessException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Gitonomy\Git\Exception;

use Gitonomy\Git\Util\Credentials;
use Symfony\Component\Process\Process;

/**
Expand All @@ -11,16 +12,20 @@ class ProcessException extends RuntimeException implements GitExceptionInterface
{
protected $process;

/**
* The message hides the credentials of the URLs, unlike getErrorOutput()
* and getOutput().
*/
public function __construct(Process $process)
{
parent::__construct(
parent::__construct(Credentials::redact(
"Error while running git command:\n".
$process->getCommandLine()."\n".
"\n".
$process->getErrorOutput()."\n".
"\n".
$process->getOutput()
);
));

$this->process = $process;
}
Expand Down
Loading
Loading