Skip to content

[BUGFIX] Quarantine the domain the request was blocked on - #307

Merged
andreaskienast merged 2 commits into
TYPO3GmbH:developfrom
CybotTM:fix/quarantine-domain
Aug 22, 2026
Merged

[BUGFIX] Quarantine the domain the request was blocked on#307
andreaskienast merged 2 commits into
TYPO3GmbH:developfrom
CybotTM:fix/quarantine-domain

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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 — github.com versus raw.githubusercontent.com.

That is not cosmetic: approving an entry allowlists exactly the recorded domain and replays every entry sharing it. So an admin approving a GitHub repository creates a KnownRepositoryDomain for a domain the fetch check never consults, while the replayed events are checked against the real host and land in quarantine again. The approval silently does nothing.

This records the host the check actually rejected. updateLastHit() had the same mismatch and could never find the row it meant to touch.

Needs a migration: the feature shipped in 7.2.0, so existing rows carry the wrong host and approving one of them reproduces the bug. The migration recomputes the domain from the push event each row already stores.

t3g:test (162 tests), t3g:phpstan and t3g:cgl pass. Found while working on #305, independent of it.

Details — migration behaviour, the replay fix, tests, merge notes

Migration

The domain is recomputed from serialized_push_event, which contains urlToComposerFile. Rows whose payload cannot be decoded are skipped rather than failing the migration; rows already correct (Bitbucket Cloud, most GitLab setups) are left alone. The checksum hashes only the serialized push event and does not include the domain, so deduplication is unaffected. It is irreversible — the old value came from a different url and cannot be reconstructed.

I ran it against a SQLite database seeded with a GitHub row, a Bitbucket row and a row with an undecodable payload: only the GitHub row changed, from github.com to raw.githubusercontent.com.

Replay loop made robust

Approving a domain replays every entry it holds. Until now a single entry that cannot be rendered — an irrelevant branch name is the likely case — threw out of the loop, gave the admin a 500, aborted the remaining entries and left the queue half-processed. This was invisible before, because the loop never got that far. Such an entry is now skipped and the admin is told how many were dropped. Both approval paths are covered.

Tests

RenderDocumentationServiceTest pins that the host handed to quarantine() and to updateLastHit() is the composer host, using a GitHub-shaped push event where the two differ. I verified it discriminates by reverting each production line separately — the test fails each time. The earlier version of this PR only asserted a setter passthrough, which stayed green when the real bug was restored; that gap is what this test closes.

Merge notes

#306 touches RenderDocumentationService in the same area. All three related PRs merge onto develop cleanly in any order — verified by performing the merges, with an identical resulting tree and a green combined suite.

@CybotTM
CybotTM force-pushed the fix/quarantine-domain branch from f42bfd7 to 6c7914e Compare August 4, 2026 15:00
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>

Approving a domain replays every entry it holds, and until now a
single entry that can not be rendered, an irrelevant branch name for
instance, aborted the whole run and left the rest of the queue
behind. Skip such an entry and tell the admin how many were dropped.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@andreaskienast

Copy link
Copy Markdown
Member

Sorry, I pushed a commit that fixes one bug your PR already covered. Can you please rebase your PR and give me a ping?

Thanks in advance!

andreaskienast pushed a commit that referenced this pull request Aug 22, 2026
`assertUrlToComposerFileIsSafe()` validates the composer.json url built
from an unauthenticated webhook payload — but only once, on the initial
url. The client then follows up to five redirects, none of which is
checked again, so an open redirect on an allowed domain is enough to
make intercept fetch from anywhere.

This re-runs the check on every hop. No legitimate fetch is affected:
GitHub raw, GitLab `/raw/` and Forgejo `/raw/branch/` all answer
directly with zero redirects.

**Second change, in the same PR on purpose:** the check can now fail
mid-request, and one of its three exceptions
(`InvalidComposerJsonUrlException`) was caught nowhere — that would have
turned a redirect to a disallowed scheme into an HTTP 500 instead of the
intended 422. Splitting the two would mean merging a state where the
first change makes things worse.

`t3g:test` (164 tests), `t3g:phpstan` and `t3g:cgl` pass. Found while
working on #305, independent of it.

<details>
<summary>Details — evidence, the new history status, merge
notes</summary>

### Evidence

`config/services.yaml:53-56` registers `guzzle.client.general` as a bare
`GuzzleHttp\Client`; its resolved config is `allow_redirects: {max: 5,
protocols: [http, https], strict: false, referer: false}`. I confirmed
the behaviour with that same construction against a local redirect
server: a 302 to another host was followed and its body returned.

I also checked the guard cannot be evaded: `on_redirect` fires for 301,
302, 303, 307 and 308; relative and protocol-relative `Location` headers
are resolved and checked (`//evil.example/x` arrives as
`https://evil.example/x`); and the exception thrown inside the callback
is not a `GuzzleException`, so the existing `catch (GuzzleException)` in
`fetchRemoteComposerJson()` does not swallow it into a plain "not
found".

Whether legitimate urls rely on redirects, checked live:
`raw.githubusercontent.com` 200/0 redirects, GitLab `/raw/` 200/0,
Forgejo `/raw/branch/` 200/0. Only Forgejo's deprecated short form
redirects, and intercept does not build it.

### New history status

The new catch writes
`DocsRenderingHistoryStatus::INVALID_COMPOSER_JSON_URL` rather than
reusing `UNKNOWN_REPOSITORY_DOMAIN`, which would have labelled an
unusable url as a domain problem in the rendering history.

### Tests

`DocumentationBuildInformationServiceTest` covers a redirect leaving the
allowlist (rejected), one staying on it (followed), a plain response and
a non-200. I verified they discriminate by removing the `on_redirect`
guard and confirming the first test fails.

### Merge notes

#307 touches `RenderDocumentationService` in the same area and #308 adds
the identical catch block and status. All three merge onto `develop`
cleanly in any order — I verified that by performing the merges; the
combined tree is identical regardless of order and its unit suite is
green.

</details>

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@andreaskienast
andreaskienast merged commit 0e936f6 into TYPO3GmbH:develop Aug 22, 2026
3 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.

2 participants