Per-endpoint RunAs: Service / InteractiveUser / SpecificUser

Native per-endpoint identity instead of the schtasks bridge:
- Service (default) keeps the existing path - hooks inherit the service
  account (SYSTEM by default, or whatever you installed under).
- SpecificUser binds ProcessStartInfo.UserName / Password / Domain so
  the hook runs in a batch logon session as the named account. Useful
  for AD-write hooks that should NOT run as SYSTEM.
- InteractiveUser uses WTSQueryUserToken(WTSGetActiveConsoleSessionId)
  + DuplicateTokenEx + CreateProcessAsUser to drop the child into the
  logged-in user's session with their environment block. This is the
  real fix for "calc.exe should pop up on my desktop" - no Task
  Scheduler bridge required. Stdio is captured via inheritable
  anonymous pipes so the hook still returns stdout/stderr to the
  caller normally.

Implementation:
- New RunAsMode enum + RunAsConfig model on EndpointConfig
- ConfigStore round-trips RunAs.Password through DPAPI alongside
  bearer/HMAC/PFX secrets
- AdminPipeServer's secret-merge logic preserves the encrypted blob
  when the GUI saves an endpoint without re-typing the password
- New WebhookServer.Core.Execution.Native namespace with NativeMethods
  (P/Invoke) and InteractiveProcessLauncher (token-based launcher)
- ProcessExecutor branches on RunAs.Mode; the Service/SpecificUser
  paths share .NET's Process; InteractiveUser uses the launcher
- GUI editor gets a "Run as" section: dropdown + conditional
  username/password/load-profile fields under SpecificUser

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 09:08:08 -04:00
parent 27e5264714
commit 24d8701b65
10 changed files with 701 additions and 20 deletions
@@ -23,6 +23,8 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
Endpoint = JsonSerializer.Deserialize<EndpointConfig>(json, ConfigJson.Compact)!;
Endpoint.Bearer ??= new BearerOptions();
Endpoint.Hmac ??= new HmacOptions();
Endpoint.RunAs ??= new RunAsConfig();
Endpoint.RunAs.Password ??= new ProtectedString();
IsNew = isNew;
}
@@ -53,6 +55,58 @@ public sealed partial class EndpointEditorViewModel : ObservableObject
public Visibility HmacVisible =>
Endpoint.AuthMode == AuthMode.Hmac ? Visibility.Visible : Visibility.Collapsed;
public Array RunAsModes { get; } = Enum.GetValues(typeof(RunAsMode));
public RunAsMode SelectedRunAsMode
{
get => Endpoint.RunAs?.Mode ?? RunAsMode.Service;
set
{
Endpoint.RunAs ??= new RunAsConfig();
if (Endpoint.RunAs.Mode == value) return;
Endpoint.RunAs.Mode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(SpecificUserVisible));
}
}
public Visibility SpecificUserVisible =>
SelectedRunAsMode == RunAsMode.SpecificUser ? Visibility.Visible : Visibility.Collapsed;
public string RunAsUsername
{
get => Endpoint.RunAs?.Username ?? "";
set
{
Endpoint.RunAs ??= new RunAsConfig();
Endpoint.RunAs.Username = string.IsNullOrEmpty(value) ? null : value;
OnPropertyChanged();
}
}
public string RunAsPassword
{
get => Endpoint.RunAs?.Password?.Plaintext ?? "";
set
{
Endpoint.RunAs ??= new RunAsConfig();
Endpoint.RunAs.Password ??= new ProtectedString();
Endpoint.RunAs.Password.Plaintext = string.IsNullOrEmpty(value) ? null : value;
OnPropertyChanged();
}
}
public bool RunAsLoadProfile
{
get => Endpoint.RunAs?.LoadProfile ?? false;
set
{
Endpoint.RunAs ??= new RunAsConfig();
Endpoint.RunAs.LoadProfile = value;
OnPropertyChanged();
}
}
public string AllowedClientsText
{
get => string.Join(Environment.NewLine, Endpoint.AllowedClients);