Closing Composer's Download Fallback Paths in Private Packagist

This is the next post in our supply chain security series, following the supply chain security update and the Composer 2.10 release. Each post in this series covers a specific Composer behavior worth understanding, and a Private Packagist feature we are introducing on top of it.

Today: How Composer's package download fallback behavior, originally designed for resilience, can become a supply chain risk, and the new Private Packagist options that close it off.

Resilience through fallbacks

Composer's download model was designed to be resilient. If a mirror is unavailable, it tries to fall back to the original distribution download URL. If the dist download fails, it falls back to cloning from source. Each fallback gives the install a chance to succeed even when one part of the infrastructure is down.

For an organization using Private Packagist as a mirror of Packagist.org, this resulted in lock file entries that look like this:

{
    "name": "psr/log",
    "version": "3.0.2",
    "source": {
        "type": "git",
        "url": "https://github.com/php-fig/log.git",
        "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
    },
    "dist": {
        "type": "zip",
        "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
        "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
        "mirrors": [
            {
                "url": "https://repo.packagist.com/my-org/dists/%package%/%version%/r%reference%.%type%",
                "preferred": true
            }
        ]
    }
}

Three different ways to obtain the same package are advertised: The preferred mirror URL on Private Packagist, the original dist URL on GitHub, and the git source repository. When everything is running as expected, Composer downloads from the preferred mirror and the other entries never get used.

Resilience that never materialized

In practice, this resilience was already mostly theatre for any organization with private packages of its own, which is most Private Packagist customers. The CI runners, deploy environments, and developer machines installing from a private repository typically only hold credentials for Private Packagist itself, not for the upstream VCS hosts like GitHub. When the preferred mirror URL fails, the fallback to the original dist URL fails too, because the upstream rejects an unauthenticated request, and the source clone fails for the same reason.

The same is true for packages defined directly on Private Packagist through artifact uploads or custom JSON package definitions: The original URL is a packagist.com URL to begin with, so if Private Packagist itself is unavailable, the fallback URLs are unavailable for the same reason. Private Packagist has exceeded five nines of availability for several years, so this fallback path is in practice never used anyway.

The fallback paths only ever quietly succeed for mirrored public third-party packages, the exact case where the supply chain risk lives.

Where resilience becomes a supply chain risk

Suppose a malicious version of psr/log is published. Private Packagist detects it (through the Aikido malware feed, organization-level allow-lists, or any other security policy you have configured) and refuses to serve that specific version by returning a 404 or another error code and an error message for its dist URL. The intent on the repository side is straightforward: Stop developers from downloading the compromised artifact.

What Composer actually does:

Package operations: 1 install, 0 updates, 0 removals
  - Downloading psr/log (3.0.2)
Warning from repo.packagist.com:
==========================================
This dist file has been flagged as malware
==========================================
    Failed to download psr/log from dist: The "https://repo.packagist.com/my-org/dists/psr/log/3.0.2.0/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3.zip" file could not be downloaded (HTTP/2 404 )
  - Downloading psr/log (3.0.2)
  - Installing psr/log (3.0.2): Extracting archive

Composer notes the failure, picks up the next URL in the lock file, and downloads the malicious artifact directly from GitHub. The decision made by Private Packagist to block the version is silently overridden by the fallback behavior built into the client.

If GitHub zip downloads are also unavailable for that specific tag, Composer falls one step further, to a source checkout. This is even more relevant when mirroring other third-party package repositories, where artifact and repository may not be hosted by the same service.

==========================================
This dist file has been flagged as malware
==========================================
Failed to download psr/log from dist: The
"https://codeload.github.com/php-fig/log/legacy.zip/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" file could not be downloaded (HTTP/2 404 )
Now trying to download from source
  - Installing psr/log (3.0.2): Cloning f16e1d5863

The developer's machine now clones directly from the attacker-controlled git repository, bypassing every infrastructure protection that was in place to block the artifact.

Composer 2.10: A partial solution

The Composer config options governing this behavior are preferred-install (whether to prefer dist or source, defaulting to dist since Composer 2.1) and source-fallback (whether to fall back to source when dist fails). As covered in the Composer 2.10 release post, source-fallback has now been deprecated and defaults to false starting with 2.10, with full removal scheduled for 2.11. Note, that this still allows installation from source checkouts, you just have to explicitly request them through preferred-install config or command line options.

That closes the source-fallback half of the problem for projects on a current Composer version. It does not help projects on older Composer clients, or projects that explicitly override the default. It does nothing about the dist URL falling back to the upstream source code repository.

What we are adding in Private Packagist

We are introducing two related options at the organization level that close these fallback paths from the repository side, regardless of which Composer version a given developer, CI system or AI agent is running.

Private Packagist as the dist URL, not a mirror

