Skip to content

[BUGFIX] Require the composer.json url to point at a composer.json - #308

Merged
andreaskienast merged 1 commit into
TYPO3GmbH:developfrom
CybotTM:fix/pin-composer-json-path
Aug 22, 2026
Merged

[BUGFIX] Require the composer.json url to point at a composer.json#308
andreaskienast merged 1 commit into
TYPO3GmbH:developfrom
CybotTM:fix/pin-composer-json-path

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

The composer.json url is assembled by string substitution from webhook payload fields, and the allowlist guarding it compares the host only. A # or a ? inside one of those fields therefore pushes the intended …/composer.json suffix out of the path, and what actually gets fetched is an arbitrary endpoint on an allowed host.

All four services produce a url whose path ends in /composer.json, so requiring that suffix rejects those shapes while leaving every real url untouched.

What this does not fix: the allowlist still stores a bare hostname, so a different port on an allowed host is still reachable — this only bounds what can be retrieved there to files named composer.json. Making it exact depends on what the existing KnownRepositoryDomain rows look like in production, which I cannot see.

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

Details — evidence, bypass attempts, one risk to check, merge notes

Evidence

Built through the real GitRepositoryService with project.web_url as the attacker-controlled field:

payload value path actually requested
https://allowed.example/internal/admin# /internal/admin
https://allowed.example/api/v4/user?a= /api/v4/user
https://allowed.example:9200/_cluster/health# /_cluster/health on port 9200

In each case the allowlist saw only allowed.example and passed. With this change all three are rejected.

Bypass attempts

I tried to get past the new check with percent-encoded slashes and dots (%2Fcomposer.json, composer%2Ejson), uppercase, a semicolon parameter, a trailing traversal after the suffix, and double slashes. All rejected. What still passes is a traversal that itself ends in /composer.json — consistent with the stated bound above.

One risk worth checking

publicComposerJsonUrl is editable in the manual deployment wizard. If any existing row points at a file not named composer.json, it would now be rejected. A quick look at that column would confirm whether that is the case.

Exception handling

The new rejection uses InvalidComposerJsonUrlException, which no caller handled — on its own that would answer a public request with a 500. This PR therefore also handles it, with its own history status INVALID_COMPOSER_JSON_URL rather than reusing the one for an unknown domain. That block is byte-identical to the one in #306, so the two merge in either order.

Merge notes

All three related PRs merge onto develop cleanly in any order — verified by performing the merges, with an identical resulting tree, the catch block and the status each present exactly once, and a green combined suite.

@CybotTM
CybotTM force-pushed the fix/pin-composer-json-path branch from a65f682 to ab2f8af Compare August 4, 2026 14:55
The url is assembled by string substitution from webhook payload
fields, and the allowlist that guards it compares the host only. A '#'
or a '?' inside one of those fields therefore pushes the intended
'…/composer.json' suffix into a fragment or a query string and leaves
an arbitrary endpoint on an allowed host, which is then fetched.

Every url format this application builds ends its path with
'/composer.json', for all four services, so require that. Both
manipulations lose the suffix and are rejected.

This does not make the allowlist exact, a different port on an allowed
host still passes, but it bounds what can be reached there to files
named composer.json.

The new rejection uses InvalidComposerJsonUrlException, which no
caller handled, so it would have answered a public request with a 500.
Handle it like its two siblings. That block, and the history status it uses, are identical to the ones in
the pull request that re-checks the url after a redirect, so the two
merge in either order.

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