Skip to content

Commit 6c7914e

Browse files
committed
[BUGFIX] Quarantine the domain the request was blocked on
A quarantined rendering recorded the host of the clone url, while the check that blocked it keys on the host of the composer.json url. For Github those never match: the repository is on github.com, the composer.json is fetched from raw.githubusercontent.com. Approving a quarantined entry allowlists exactly the recorded domain and then replays every entry sharing it. With the wrong domain recorded, the replay is checked against a host that was never approved, so it is quarantined again and the allowlist gains an entry for a domain that is never consulted. Record the host the check actually rejected, which the exception already carries. updateLastHit() had the same mismatch and could therefore never find the row it meant to touch. The feature shipped in 7.2.0, so entries recorded before this fix carry the clone host. Recompute those from the push event each row already stores. Rows whose payload can not be read are left alone, and the checksum does not cover the domain, so deduplication is unaffected. Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
1 parent a2990fa commit 6c7914e

5 files changed

Lines changed: 207 additions & 5 deletions

File tree

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+
}

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/RenderDocumentationService.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,12 @@ public function requestDocumentationRendering(PushEvent $pushEvent, Documentatio
5454
$userIdentifier = $this->security->getUser() instanceof KeyCloakUser ? $this->security->getUser()->getDisplayName() : 'Anon.';
5555

5656
try {
57-
$this->documentationBuildInformationService->updateLastHit(RepositoryUrlUtility::getNormalizedDomain($pushEvent->getRepositoryUrl()));
57+
$this->documentationBuildInformationService->updateLastHit(RepositoryUrlUtility::getNormalizedDomain($pushEvent->getUrlToComposerFile()));
5858

5959
$composerJson = $this->documentationBuildInformationService->fetchRemoteComposerJson($pushEvent->getUrlToComposerFile());
6060
} catch (UnknownComposerJsonUrlException $e) {
6161
if (!$this->documentationQuarantineService->isQuarantined($pushEvent)) {
62-
$documentationQuarantine = $this->documentationQuarantineService->quarantine($pushEvent);
62+
$documentationQuarantine = $this->documentationQuarantineService->quarantine($pushEvent, $e->normalizedHost);
6363
$this->documentationBuildInformationService->notifyAboutUnknownRepositoryDomain($documentationQuarantine);
6464

6565
$this->historyService->writeHistory(new HistoryEntryDto(
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the package t3g/intercept.
7+
*
8+
* For the full copyright and license information, please read the
9+
* LICENSE file that was distributed with this source code.
10+
*/
11+
12+
namespace App\Tests\Unit\Service;
13+
14+
use App\Entity\DocumentationQuarantine;
15+
use App\Extractor\PushEvent;
16+
use App\Repository\DocumentationQuarantineRepository;
17+
use App\Service\DocumentationQuarantineService;
18+
use Doctrine\ORM\EntityManagerInterface;
19+
use PHPUnit\Framework\TestCase;
20+
21+
class DocumentationQuarantineServiceTest extends TestCase
22+
{
23+
/**
24+
* Approving a quarantined entry allowlists the domain it recorded, and that
25+
* decision is only correct if the recorded domain is the one the request was
26+
* blocked on. For Github those two never match: the clone url is on
27+
* github.com while the composer.json is fetched from
28+
* raw.githubusercontent.com.
29+
*/
30+
public function testQuarantineRecordsTheDomainTheRequestWasBlockedOn(): void
31+
{
32+
$persisted = null;
33+
$entityManager = $this->createMock(EntityManagerInterface::class);
34+
$entityManager->method('persist')->willReturnCallback(
35+
static function (object $entity) use (&$persisted): void {
36+
$persisted = $entity;
37+
}
38+
);
39+
40+
$subject = new DocumentationQuarantineService($entityManager, $this->createMock(DocumentationQuarantineRepository::class));
41+
$pushEvent = new PushEvent(
42+
'https://github.com/acme/coolextension.git',
43+
'main',
44+
'https://raw.githubusercontent.com/acme/coolextension/main/composer.json',
45+
'{}'
46+
);
47+
48+
$subject->quarantine($pushEvent, 'raw.githubusercontent.com');
49+
50+
$this->assertInstanceOf(DocumentationQuarantine::class, $persisted);
51+
$this->assertSame('raw.githubusercontent.com', $persisted->getDomain());
52+
}
53+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the package t3g/intercept.
7+
*
8+
* For the full copyright and license information, please read the
9+
* LICENSE file that was distributed with this source code.
10+
*/
11+
12+
namespace App\Tests\Unit\Service;
13+
14+
use App\Entity\DocumentationQuarantine;
15+
use App\Enum\DocumentationRenderingTrigger;
16+
use App\Exception\DocumentationRenderingRequestDeclinedException;
17+
use App\Exception\UnknownComposerJsonUrlException;
18+
use App\Extractor\PushEvent;
19+
use App\Repository\RepositoryBlacklistEntryRepository;
20+
use App\Service\DocumentationBuildInformationService;
21+
use App\Service\DocumentationQuarantineService;
22+
use App\Service\GithubService;
23+
use App\Service\HistoryService;
24+
use App\Service\MailService;
25+
use App\Service\RenderDocumentationService;
26+
use Doctrine\ORM\EntityManagerInterface;
27+
use PHPUnit\Framework\TestCase;
28+
use Psr\Log\NullLogger;
29+
use Symfony\Bundle\SecurityBundle\Security;
30+
31+
class RenderDocumentationServiceTest extends TestCase
32+
{
33+
/**
34+
* The quarantined domain is what an admin later allowlists, so it has to be
35+
* the host the request was blocked on. That is the host of the composer.json
36+
* url, which for Github is never the host of the clone url.
37+
*/
38+
public function testTheBlockedDomainIsHandedToTheQuarantine(): void
39+
{
40+
$pushEvent = new PushEvent(
41+
'https://github.com/acme/coolextension.git',
42+
'main',
43+
'https://raw.githubusercontent.com/acme/coolextension/main/composer.json',
44+
'{}'
45+
);
46+
47+
$buildInformationService = $this->createMock(DocumentationBuildInformationService::class);
48+
$buildInformationService->method('fetchRemoteComposerJson')->willThrowException(
49+
new UnknownComposerJsonUrlException('', 1782290340, null, $pushEvent->getUrlToComposerFile(), 'raw.githubusercontent.com')
50+
);
51+
52+
$lastHitDomain = null;
53+
$buildInformationService->method('updateLastHit')->willReturnCallback(
54+
static function (string $domain) use (&$lastHitDomain): void {
55+
$lastHitDomain = $domain;
56+
}
57+
);
58+
59+
$quarantinedDomain = null;
60+
$quarantineService = $this->createMock(DocumentationQuarantineService::class);
61+
$quarantineService->method('isQuarantined')->willReturn(false);
62+
$quarantineService->method('quarantine')->willReturnCallback(
63+
static function (PushEvent $event, string $domain) use (&$quarantinedDomain): DocumentationQuarantine {
64+
$quarantinedDomain = $domain;
65+
66+
return new DocumentationQuarantine();
67+
}
68+
);
69+
70+
$subject = new RenderDocumentationService(
71+
$buildInformationService,
72+
$this->createMock(GithubService::class),
73+
new HistoryService($this->createMock(EntityManagerInterface::class)),
74+
new NullLogger(),
75+
$quarantineService,
76+
$this->createMock(RepositoryBlacklistEntryRepository::class),
77+
$this->createMock(MailService::class),
78+
$this->createMock(Security::class),
79+
);
80+
81+
try {
82+
$subject->requestDocumentationRendering($pushEvent, DocumentationRenderingTrigger::API);
83+
$this->fail('An unknown domain has to decline the rendering request.');
84+
} catch (DocumentationRenderingRequestDeclinedException) {
85+
// expected, the assertions below are what this test is about
86+
}
87+
88+
$this->assertSame('raw.githubusercontent.com', $quarantinedDomain, 'The quarantine has to record the host the request was blocked on.');
89+
$this->assertSame('raw.githubusercontent.com', $lastHitDomain, 'The last hit belongs to the domain row the check looks up.');
90+
}
91+
}

0 commit comments

Comments
 (0)