Until now, Private Packagist has advertised itself as a preferred mirror, alongside the upstream dist URL pointing at GitHub or another source. Private Packagist now becomes the dist URL itself, and no upstream mirror is advertised. A new option allows you to restore the old discouraged behavior if you rely on it.

After a composer update --lock (rewrites the lock file without updating dependencies), the lock file now becomes:

"dist": {
    "type": "zip",
    "url": "https://repo.packagist.com/my-org/dists/psr/log/3.0.2.0/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3.zip",
    "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
}

The mirrors array is gone. There is no upstream URL for Composer to fall back to. Running the same scenario as before, with Private Packagist returning a 404 for a blocked version, the dist-side fallback to GitHub disappears:

Package operations: 1 install, 0 updates, 0 removals
  - Downloading psr/log (3.0.2)
Warning from repo.packagist.com:
==========================================
This dist file has been flagged as malware
==========================================
Failed to download psr/log from dist: The
"https://repo.packagist.com/my-org/dists/psr/log/3.0.2.0/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3.zip" file could not be downloaded (HTTP/2 404 )
Now trying to download from source
  - Installing psr/log (3.0.2): Cloning f16e1d5863

Composer still attempts the source fallback though, because the lock file's source block still points at the upstream git repository. That brings us to the second change.

Dropping source URLs from third-party package metadata

The source fallback is already on its way out in Composer itself with the 2.10 deprecation, but a lot of CI pipelines, deployment scripts, and developer machines will not be on a current Composer version for some time. Composer 2.2 LTS is still supported and performs source fallbacks. The same is true for projects that explicitly override the default, or use --prefer-source. Further, developers or even AI agents within your company can easily accidentally use an old version of Composer with source fallbacks enabled.

Private Packagist now let's you decide to remove the source block from mirrored third-party repository package metadata served to Composer by Private Packagist entirely. With no source URL in the lock file, there is nothing for Composer to fall back to, no matter how the client is configured or how old it is. At the same time the source URL is still available for your own and private packages, so you can easily install them from source into your vendor directory for direct editing.

Similarly to the first change, there is a new option in Private Packagist security settings to keep the less secure legacy behavior, if necessary.

With both changes enabled, and after a composer update --lock the same blocked-version scenario produces the result the repository operator actually wanted in the first place:

Package operations: 1 install, 0 updates, 0 removals
  - Downloading psr/log (3.0.2)
Warning from repo.packagist.com: 
==========================================
This dist file has been flagged as malware
==========================================
  The "https://repo.packagist.com/my-org/dists/psr/log/3.0.2.0/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3.zip" file could not be downloaded (HTTP/2 404 )

The install fails. There is no silent fallback to attacker-controlled code.

Configuration and defaults

Both options live under organization security settings:

  • Legacy insecure package download fallback: Whether to advertise the upstream dist URL alongside the Private Packagist URL. Set to no to make Private Packagist the only dist/artifact download provider.
  • Hide source code checkout URLs from Composer: Whether to omit source repository URLs from package metadata. Available scopes are never, only for packagist.org, for all third-party mirrored repository packages, and always.

The defaults differ for new and existing organizations:

  • New organizations get the strict configuration by default: original dist URL off, source URLs dropped for all mirrored packages.
  • Existing organizations mostly keep the previous behavior by default: legacy download fallback is on, source URLs are dropped only for packagist.org packages. The new options are opt-in for organizations that are already running, because dropping fallback URLs or source URLs more broadly can affect existing workflows.

We recommend that existing Private Packagist organizations review these options. The recommended secure configuration is: Legacy insecure package download fallback off, source URLs dropped for all mirrored packages.

A note for workflows that legitimately rely on source URLs being present. If your developers contribute back to mirrored packages by installing from source locally, dropping source URLs will affect that workflow. In that case, the only for packagist.org scope still removes the most-exposed fallback path while preserving source information for other packages you depend on.

The result

With both changes applied, the chain of effects is:

  • Composer downloads only from Private Packagist URLs. There is no path from composer install to GitHub or any other third-party.
  • A blocked version on Private Packagist stays blocked on the developer's machine. There is no silent way to obtain the artifact elsewhere.
  • Older Composer versions, and projects that override source-fallback, still cannot unintentionally install from source, because there is no source URL in the metadata for them to use.
  • Malware filtering, plugin allow-listing (coming soon!), and other security policies you configure on Private Packagist apply regardless of how individual clients are configured.

The Composer 2.10 release closes the source-fallback path for projects on a current client. These Private Packagist changes close the dist-fallback path entirely and extend the source-fallback closure back to projects on older clients. Between the two, your supply chain becomes a single line from Private Packagist to the developer's machine, with no detours.

More posts in this series are coming over the next days, covering further Composer behaviors and Private Packagist features that build on the 2.10 release.