Skip to content

Commit 4367cc2

Browse files
committed
Keep pasted pack tokens out of import errors, helpers and config
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.
1 parent 4cc86d7 commit 4367cc2

8 files changed

Lines changed: 633 additions & 22 deletions

File tree

‎backend/src/CodeSpace.Core/Services/Agents/PackCloneFetcher.cs‎

Lines changed: 105 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
using System.Text.RegularExpressions;
12
using CodeSpace.Core.DependencyInjection;
23
using CodeSpace.Core.Services.Agents.Sandbox;
34
using CodeSpace.Core.Services.Agents.Workspace;
5+
using CodeSpace.Core.Services.Agents.Workspace.Providers;
46
using CodeSpace.Messages.Agents;
57
using Microsoft.Extensions.Logging;
68

@@ -16,8 +18,14 @@ namespace CodeSpace.Core.Services.Agents;
1618
/// FAILURE (or any throw mid-clone) reclaims the partial dir immediately; (3) <see cref="IWorkspaceJanitor"/> — the
1719
/// crash-safety backstop: the recurring sweep (which fans out over every janitor) ages out a clone orphaned by a
1820
/// worker that died between clone and dispose.</para>
21+
///
22+
/// <para>A pasted URL can carry a credential in its userinfo (<see cref="PastedSecret"/>). The clone runs as a tokened command,
23+
/// so the operator's credential helpers and trace2 targets never see it, in a directory only this worker's uid can read;
24+
/// once cloned, origin is rewritten to the URL without it, so the checkout the import walks holds none; a clone failure names
25+
/// the URL without it and redacts it from git's stderr, since that message reaches the API error body, the UI and the
26+
/// mediator's error log.</para>
1927
/// </summary>
20-
public sealed class PackCloneFetcher : IPackSourceFetcher, IWorkspaceJanitor, ISingletonDependency
28+
public sealed partial class PackCloneFetcher : IPackSourceFetcher, IWorkspaceJanitor, ISingletonDependency
2129
{
2230
/// <summary>Operators tune how long an orphaned pack clone lingers before the janitor reclaims it (a TimeSpan, e.g. "00:30:00"); default 1h. Pinned by a test (Rule 8). MUST exceed the maximum possible import duration so the age-based sweep never deletes a live clone.</summary>
2331
public const string StaleThresholdEnvVar = "CODESPACE_PACK_CLONE_STALE_THRESHOLD";
@@ -50,25 +58,54 @@ public async Task<PackCheckout> FetchAsync(string url, string? reference, Cancel
5058
Directory.CreateDirectory(PackClonesRoot);
5159
var dir = Path.Combine(PackClonesRoot, Guid.NewGuid().ToString("N"));
5260

53-
SandboxResult result;
5461
try
5562
{
56-
Directory.CreateDirectory(dir);
57-
result = await _runners.Resolve(SandboxKinds.Local).RunAsync(BuildCloneSpec(url, reference, dir), cancellationToken).ConfigureAwait(false);
63+
CreateOwnerOnlyDirectory(dir);
64+
await CloneAsync(url, reference, dir, cancellationToken).ConfigureAwait(false);
65+
await StripPastedCredentialAsync(url, dir, cancellationToken).ConfigureAwait(false);
5866
}
5967
catch
6068
{
61-
TryDeleteDirectory(dir); // never leak a partial clone, even on an unexpected throw / cancellation
69+
TryDeleteDirectory(dir); // never leak a partial clone, or one still holding a pasted credential, even on an unexpected throw / cancellation
6270
throw;
6371
}
6472

73+
return new PackCheckout(dir);
74+
}
75+
76+
/// <summary>
77+
/// The clone's directory, readable by this worker's uid alone. git writes the pasted URL, credential included, into
78+
/// <c>.git/config</c> before the transfer starts, and origin is stripped only once it ends — up to the clone timeout later,
79+
/// or never when the worker dies mid-clone and leaves it to the janitor — so it is owner-only before git runs.
80+
/// </summary>
81+
private static void CreateOwnerOnlyDirectory(string dir)
82+
{
83+
Directory.CreateDirectory(dir);
84+
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(dir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
85+
}
86+
87+
/// <summary>Run the clone; a failure throws a <see cref="PackImportException"/> that names no pasted credential.</summary>
88+
private async Task CloneAsync(string url, string? reference, string dir, CancellationToken cancellationToken)
89+
{
90+
var result = await _runners.Resolve(SandboxKinds.Local).RunAsync(BuildCloneSpec(url, reference, dir), cancellationToken).ConfigureAwait(false);
91+
6592
if (result.Status != SandboxStatus.Success)
66-
{
67-
TryDeleteDirectory(dir); // clone failed → reclaim the partial dir immediately
68-
throw new PackImportException($"git clone of '{url}' failed ({result.Status}, exit {result.ExitCode}): {Summarize(result.Stderr)}");
69-
}
93+
throw new PackImportException(CloneFailedMessage(url, result));
94+
}
7095

71-
return new PackCheckout(dir);
96+
/// <summary>
97+
/// git writes the pasted URL, credential included, into the clone's origin, and the import then walks that checkout (a
98+
/// worker that dies mid-import leaves it on disk for the janitor). Rewrite origin to the URL without the credential through
99+
/// the workspace provider's own strip: set-url, else remove origin, else a <see cref="WorkspaceException"/> — and the
100+
/// caller deletes the clone on the way out.
101+
/// </summary>
102+
private async Task StripPastedCredentialAsync(string url, string dir, CancellationToken cancellationToken)
103+
{
104+
var cleanUrl = WithoutPastedCredential(url);
105+
106+
if (cleanUrl == url) return;
107+
108+
await LocalGitWorkspaceProvider.StripTokenFromRemoteAsync(_runners.Resolve(SandboxKinds.Local), CloneTimeoutSeconds, _logger, cleanUrl, dir, cancellationToken).ConfigureAwait(false);
72109
}
73110

74111
/// <summary>
@@ -93,9 +130,18 @@ internal static IReadOnlyList<string> BuildCloneArgs(string url, string? referen
93130
return args;
94131
}
95132

96-
/// <summary>The clone as the runner gets it: <see cref="BuildCloneArgs"/> in <paramref name="dir"/>, with the network. A pasted URL carrying a token clones as a <see cref="TokenedGitCommand"/>, so no credential helper stores it and no trace2 target records it.</summary>
97-
internal static SandboxSpec BuildCloneSpec(string url, string? reference, string dir) =>
98-
TokenedGitCommand.Spec(url, new SandboxSpec { Command = "git", Args = BuildCloneArgs(url, reference, dir), WorkingDirectory = dir, TimeoutSeconds = CloneTimeoutSeconds, AllowNetwork = true });
133+
/// <summary>
134+
/// The clone as the runner gets it: <see cref="BuildCloneArgs"/> in <paramref name="dir"/>, with the network. A pasted URL
135+
/// carrying a credential (<see cref="PastedSecret"/>) clones as a <see cref="TokenedGitCommand"/>, so no credential helper sees
136+
/// it and no trace2 target records it — a token pasted as the user alone too, which carries no password for
137+
/// <see cref="TokenedGitCommand.IsTokened"/> to find, yet git hands it to every helper it asks for the missing one.
138+
/// </summary>
139+
internal static SandboxSpec BuildCloneSpec(string url, string? reference, string dir)
140+
{
141+
var spec = new SandboxSpec { Command = "git", Args = BuildCloneArgs(url, reference, dir), WorkingDirectory = dir, TimeoutSeconds = CloneTimeoutSeconds, AllowNetwork = true };
142+
143+
return PastedSecret(url) is null ? spec : TokenedGitCommand.AsTokened(url, spec);
144+
}
99145

100146
// ── IWorkspaceJanitor: reclaim pack clones orphaned by a crashed worker ──────────────────────────
101147

@@ -145,6 +191,52 @@ private static void TryDeleteDirectory(string directory)
145191
}
146192
}
147193

