Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .azure-pipelines/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ parameters:

variables:
- name: 'GVFSMajorAndMinorVersion'
value: '2.0'
value: '2.1'
- name: 'GVFSRevision'
value: $(Build.BuildNumber)
- name: 'GVFSVersion'
Expand Down
2 changes: 2 additions & 0 deletions GVFS/GVFS.Common/GVFSJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ namespace GVFS.Common
[JsonSerializable(typeof(NamedPipeMessages.EnableAndAttachProjFSRequest.Response), TypeInfoPropertyName = "EnableAndAttachProjFSResponse")]
[JsonSerializable(typeof(NamedPipeMessages.GetActiveRepoListRequest))]
[JsonSerializable(typeof(NamedPipeMessages.GetActiveRepoListRequest.Response), TypeInfoPropertyName = "GetActiveRepoListResponse")]
[JsonSerializable(typeof(NamedPipeMessages.RunInstallerRequest))]
[JsonSerializable(typeof(NamedPipeMessages.RunInstallerRequest.Response), TypeInfoPropertyName = "RunInstallerResponse")]
[JsonSerializable(typeof(NamedPipeMessages.BaseResponse<string>))]
[JsonSerializable(typeof(TelemetryDaemonEventListener.PipeMessage))]
[JsonSerializable(typeof(PrettyConsoleEventListener.ConsoleOutputPayload))]
Expand Down
403 changes: 403 additions & 0 deletions GVFS/GVFS.Common/InstallerVerifier.cs

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,41 @@ public static Response FromMessage(Message message)
}
}

public class RunInstallerRequest
{
public const string Header = nameof(RunInstallerRequest);

/// <summary>
/// Full path to the installer executable.
/// </summary>
public string InstallerPath { get; set; }

/// <summary>
/// When true, skip Authenticode signature verification.
/// Product identity (ProductName) is still checked.
/// Intended for development/test builds only.
/// </summary>
public bool AllowUnsigned { get; set; }

public static RunInstallerRequest FromMessage(Message message)
{
return GVFSJsonOptions.Deserialize<RunInstallerRequest>(message.Body);
}

public Message ToMessage()
{
return new Message(Header, GVFSJsonOptions.Serialize(this));
}

public class Response : BaseResponse<RunInstallerRequest>
{
public static Response FromMessage(Message message)
{
return GVFSJsonOptions.Deserialize<Response>(message.Body);
}
}
}

public class BaseResponse<TRequest>
{
public const string Header = nameof(TRequest) + ResponseSuffix;
Expand Down
21 changes: 21 additions & 0 deletions GVFS/GVFS.Common/NamedPipes/NamedPipeServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,27 @@ public bool IsConnected
get { return !this.isStopping() && this.serverStream.IsConnected; }
}

/// <summary>
/// Impersonates the connected client for the duration of
/// <paramref name="action"/>. Inside the action,
/// <see cref="System.Security.Principal.WindowsIdentity.GetCurrent()"/>
/// returns the client's identity rather than the service's.
/// Returns false if impersonation is not supported on this
/// platform (only Windows named pipes carry caller identity).
/// </summary>
public bool TryRunAsClient(Action action)
{
ArgumentNullException.ThrowIfNull(action);

if (!OperatingSystem.IsWindows())
{
return false;
}

this.serverStream.RunAsClient(() => action());
return true;
}

