Skip to content

Commit 4a0ea8b

Browse files
Merge branch 'release/7.3.0'
2 parents dd4f8fd + 0e936f6 commit 4a0ea8b

23 files changed

Lines changed: 1046 additions & 41 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
- uses: actions/checkout@v7
3030

3131
- name: Setup node
32-
uses: actions/setup-node@v6
32+
uses: actions/setup-node@v7
3333
with:
3434
node-version-file: '.nvmrc'
3535

@@ -110,7 +110,7 @@ jobs:
110110
- uses: actions/checkout@v7
111111

112112
- name: Setup node
113-
uses: actions/setup-node@v6
113+
uses: actions/setup-node@v7
114114
with:
115115
node-version-file: '.nvmrc'
116116

legacy_hook/composer.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use App\Utility\RepositoryUrlUtility;
8+
use Doctrine\DBAL\Schema\Schema;
9+
use Doctrine\Migrations\AbstractMigration;
10+
11+
/**
12+
* Quarantined entries recorded the host of the clone url, while the check that
13+
* blocked them keys on the host of the composer.json url. Recompute the domain
14+
* of the existing rows from the push event they carry, so approving them
15+
* allowlists the host that is actually consulted.
16+
*/
17+
final class Version20260804120000 extends AbstractMigration
18+
{
19+
public function getDescription(): string
20+
{
21+
return 'Set the quarantined domain to the host of the composer.json url';
22+
}
23+
24+
public function up(Schema $schema): void
25+
{
26+
$rows = $this->connection->fetchAllAssociative('SELECT id, domain, serialized_push_event FROM documentation_quarantine');
27+
foreach ($rows as $row) {
28+
try {
29+
$pushEvent = json_decode((string) $row['serialized_push_event'], true, 512, JSON_THROW_ON_ERROR);
30+
} catch (\JsonException) {
31+
continue;
32+
}
33+
$urlToComposerFile = (string) ($pushEvent['urlToComposerFile'] ?? '');
34+
if ('' === $urlToComposerFile) {
35+
continue;
36+
}
37+
$domain = RepositoryUrlUtility::getNormalizedDomain($urlToComposerFile);
38+
if ('' === $domain || $domain === $row['domain']) {
39+
continue;
40+
}
41+
$this->connection->update('documentation_quarantine', ['domain' => $domain], ['id' => $row['id']]);
42+
}
43+
}
44+
45+
public function down(Schema $schema): void
46+
{
47+
$this->throwIrreversibleMigrationException('The previously stored domain can not be restored, it was derived from the clone url.');
48+
}
49+
50+
public function isTransactional(): bool
51+
{
52+
return false;
53+
}
54+
}

package-lock.json

Lines changed: 19 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Controller/AdminInterface/Docs/KnownRepositoryDomainsController.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use App\Entity\KnownRepositoryDomain;
1515
use App\Enum\DocumentationRenderingTrigger;
16+
use App\Exception\DocumentationRenderingRequestDeclinedException;
1617
use App\Form\KnownDomainCreateType;
1718
use App\Form\KnownDomainDeleteType;
1819
use App\Repository\KnownRepositoryDomainRepository;
@@ -69,7 +70,13 @@ public function new(Request $request): Response
6970
if ($data->isAllowed()) {
7071
foreach ($this->documentationQuarantineService->findAllByDomain($data->getDomain()) as $documentationQuarantine) {
7172
$pushEvent = $documentationQuarantine->getPushEvent();
72-
$this->renderDocumentationService->requestDocumentationRendering($pushEvent, DocumentationRenderingTrigger::WEB);
73+
try {
74+
$this->renderDocumentationService->requestDocumentationRendering($pushEvent, DocumentationRenderingTrigger::WEB);
75+
} catch (DocumentationRenderingRequestDeclinedException) {
76+
// An entry can be undeployable for reasons that have nothing to
77+
// do with the domain, an irrelevant branch name for instance.
78+
// Keep going, one such entry must not stop the others.
79+
}
7380

7481
$this->entityManager->remove($documentationQuarantine);
7582
}

src/Controller/AdminInterface/Docs/QuarantinedDocumentationsController.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,20 +71,26 @@ public function allow(Request $request, DocumentationQuarantine $quarantinedDocu
7171
$this->entityManager->persist($knownRepositoryDomain);
7272
$this->entityManager->flush();
7373

74+
$declined = 0;
7475
foreach ($this->documentationQuarantineService->findAllByDomain($domain) as $documentationQuarantine) {
7576
$pushEvent = $documentationQuarantine->getPushEvent();
7677
try {
7778
$this->renderDocumentationService->requestDocumentationRendering($pushEvent, DocumentationRenderingTrigger::WEB);
7879
} catch (DocumentationRenderingRequestDeclinedException) {
79-
// Exception is thrown if the request documentation rendering does not comply with requirements
80-
// Intended fall-thru
80+
// An entry can be undeployable for reasons that have nothing to do
81+
// with the domain, an irrelevant branch name for instance. Keep
82+
// going, one such entry must not stop the others.
83+
++$declined;
8184
}
8285

8386
$this->entityManager->remove($documentationQuarantine);
8487
}
8588
$this->entityManager->flush();
8689

8790
$this->addFlash('success', sprintf('The domain %s has been allowed and all quarantined renderings have been activated.', $domain));
91+
if ($declined > 0) {
92+
$this->addFlash('warning', sprintf('%d of them could not be rendered and were discarded, see the rendering history for the reason.', $declined));
93+
}
8894

8995
return $this->redirectToRoute('admin_docs_quarantine_index');
9096
}

src/Enum/DocsRenderingHistoryStatus.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ final class DocsRenderingHistoryStatus
1818
public const NO_COMPOSER_JSON = 'noComposerJson';
1919
public const INVALID_COMPOSER_JSON = 'invalidComposerJson';
2020
public const UNKNOWN_REPOSITORY_DOMAIN = 'unknownRepositoryDomain';
21+
public const INVALID_COMPOSER_JSON_URL = 'invalidComposerJsonUrl';
2122
public const PACKAGE_REGISTERED_WITH_DIFFERENT_REPOSITORY = 'packageRegisteredWithDifferentRepository';
2223
public const NO_RELEVANT_BRANCH_OR_TAG = 'noRelevantBranchOrTag';
2324
public const MISSING_VALUE_IN_COMPOSER_JSON = 'missingValueInComposerJson';
@@ -35,6 +36,7 @@ final class DocsRenderingHistoryStatus
3536
self::NO_COMPOSER_JSON,
3637
self::INVALID_COMPOSER_JSON,
3738
self::UNKNOWN_REPOSITORY_DOMAIN,
39+
self::INVALID_COMPOSER_JSON_URL,
3840
self::PACKAGE_REGISTERED_WITH_DIFFERENT_REPOSITORY,
3941
self::NO_RELEVANT_BRANCH_OR_TAG,
4042
self::MISSING_VALUE_IN_COMPOSER_JSON,
@@ -55,6 +57,7 @@ final class DocsRenderingHistoryStatus
5557
self::NO_COMPOSER_JSON => 'No composer.json found.',
5658
self::INVALID_COMPOSER_JSON => 'Invalid composer.json.',
5759
self::UNKNOWN_REPOSITORY_DOMAIN => 'Unknown repository domain.',
60+
self::INVALID_COMPOSER_JSON_URL => 'The composer.json url can not be used.',
5861
self::PACKAGE_REGISTERED_WITH_DIFFERENT_REPOSITORY => 'Package registered with different repository.',
5962
self::NO_RELEVANT_BRANCH_OR_TAG => 'No relevant branch or tag found.',
6063
self::MISSING_VALUE_IN_COMPOSER_JSON => 'Missing value in composer.json.',

src/Service/DocumentationBuildInformationService.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
use GuzzleHttp\ClientInterface;
3434
use GuzzleHttp\Exception\GuzzleException;
3535
use GuzzleHttp\Psr7\Uri;
36+
use Psr\Http\Message\RequestInterface;
37+
use Psr\Http\Message\ResponseInterface;
38+
use Psr\Http\Message\UriInterface;
3639
use Symfony\Component\Filesystem\Filesystem;
3740

3841
/**
@@ -75,7 +78,20 @@ public function fetchRemoteComposerJson(string $path): array
7578
$this->assertUrlToComposerFileIsSafe($path);
7679

7780
try {
78-
$response = $this->generalClient->request('GET', $path);
81+
// The url is only known to be safe until the first redirect, so every
82+
// hop has to pass the same check. Without this an open redirect on an
83+
// allowed domain would be enough to reach an arbitrary target.
84+
$response = $this->generalClient->request('GET', $path, [
85+
'allow_redirects' => [
86+
'max' => 5,
87+
'protocols' => ['http', 'https'],
88+
'strict' => false,
89+
'referer' => false,
90+
'on_redirect' => function (RequestInterface $request, ResponseInterface $response, UriInterface $uri): void {
91+
$this->assertUrlToComposerFileIsSafe((string) $uri);
92+
},
93+
],
94+
]);
7995
} catch (GuzzleException $e) {
8096
throw new ComposerJsonNotFoundException($e->getMessage(), $e->getCode());
8197
}
@@ -324,6 +340,14 @@ private function assertUrlToComposerFileIsSafe(string $url): void
324340
throw new InvalidComposerJsonUrlException('URL to composer.json contains disallowed scheme', 1781613532, null, $url);
325341
}
326342

343+
// The url is assembled from payload fields, so a '#' or a '?' inside one of
344+
// them can push the intended '…/composer.json' suffix out of the path and
345+
// leave an arbitrary endpoint on the same host. Every format this
346+
// application builds ends in that suffix, so require it.
347+
if (!str_ends_with($uri->getPath(), '/composer.json')) {
348+
throw new InvalidComposerJsonUrlException('URL to composer.json does not point to a composer.json', 1785816000, null, $url);
349+
}
350+
327351
$normalizedHost = RepositoryUrlUtility::getNormalizedDomain($uri);
328352
$allowedRepositoryDomain = $this->knownRepositoryDomainsRepository->findOneBy([
329353
'domain' => $normalizedHost,

src/Service/DocumentationQuarantineService.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
use App\Entity\DocumentationQuarantine;
1515
use App\Extractor\PushEvent;
1616
use App\Repository\DocumentationQuarantineRepository;
17-
use App\Utility\RepositoryUrlUtility;
1817
use Doctrine\ORM\EntityManagerInterface;
1918

2019
class DocumentationQuarantineService
@@ -32,10 +31,15 @@ public function isQuarantined(PushEvent $pushEvent): bool
3231
]);
3332
}
3433

35-
public function quarantine(PushEvent $pushEvent): DocumentationQuarantine
34+
/**
35+
* The domain has to be the one the request was actually blocked on, which is
36+
* the host of the composer.json url. It differs from the host of the clone url
37+
* for Github always, and can differ for the other services as well.
38+
*/
39+
public function quarantine(PushEvent $pushEvent, string $blockedDomain): DocumentationQuarantine
3640
{
3741
$documentationQuarantine = (new DocumentationQuarantine())
38-
->setDomain(RepositoryUrlUtility::getNormalizedDomain($pushEvent->getRepositoryUrl()))
42+
->setDomain($blockedDomain)
3943
->setSerializedPushEvent(json_encode($pushEvent, JSON_THROW_ON_ERROR))
4044
->setChecksum($this->hash($pushEvent));
4145

src/Service/GitRepositoryService.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class GitRepositoryService
2020
final public const SERVICE_BITBUCKET_SERVER = 'bitbucket-server';
2121
final public const SERVICE_GITHUB = 'github';
2222
final public const SERVICE_GITLAB = 'gitlab';
23+
final public const SERVICE_FORGEJO = 'forgejo';
2324

2425
final public const SERVICE_NAMES = [
2526
self::SERVICE_GITHUB => 'GitHub',
@@ -32,6 +33,7 @@ class GitRepositoryService
3233
self::SERVICE_BITBUCKET_SERVER => '{baseUrl}/projects/{project}/repos/{package}/raw/composer.json?at=refs%2F{type}%2F{version}',
3334
self::SERVICE_GITLAB => '{baseUrl}/raw/{version}/composer.json',
3435
self::SERVICE_GITHUB => 'https://raw.githubusercontent.com/{repoName}/{version}/composer.json',
36+
self::SERVICE_FORGEJO => '{baseUrl}/raw/{type}/{version}/composer.json',
3537
];
3638
protected array $allowedBranches = ['master', 'main', 'documentation-draft'];
3739

@@ -41,6 +43,7 @@ public function resolvePublicComposerJsonUrlByPayload(\stdClass $payload, string
4143
self::SERVICE_BITBUCKET_SERVER, self::SERVICE_BITBUCKET_CLOUD => $this->getPublicComposerUrlForBitbucket($payload),
4244
self::SERVICE_GITHUB => $this->getPublicComposerUrlForGithub($payload),
4345
self::SERVICE_GITLAB => $this->getPublicComposerUrlForGitlab($payload),
46+
self::SERVICE_FORGEJO => $this->getPublicComposerUrlForForgejo($payload),
4447
default => '',
4548
};
4649
}
@@ -139,6 +142,18 @@ protected function getPublicComposerUrlForGitlab(\stdClass $payload): string
139142
]);
140143
}
141144

145+
protected function getPublicComposerUrlForForgejo(\stdClass $payload): string
146+
{
147+
$ref = (string) $payload->ref;
148+
$version = str_replace(['refs/tags/', 'refs/heads/'], '', $ref);
149+
150+
return $this->getParsedUrl($this->composerJsonUrlFormat[self::SERVICE_FORGEJO], [
151+
'{baseUrl}' => (string) $payload->repository->html_url,
152+
'{type}' => str_starts_with($ref, 'refs/tags/') ? 'tag' : 'branch',
153+
'{version}' => $version,
154+
]);
155+
}
156+
142157
protected function getPublicComposerUrlForGithub(\stdClass $payload): string
143158
{
144159
$version = str_replace(['refs/tags/', 'refs/heads/'], '', (string) $payload->ref);

0 commit comments

Comments
 (0)