194+
/// <summary>
195+
/// The clone failure as the operator reads it — in the API error body, the UI and the mediator's error log: the URL
196+
/// without its pasted credential, and git's stderr with that credential redacted. git hides a password, but when a token
197+
/// is pasted as the user alone it asks for a password and names that user. Pure + internal so it is unit-pinned.
198+
/// </summary>
199+
internal static string CloneFailedMessage(string url, SandboxResult result) =>
200+
$"git clone of '{WithoutPastedCredential(url)}' failed ({result.Status}, exit {result.ExitCode}): {RedactPastedCredential(url, Summarize(result.Stderr))}";
201+
202+
/// <summary>
203+
/// The part of a pasted http(s) URL's userinfo that carries its credential: the password when one is given
204+
/// (<c>x-access-token:&lt;token&gt;@</c>, <c>oauth2:&lt;token&gt;@</c>), else the user — a token pasted as the user alone
205+
/// (<c>&lt;token&gt;@</c>). Null for a URL without userinfo and for any other scheme: git never sends an ssh URL's user as a
206+
/// credential, and its <c>git@</c> names an account.
207+
/// </summary>
208+
private static string? PastedSecret(string url)
209+
{
210+
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) return null;
211+
212+
var (user, password) = uri.UserInfo.Split(':', 2) is [var u, var p] ? (u, p) : (uri.UserInfo, "");
213+
var secret = password.Length > 0 ? password : user;
214+
215+
return secret.Length > 0 ? secret : null;
216+
}
217+
218+
/// <summary>
219+
/// <paramref name="text"/> without the pasted credential. First the userinfo of every http(s) URL in it (<see cref="UrlUserInfo"/>),
220+
/// whichever part carries the token — <c>&lt;token&gt;:x-oauth-basic@</c> puts it in the user — and in whatever spelling. Then
221+
/// <see cref="PastedSecret"/> as bare text in each spelling git or a remote may echo it: a decoded user can hold the '/' or
222+
/// '@' that ends a URL's userinfo, and a remote can echo the token it was handed. A user beside a password is not bare
223+
/// text to redact: it names an account, and git names it only inside a URL, since it asks for a password only when none
224+
/// was given — masking it would mask every 'a' in git's reason, or the owner in the repository's path.
225+
/// </summary>
226+
private static string RedactPastedCredential(string url, string text) =>
227+
new SecretRedactor(Spellings(PastedSecret(url))).Redact(UrlUserInfo().Replace(text, "${scheme}" + SecretRedactor.Placeholder + "@"));
228+
229+
/// <summary><paramref name="secret"/> in each spelling git may echo it: as written, decoded (git 2.33 names a user decoded) and re-encoded (later git re-encodes it).</summary>
230+
private static IEnumerable<string> Spellings(string? secret) =>
231+
secret is null ? Array.Empty<string>() : new[] { secret, Uri.UnescapeDataString(secret), Uri.EscapeDataString(Uri.UnescapeDataString(secret)) };
232+
233+
/// <summary>An http(s) URL's userinfo in free text: what follows the scheme up to an '@', with no '/', '?', '#', whitespace or quote between.</summary>
234+
[GeneratedRegex(@"(?<scheme>https?://)[^/?#@\s'""]+@", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
235+
private static partial Regex UrlUserInfo();
236+
237+
/// <summary>The URL origin keeps and an error names: without its userinfo (<see cref="RemoteTipResolver.SanitizeUrl"/>) when that carries a <see cref="PastedSecret"/>, otherwise as written.</summary>
238+
private static string WithoutPastedCredential(string url) => PastedSecret(url) is null ? url : RemoteTipResolver.SanitizeUrl(url);
239+
148240
private static string Summarize(string stderr) =>
149241
string.IsNullOrWhiteSpace(stderr) ? "(no stderr)" : stderr.Trim().Replace("\n", " ");
150242
}

‎backend/src/CodeSpace.Core/Services/Agents/PackHostAllowlist.cs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,18 @@ internal static IReadOnlySet<string> BuildHosts(string? rawOverride)
4242
/// <summary>True when <paramref name="url"/> is a well-formed absolute https URL whose host is on <paramref name="hosts"/>; else false with an actionable <paramref name="reason"/>. Pure + internal so it's unit-pinned.</summary>
4343
internal static bool TryValidate(string url, IReadOnlySet<string> hosts, out string reason)
4444
{
45+
// Neither reason below names the input: a pasted token would otherwise ride into the API error body, the UI and the
46+
// mediator's error log. An unparseable URL has no userinfo to strip, and a URL pasted without "https://" parses the
47+
// token before its colon as the scheme.
4548
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
4649
{
47-
reason = $"'{url}' is not a valid absolute URL.";
50+
reason = "The pack source is not a valid absolute URL.";
4851
return false;
4952
}
5053

5154
if (uri.Scheme != Uri.UriSchemeHttps)
5255
{
53-
reason = $"Only https pack sources are allowed (got scheme '{uri.Scheme}'). Paste an https git URL.";
56+
reason = "Only https pack sources are allowed. Paste an https git URL.";
5457
return false;
5558
}
5659

‎backend/src/CodeSpace.Core/Services/Agents/Workspace/TokenedGitCommand.cs‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,16 @@ internal static IReadOnlyList<string> CredentialHelperReset(string remoteUrl)
5050
return new[] { "-c", $"credential.{uri.Scheme}://{uri.Authority}.helper=" };
5151
}
5252

53-
/// <summary><paramref name="spec"/> as a tokened command when the remote it can reach is tokened — <see cref="CredentialHelperReset"/> ahead of its arguments, <see cref="TraceOff"/> over its environment — otherwise unchanged.</summary>
54-
internal static SandboxSpec Spec(string remoteUrl, SandboxSpec spec)
55-
{
56-
if (!IsTokened(remoteUrl)) return spec;
53+
/// <summary><paramref name="spec"/> as a tokened command (<see cref="AsTokened"/>) when the remote it can reach is tokened, otherwise unchanged.</summary>
54+
internal static SandboxSpec Spec(string remoteUrl, SandboxSpec spec) => IsTokened(remoteUrl) ? AsTokened(remoteUrl, spec) : spec;
5755

56+
/// <summary>
57+
/// <paramref name="spec"/> as a tokened command for <paramref name="remoteUrl"/> — <see cref="CredentialHelperReset"/> ahead of
58+
/// its arguments, <see cref="TraceOff"/> over its environment — whatever <see cref="IsTokened"/> says: for a caller that knows
59+
/// the URL's userinfo is a credential without a password, as a token pasted as the user alone is.
60+
/// </summary>
61+
internal static SandboxSpec AsTokened(string remoteUrl, SandboxSpec spec)
62+
{
5863
var environment = new Dictionary<string, string>(spec.Environment);
5964
foreach (var (name, value) in TraceOff) environment[name] = value;
6065

0 commit comments

Comments
 (0)