Skip to content

Hosted Composer rewrite keeps the entry's transport-options, so Composer sends a private repository's auth headers to the hosted patch URL #399

Description

[agent] Found by the scheduled Composer bug-hunt routine (ledger #321).

Summary

Composer copies a repository's options (for example "options": {"http": {"header": ["X-Private-Token: …"]}}, a common way to authenticate a private Composer repository or Satis) into every package it resolves from that repository, as the lock entry's transport-options. scan --mode hosted sets the entry's dist.url to the hosted patch archive and removes source and dist.mirrors, but it keeps transport-options. Composer applies a package's transport-options to that package's dist download. So the next composer install sends the private repository's credential header to the hosted patch host.

Impact

  • A private registry credential is disclosed to a third-party origin (the hosted patch server) on every install from the rewritten lock. That includes CI and fresh clones, and nothing warns about it.
  • The same applies to any other stream option the repository set, such as ssl.local_cert, ssl.verify_peer: false, or http.proxy. Those were meant for the private repository only and now apply to the hosted URL.
  • Vendored mode isn't affected: rewrite_lock_entry replaces transport-options with {"symlink": false}.

Repro

Everything runs locally: a two-file Composer repository on 127.0.0.1:8765, plus a small mock of the patch API on 127.0.0.1:8766 (it serves patches/batch, patches/package → a granted artifacts[].integrity.sha1, by-package, view and the zip). This is the same shape e2e_redirect_composer_build mocks.

# private repo with a header option (served by python -m http.server 8765)
cat > composer.json <<'J'
{"name":"t/app",
 "repositories":[{"type":"composer","url":"http://127.0.0.1:8765",
                  "options":{"http":{"header":["X-Private-Token: s3cret"]}}},
                 {"packagist.org":false}],
 "require":{"acme/rlib":"1.0.0"},"config":{"secure-http":false}}
J
composer update -q
jq '.packages[0]["transport-options"]' composer.lock     # {"http":{"header":["X-Private-Token: s3cret"]}}

socket-patch scan --mode hosted --json --yes --api-url http://127.0.0.1:8766 --org org --api-token fake
jq '.packages[0] | {dist, "transport-options"}' composer.lock
#  dist.url -> http://127.0.0.1:8766/patch/composer/acme/rlib/1.0.0/tok/<uuid>/rlib-1.0.0.zip
#  transport-options -> {"http":{"header":["X-Private-Token: s3cret"]}}   <- kept

rm -rf vendor && COMPOSER_HOME=$(mktemp -d) composer install -q
# mock hosted server log:
# GET /patch/composer/acme/rlib/1.0.0/tok/<uuid>/rlib-1.0.0.zip headers={..., 'X-Private-Token': 's3cret', ...}

The install succeeds and the patched bytes land, so there's no visible error. The header is only visible on the hosted server's side.

Expected vs actual

  • Expected: docs/testing/composer-compatibility.md ("What each mode writes") says the hosted rewrite retargets the dist to the hosted archive and removes the entry's source and dist.mirrors, because they belong to the original origin. The entry's transport-options also belong only to the original repository, so the hosted rewrite should drop them too. It could warn, like redirect_composer_dist_mirrors_removed does, and the fragment revert would restore them. The alternative is to refuse. Credentials for the private registry must never be sent to the patch host.
  • Actual: transport-options stays on the entry verbatim, and Composer sends the header to the hosted URL. redirected: 1 with no warning.

Matrix (Linux, main f6b7fb9 = 4.0.0)

Composer (PHP 8.3) Lock carries transport-options from repo options Header sent to hosted URL on composer install
1.10.28 yes yes
2.2.30 yes yes
2.8.12 yes yes (twice)

This is OS-independent: the lock text and Composer's own download options drive it. I didn't bisect: every release that carries the current hosted composer rewriter behaves this way on 4.0.0.

Suspect code

  • crates/socket-patch-core/src/patch/redirect/composer_source.rs:292 (apply_dist_edit). It drops source (plan_source_drop, line 241) and dist.mirrors, but never looks at the entry's transport-options.
  • crates/socket-patch-core/src/patch/redirect/mod.rs:4953 (rewrite_composer_lock).

Backlog review — 2026-10-08

Priority: P2 → P1. Forwarding private repository authorization headers to the hosted patch URL is credential disclosure. Open PR #1026 should receive security-focused review.

Activity

  1. mikolalysenko commented on Oct 1, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p2 (Composer). Confirmed on main f6b7fb9: the hosted Composer rewrite never touches transport-options (the only writer of that key is the vendored path in crates/socket-patch-core/src/vendor/composer_lock.rs, which supersedes it with {"symlink": false}). Not a duplicate, and not covered by open PR #358 (that PR is about nested extra.dist lookup). Treating as a correctness/security bug (credential sent to a third-party origin).


    Generated by Claude Code

  2. mikolalysenko commented on Oct 1, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Re-triage on main 2463257 (v5 consolidation, #277): still reproduces. The v5 hosted rewrite (no ledger) still leaves the entry's transport-options in place after it points dist.url at the hosted origin.

    Lock entry before: dist.url = https://repo.example.com/dist/acme-tool-1.0.0.zip, "transport-options": {"http": {"header": ["X-Private-Token: s3cret"]}}. After socket-patch scan --mode hosted --ecosystems composer (mock patch API, granted with sha1):

    {"name": "acme/tool", "version": "1.0.0", "dist": {"type": "zip", "url": "https://patch.socket.dev/patch/composer/acme/tool/1.0.0/tok/<uuid>/acme-tool-1.0.0.zip", "reference": "deadbeef", "shasum": "0123…"}, "type": "library", "transport-options": {"http": {"header": ["X-Private-Token: s3cret"]}}}

    So Composer will still send the private repository's header to patch.socket.dev. Real-Composer behaviour for that lock shape is as shown in the original report.


    Generated by Claude Code

  3. mikolalysenko commented on Oct 1, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Composer bug-hunt routine (ledger #321): still reproduces on main 61cfb9b, after #358 changed the Composer rewriter. I also found a second impact of the same kept transport-options. It needs no private repository: on Composer 1, the next install of a hosted-redirected path-repository entry fails outright.

    Path-repo variant. For a relative "type": "path" repository, Composer writes "transport-options": {"relative": true} (plus "symlink": … if the repo sets it) on the lock entry. The hosted rewrite turns the entry into dist.type: zip with the hosted url and shasum, but keeps those transport-options. Composer 1 passes a package's transport-options into stream_context_create() for the dist download. These keys aren't stream-wrapper options, so the download dies:

    # Composer 1.10.28 project: repositories:[{"type":"path","url":"../pkgs/tool"}], require acme/tool 1.0.0
    socket-patch scan            # bare scan = hosted; redirected: 1, no warning
    jq '.packages[0] | {dist, "transport-options"}' composer.lock
    #  dist: {type: zip, url: http://<patch-host>/patch/composer/acme/tool/1.0.0/<tok>/<uuid>/tool-1.0.0.zip, reference: …, shasum: …}
    #  transport-options: {relative: true}          <- kept
    rm -rf vendor && php composer-1.10.28.phar install
    #  - Installing acme/tool (1.0.0): Downloading
    #  PHP Fatal error:  Uncaught ValueError: Options should have the form ["wrappername"]["optionname"] = $value
    #    in …/composer.phar/src/Composer/Util/StreamContextFactory.php:153        (PHP 8.x, exit 255)
    #  [ErrorException] stream_context_create(): options should have the form …   (PHP 7.x, exit 1)
    

    Removing only transport-options from the rewritten entry makes the same install succeed with the patched bytes.

    Composer PHP OS Install from the hosted lock (kept) Same lock, transport-options stripped
    1.10.28 7.2 / 7.4 ubuntu fails (ErrorException, exit 1) patched
    1.10.28 8.0 / 8.1 / 8.4 ubuntu fails (ValueError, exit 255) patched
    1.10.28 8.1 macOS, Windows fails (ValueError, exit 255) patched
    1.10.28 8.3 local Linux, real socket-patch scan on 61cfb9b (×3) and on v4.0.0 fails patched
    2.2.30 7.2 / 8.3 ubuntu / local installs patched patched
    2.8.12 / 2.10.3 8.3 local installs patched patched

    Header leak (the original report). Re-checked on 61cfb9b with a type: composer repository carrying options.http.header. The rewritten entry still has transport-options: {"http":{"header":["X-Private-Token: s3cret"]}}.

    Dropping (or refusing) transport-options in the hosted rewrite, as proposed above, fixes both. relative and symlink only mean anything for a path dist.


    Generated by Claude Code

  4. added
    v5-blockerMust resolve before v5: public interface/migration or ordinary patch-install-undo failure.
    compatibilityPublic CLI/JSON, saved state, upgrades, or package-manager compatibility.
    and removed on Oct 8, 2026
  5. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    v5 release blocker (P1). An ordinary hosted Composer rewrite forwards existing private-repository transport credentials to the patch download. Fix the source transition before release; PR #1026 is pending.

    This follows the maintainer's release scope: one normally completing CLI instance, prioritizing valid-lockfile patch/install behavior, compatibility, and actionable CLI UX.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent:triagedbugSomething isn't workingbughuntFound by a scheduled package-manager bug-hunt agentcompatibilityPublic CLI/JSON, saved state, upgrades, or package-manager compatibility.pm:composerComposerpriority:p1v5-blockerMust resolve before v5: public interface/migration or ordinary patch-install-undo failure.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions