Skip to content

Keep pasted pack tokens out of messages and the clone config - #2082

Merged
ppXD merged 1 commit into
mainfrom
fix/keep-pack-clone-tokens-out-of-messages-and-config
Oct 7, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/keep-pack-clone-tokens-out-of-messages-and-config

Conversation

@ppXD

@ppXD ppXD commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • A pack can be imported from a pasted git URL that carries a token in its userinfo (x-access-token:<token>@, oauth2:<token>@, or <token>@). When a clone fails, the PackImportException now names the URL without its userinfo, using RemoteTipResolver.SanitizeUrl. That message reaches the API error body, the UI and the mediator log.
    • git's stderr loses the userinfo of every http(s) URL in it.
    • The part that carries the credential is also masked as bare text, in every spelling git echoes (as written, decoded, re-encoded). That part is the password, or the user when no password is given.
    • A user named next to a password stays readable, so git's reason is not garbled.
    • Neither PackHostAllowlist refusal echoes the input anymore. A URL pasted without https:// parses the token as its scheme.
  • The clone now runs as a tokened command whenever the URL carries any credential, through the new TokenedGitCommand.AsTokened. This includes a token pasted as the user alone, which git used to pass to the operator's credential helpers (when asking for the missing password) and write to their trace2 targets. TokenedGitCommand.IsTokened is unchanged, so a stored https://user@mirror workspace URL still authenticates through the operator's helper.
  • The clone directory is created owner-only (0700) before git runs. Once the clone finishes, origin is rewritten through LocalGitWorkspaceProvider.StripTokenFromRemoteAsync. That step fails closed: if the token cannot be stripped, the clone is refused and deleted.

Not in this change

  • A successful import still stores the pasted URL, token included, as pack.url (PackImportService.Commit.cs:109). ListPacksQuery and GetPackQuery return it to every team member, Viewers included (PackService.cs:109).
    • Sync re-clones from that URL. The add-from-sync flow also uses it: it posts pack.url back as the import URL (SyncResultModal.tsx:46).
    • So the column cannot simply be sanitized, and neither can the response. A fix needs the credential kept server-side (an encrypted reference), a migration, and an add-from-sync that resolves the pack instead of its URL.
  • During the clone, the token is still in git's argv and in .git/config. .git/config is now readable only by the worker's own uid. Passing the token through the environment as an http.extraHeader would close this gap, for the workspace provider's clones too.
  • For a token pasted as the user alone, git has no helper left to ask, so it falls through to any core.askPass set in the operator's config.

Test plan

  • Unit tests: PackCloneFetcherCredentialTests, PackCloneFetcherArgsTests, PackHostAllowlistTests and TokenedGitCommandTests (77/77). Full unit suite: 12140 passed, 1 skipped.
  • Integration tests run with real git against a loopback smart-HTTP remote that requires a fake token, using a scratch HOME and global config with system config off.
    • PackCloneCredentialFlowTests sets a logging credential helper and trace2 targets, with positive controls. The token-only paste reaches neither, and the checkout directory stays 0700.
    • Pack and tokened-git suites: 88/88 on git 2.33.0 and 88/88 on Apple git 2.50.1.
  • Mutation checks. Each of the following fails a test:
    • Dropping the URL-shape redaction, any one spelling, or the password half.
    • Restoring the needles for both userinfo parts.
    • Reverting to TokenedGitCommand.Spec.
    • Dropping the chmod.
  • dotnet build CodeSpace.sln: 0 errors.

An operator can import an agent pack from a pasted git URL that carries
a token in its userinfo: x-access-token:<token>@, oauth2:<token>@, or
the token alone as the user. Several places kept it or passed it on.

A failed clone's PackImportException named the pasted URL verbatim, and
that message reaches the API error body, the UI and the mediator's
error log. git hides a password in its own output, but when a token is
pasted as the user alone it asks for a password and names that user
(decoded by git 2.33, re-encoded by 2.50), so git's stderr carried it
as well. The host allowlist echoed a URL it could not parse, and named
the scheme it refused; a URL pasted without https:// parses the token
before its colon as that scheme.

A token pasted as the user alone carries no password, so the clone did
not run as a tokened command: git handed the token to the operator's
credential helpers when it asked them for the missing password, and
wrote it to their trace2 targets.

The clone's .git/config held the tokened origin from before the
transfer until after the import walked the checkout, under the
worker's temp dir with default permissions, and until the janitor's
sweep when a worker died mid-import.

The failure now names the URL without its userinfo, through the same
SanitizeUrl the launch-base resolver uses. git's stderr loses the
userinfo of every http(s) URL in it, then the part that carries the
credential (the password, or the user when none is given) as bare text
in each spelling git echoes it; a user named beside a password, an
account and never echoed outside a URL, is left so git's reason stays
readable. Neither allowlist refusal echoes the input.

The clone runs as a tokened command whenever the URL carries any
credential. TokenedGitCommand.IsTokened is unchanged: a stored
https://user@mirror workspace URL may authenticate through an operator
helper. The clone directory is owner-only before git runs, and once
cloned, origin is rewritten through the workspace provider's own
fail-closed strip, so a clone that can shed it neither way is refused
and deleted. An ssh URL's git@ names an account and is left as written.

Not changed here:
- A successful import stores the pasted URL, token included, as
  pack.url, and the pack list and detail queries return it to every
  team member, Viewers included. Sync and the add-from-sync flow
  re-clone from that URL, so storing or returning it without the token
  needs the credential kept server-side (an encrypted reference, a
  migration, and an add that resolves the pack instead of its URL).
- During the clone the token is still in git's argv and in
  .git/config, now readable by the worker's own uid only. Passing it
  through the environment as an http.extraHeader would close that, for
  the workspace provider's clones too.
- For a token pasted as the user alone, git with no helper left to ask
  falls through to a core.askPass the operator's config may set.

Verified against git 2.33.0 and 2.50.1.
@ppXD
ppXD merged commit 4367cc2 into main Oct 7, 2026
6 of 7 checks passed
ppXD added a commit that referenced this pull request Oct 7, 2026
A pack imported from a git URL that embedded a token stored that URL
verbatim in pack.url. The pack list and detail returned it to every
team member, Viewers included; the Library rendered it as a link; and
the add-after-sync flow sent it back to import-url.

pack.url now holds the URL without userinfo and is the pack's identity.
The URL exactly as cloned is sealed with IPayloadEncryptor in
pack.encrypted_clone_url, set only when it carried userinfo. Sync and
the new POST /api/packs/{id}/import decrypt it just for the clone, so a
private pack keeps syncing and the add imports into the pack by id
rather than re-resolving a URL that can no longer clone. Re-pasting
with a rotated token now updates the same pack instead of forking one.

Both hand the decrypted URL to PackCloneFetcher, so this builds on
#2082, which names a failed clone's URL without its userinfo, redacts
it from git's stderr and strips it from the checkout's origin. Without
it, a Sync or an add whose clone fails after authenticating (a deleted
ref, say) returns the token in the error body to any member who may
write agents, and logs it.

SQL cannot run the encryptor, so existing rows are sealed by a
ten-minute recurring backfill. Until it reaches a row, the row still
syncs from its URL and the read model strips userinfo on the way out.
A re-import of that repository lands in the row and seals it: the
lookup also matches a stored URL that differs only by its credential,
so pasting the same or a rotated token neither forks the pack nor
leaves its history on the pack the backfill would mark a duplicate.
Legacy forks of one repository settle into a holder plus duplicates
(duplicate_of_pack_id) that keep syncing from their own source.

Pods that predate this change cannot read the seal. Until the rollout
completes they fail to Sync a sealed private pack, and their
import-url fails for a repository whose legacy fork became a holder
plus a duplicate. The backfill runs only where Hangfire processes
jobs, so in an Api/Worker split roll the Api pods out first.

Tokens pasted before this change were already exposed and should be
rotated.
ppXD added a commit that referenced this pull request Oct 7, 2026
A pack imported from a git URL that embedded a token stored that URL
verbatim in pack.url. The pack list and detail returned it to every
team member, Viewers included; the Library rendered it as a link; and
the add-after-sync flow sent it back to import-url.

pack.url now holds the URL without userinfo and is the pack's identity.
The URL exactly as cloned is sealed with IPayloadEncryptor in
pack.encrypted_clone_url, set only when it carried userinfo. Sync and
the new POST /api/packs/{id}/import decrypt it just for the clone, so a
private pack keeps syncing and the add imports into the pack by id
rather than re-resolving a URL that can no longer clone. Re-pasting
with a rotated token now updates the same pack instead of forking one.

Both hand the decrypted URL to PackCloneFetcher, so this builds on
#2082, which names a failed clone's URL without its userinfo, redacts
it from git's stderr and strips it from the checkout's origin. Without
it, a Sync or an add whose clone fails after authenticating (a deleted
ref, say) returns the token in the error body to any member who may
write agents, and logs it.

SQL cannot run the encryptor, so existing rows are sealed by a
ten-minute recurring backfill. Until it reaches a row, the row still
syncs from its URL and the read model strips userinfo on the way out.
A re-import of that repository lands in the row and seals it: the
lookup also matches a stored URL that differs only by its credential,
so pasting the same or a rotated token neither forks the pack nor
leaves its history on the pack the backfill would mark a duplicate.
Legacy forks of one repository settle into a holder plus duplicates
(duplicate_of_pack_id) that keep syncing from their own source.

Pods that predate this change cannot read the seal. Until the rollout
completes they fail to Sync a sealed private pack, and their
import-url fails for a repository whose legacy fork became a holder
plus a duplicate. The backfill runs only where Hangfire processes
jobs, so in an Api/Worker split roll the Api pods out first.

Tokens pasted before this change were already exposed and should be
rotated.
@ppXD
ppXD deleted the fix/keep-pack-clone-tokens-out-of-messages-and-config branch October 7, 2026 07:32
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.

1 participant