public NamedPipeMessages.Message ReadMessage()
{
return NamedPipeMessages.Message.FromString(this.ReadRequest());
Expand Down
7 changes: 7 additions & 0 deletions GVFS/GVFS.Service/Handlers/RequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ protected virtual void HandleMessage(
this.TrySendResponse(tracer, response.ToMessage().ToString(), connection);
break;

case NamedPipeMessages.RunInstallerRequest.Header:
this.requestDescription = "run installer";
NamedPipeMessages.RunInstallerRequest installerRequest = NamedPipeMessages.RunInstallerRequest.FromMessage(message);
RunInstallerHandler installerHandler = new RunInstallerHandler(tracer, connection, installerRequest);
installerHandler.Run();
break;

default:
this.requestDescription = UnknownRequestDescription;
EventMetadata metadata = new EventMetadata();
Expand Down
280 changes: 280 additions & 0 deletions GVFS/GVFS.Service/Handlers/RunInstallerHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
using GVFS.Common;
using GVFS.Common.NamedPipes;
using GVFS.Common.Tracing;
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.Versioning;
using System.Security.Principal;

namespace GVFS.Service.Handlers
{
public class RunInstallerHandler
{
private const string InstallerArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /STAGEIFMOUNTED=true /LOG=\"{0}\"";

private readonly ITracer tracer;
private readonly NamedPipeServer.Connection connection;
private readonly NamedPipeMessages.RunInstallerRequest request;

public RunInstallerHandler(
ITracer tracer,
NamedPipeServer.Connection connection,
NamedPipeMessages.RunInstallerRequest request)
{
this.tracer = tracer;
this.connection = connection;
this.request = request;
}

public void Run()
{
NamedPipeMessages.RunInstallerRequest.Response response =
new NamedPipeMessages.RunInstallerRequest.Response();

try
{
string installerPath = this.request.InstallerPath;
if (string.IsNullOrWhiteSpace(installerPath))
{
response.State = NamedPipeMessages.CompletionState.Failure;
response.ErrorMessage = "Installer path is required";
this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage);
return;
}

// Resolve to full path to prevent path traversal.
installerPath = Path.GetFullPath(installerPath);

// --allow-unsigned is a debug-build-only escape hatch and
// is rejected outright in release builds. Defense-in-depth
// against a malicious client crafting a raw pipe request
// bypassing the CLI's debug-only flag registration.
#if !DEBUG
if (this.request.AllowUnsigned)
{
response.State = NamedPipeMessages.CompletionState.Failure;
response.ErrorMessage =
"--allow-unsigned is not supported in release builds. " +
"Use a signed installer.";
this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage);
return;
}
#else
// Debug builds still require the caller to be an
// Administrator when using --allow-unsigned: an unsigned
// installer is trivially attacker-controlled (any user can
// stamp ProductName="VFS for Git" onto a binary), and the
// pipe is open to BUILTIN\Users. Without this check, any
// non-admin user on a debug-build dev machine could get
// LocalSystem code execution through the service.
if (this.request.AllowUnsigned && !this.IsCallerAdministrator(out string identityError))
{
response.State = NamedPipeMessages.CompletionState.Failure;
response.ErrorMessage =
"--allow-unsigned upgrades require Administrator privileges. " +
"Either use a signed installer or run 'gvfs upgrade --allow-unsigned' " +
"from an elevated shell.";
if (!string.IsNullOrEmpty(identityError))
{
response.ErrorMessage += $" ({identityError})";
}
this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage);
return;
}
#endif

// Hold a deny-write/delete handle on the installer for the
// lifetime of verify+launch to prevent a TOCTOU swap by a
// non-admin caller (FileShare.Read allows readers but
// blocks any write, delete, or rename of the path).
FileStream installerLock;
try
{
installerLock = new FileStream(
installerPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read);
}
catch (Exception ex) when (ex is FileNotFoundException
|| ex is DirectoryNotFoundException
|| ex is UnauthorizedAccessException
|| ex is IOException)
{
response.State = NamedPipeMessages.CompletionState.Failure;
response.ErrorMessage = $"Failed to open installer for verification: {ex.Message}";
this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage);
return;
}

using (installerLock)
{
if (!InstallerVerifier.TryVerifyInstaller(
this.tracer,
installerPath,
this.request.AllowUnsigned,
out string verifyError))
{
response.State = NamedPipeMessages.CompletionState.Failure;
response.ErrorMessage = verifyError;
return;
}

string logPath = Path.Combine(
Configuration.AssemblyPath,
"ProgramData",
"upgrade-install.log");

this.tracer.RelatedInfo(
this.CreateBaseMetadata(),
$"{nameof(RunInstallerHandler)}: Verification passed, launching installer (log: {logPath})");

int exitCode = LaunchInstallerAndWait(installerPath, logPath);

EventMetadata launchMetadata = this.CreateBaseMetadata();
launchMetadata.Add("InstallerExitCode", exitCode);

if (exitCode == 0)
{
response.State = NamedPipeMessages.CompletionState.Success;
this.tracer.RelatedInfo(
launchMetadata,
$"{nameof(RunInstallerHandler)}: Installer launched successfully");
}
else
{
response.State = NamedPipeMessages.CompletionState.Failure;
response.ErrorMessage = $"Installer failed to launch (error {exitCode})";
this.tracer.RelatedError(launchMetadata, response.ErrorMessage);
}
}
}
catch (Exception ex)
{
response.State = NamedPipeMessages.CompletionState.Failure;
response.ErrorMessage = $"Failed to run installer: {ex.Message}";
EventMetadata exceptionMetadata = this.CreateBaseMetadata();
exceptionMetadata.Add("Exception", ex.ToString());
this.tracer.RelatedError(
exceptionMetadata,
$"{nameof(RunInstallerHandler)}: {response.ErrorMessage}");
}
finally
{
this.connection.TrySendResponse(response.ToMessage().ToString());
}
}

/// <summary>
/// Returns a fresh <see cref="EventMetadata"/> populated with the
/// request's identifying fields. A new instance must be used for each
/// tracer call because <see cref="JsonTracer"/> mutates the metadata
/// (adds the "Message" key); reusing the same instance across calls
/// triggers a duplicate-key exception.
/// </summary>
private EventMetadata CreateBaseMetadata()
{
EventMetadata metadata = new EventMetadata();
metadata.Add("InstallerPath", this.request?.InstallerPath);
metadata.Add("AllowUnsigned", this.request?.AllowUnsigned ?? false);
return metadata;
}

/// <summary>
/// Impersonates the pipe client and tests whether the caller is in
/// the local Administrators group. Returns false (and sets
/// <paramref name="error"/>) if impersonation fails or the caller is
/// a regular user. Required because the service runs as LocalSystem
/// and the pipe is accessible to all interactive users — without
/// this check, any non-admin user could request privileged
/// operations.
/// </summary>
private bool IsCallerAdministrator(out string error)
{
// Named-pipe client impersonation is a Windows-only capability.
// RunInstallerHandler runs in GVFS.Service which is Windows-only
// in practice, but guard anyway so we fail closed.
if (!OperatingSystem.IsWindows())
{
error = "Client identity check is not supported on this platform";
return false;
}

bool isAdmin = false;
string innerError = null;

bool impersonated = this.connection.TryRunAsClient(() =>
{
try
{
isAdmin = IsCurrentUserAdministrator();
}
catch (Exception ex)
{
innerError = ex.Message;
}
});

if (!impersonated)
{
error = "Failed to impersonate pipe client";
return false;
}

if (innerError != null)
{
error = innerError;
return false;
}

error = null;
return isAdmin;
}

[SupportedOSPlatform("windows")]
private static bool IsCurrentUserAdministrator()
{
using (WindowsIdentity identity = WindowsIdentity.GetCurrent())
{
WindowsPrincipal principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
}

/// <summary>
/// Launches the installer as a detached process. The installer will
/// stop GVFS.Service as part of its upgrade flow, so we must not wait
/// for it to exit (that would deadlock — parent waiting on child that
/// kills parent). Returns 0 if the process started, or -1 on failure.
/// </summary>
private static int LaunchInstallerAndWait(string installerPath, string logPath)
{
string args = string.Format(InstallerArgs, logPath);
try
{
Process installerProcess = new Process();
installerProcess.StartInfo = new ProcessStartInfo
{
FileName = installerPath,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
};

if (!installerProcess.Start())
{
return -1;
}

// Do NOT call WaitForExit(). The installer will stop
// GVFS.Service (our parent), so we'd deadlock.
return 0;
}
catch (Exception)
{
return -1;
}
}
}
}
Loading
Loading