Skip to content
Merged
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
3 changes: 3 additions & 0 deletions src/WindowController.App/HotkeyManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,10 @@ public HotkeyRegistrationResult TestHotkey(HotkeyBinding binding)

// Try to register with a temporary ID that will not collide with real hotkey IDs.
// Use unique IDs to avoid cross-thread interference.
// Guard against wraparound into the profile hotkey ID range.
var testId = unchecked(_nextTestHotkeyId--);
if (_nextTestHotkeyId <= HOTKEY_ID_PROFILE_BASE + 100_000)
_nextTestHotkeyId = int.MaxValue;
var result = NativeMethods.RegisterHotKey(hwnd, testId, modifiers | NativeMethods.MOD_NOREPEAT, (uint)vkCode);
if (result)
{
Expand Down
36 changes: 34 additions & 2 deletions src/WindowController.App/ProfileApplier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,31 @@ private async Task<ApplyResult> ApplyProfileAsync(Profile profile, bool launchMi
return new ApplyResult(applied, profile.Windows.Count, failures, warnings);
}

// Extensions blocked from being launched via UseShellExecute to prevent
// arbitrary script execution through malicious profile data.
private static readonly HashSet<string> BlockedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".bat", ".cmd", ".ps1", ".psm1", ".psd1",
".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh",
".msi", ".msp", ".scr", ".hta", ".inf", ".reg",
".com", ".pif"
};

private const int LaunchTimeoutMs = 12_000;
private const int LaunchPollIntervalMs = 300;

/// <summary>
/// Returns true if the path points to a genuine executable (.exe) and not
/// a script or installer that could be used for arbitrary code execution.
/// </summary>
private static bool IsAllowedLaunchPath(string filePath)
{
var ext = Path.GetExtension(filePath);
if (BlockedExtensions.Contains(ext))
return false;
return true;
}

private async Task<nint> LaunchAndWaitAsync(WindowEntry entry, List<WindowCandidate> existingCandidates)
{
var exe = entry.Match.Exe;
Expand All @@ -164,6 +189,13 @@ private async Task<nint> LaunchAndWaitAsync(WindowEntry entry, List<WindowCandid
{
var startPath = !string.IsNullOrEmpty(path) && File.Exists(path) ? path : exe;

// Block potentially dangerous file types (scripts, installers, etc.)
if (!IsAllowedLaunchPath(startPath))
{
_log.Warning("Launch blocked for potentially unsafe file type: {Path}", startPath);
return 0;
}

var psi = new ProcessStartInfo(startPath);
if (!string.IsNullOrEmpty(url))
{
Expand All @@ -190,9 +222,9 @@ private async Task<nint> LaunchAndWaitAsync(WindowEntry entry, List<WindowCandid

// Wait for new window
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < 12000)
while (sw.ElapsedMilliseconds < LaunchTimeoutMs)
{
await Task.Delay(300);
await Task.Delay(LaunchPollIntervalMs);
var wins = _enumerator.EnumerateWindows();
foreach (var w in wins)
{
Expand Down
71 changes: 47 additions & 24 deletions src/WindowController.App/SyncManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@ public class SyncManager : IDisposable
private Dictionary<nint, int> _lastMinMaxByHwnd = new();
private Dictionary<string, nint> _lastForegroundByProfile = new();
private Dictionary<string, long> _lastForegroundTickByProfile = new();
private bool _isPropagating;
private volatile bool _isPropagating;
private long _lastRebuildTick;
private bool _disposed;

// Protects _syncGroups, _profileNames, _lastMinMaxByHwnd,
// _lastForegroundByProfile, _lastForegroundTickByProfile against
// concurrent access from WinEvent callbacks and ScheduleRebuild (Task.Run).
private readonly object _syncLock = new();

// Debounce rebuild: avoid multiple rapid rebuilds
private CancellationTokenSource? _rebuildCts;
private readonly object _rebuildLock = new();
Expand All @@ -36,6 +41,9 @@ public class SyncManager : IDisposable
private long _lastEventTick;
private const long EventThrottleMs = 30;

private const long ForegroundDebounceMs = 250;
private const long RebuildCooldownMs = 2000;

public SyncManager(ProfileStore store, WindowEnumerator enumerator,
WinEventHookManager hookManager, ILogger log)
{
Expand Down Expand Up @@ -74,9 +82,12 @@ public void RebuildGroups()
}
}

_syncGroups = newGroups;
_profileNames = newNames;
_lastRebuildTick = Environment.TickCount64;
lock (_syncLock)
{
_syncGroups = newGroups;
_profileNames = newNames;
_lastRebuildTick = Environment.TickCount64;
}
}

/// <summary>
Expand Down Expand Up @@ -117,9 +128,12 @@ public void UpdateHooksIfNeeded(bool skipRebuild = false)
else
{
_hookManager.Uninstall();
_syncGroups.Clear();
_profileNames.Clear();
_lastMinMaxByHwnd.Clear();
lock (_syncLock)
{
_syncGroups.Clear();
_profileNames.Clear();
_lastMinMaxByHwnd.Clear();
}
}
}

Expand Down Expand Up @@ -152,9 +166,12 @@ private void OnWinEvent(uint eventType, nint hwnd)
if (!NativeMethods.IsWindow(hwnd)) return;
var mm = WindowEnumerator.GetMinMax(hwnd);

if (_lastMinMaxByHwnd.TryGetValue(hwnd, out var prev) && prev == mm)
return;
_lastMinMaxByHwnd[hwnd] = mm;
lock (_syncLock)
{
if (_lastMinMaxByHwnd.TryGetValue(hwnd, out var prev) && prev == mm)
return;
_lastMinMaxByHwnd[hwnd] = mm;
}

var groups = GetGroupsContainingHwnd(hwnd);
if (groups.Count == 0)
Expand Down Expand Up @@ -239,7 +256,7 @@ private void PropagateMinMax(string profileId, HashSet<nint> group, nint sourceH
{
NativeMethods.ShowWindow(target, NativeMethods.SW_RESTORE);
}
_lastMinMaxByHwnd[target] = mm;
lock (_syncLock) { _lastMinMaxByHwnd[target] = mm; }
count++;
}
catch (Exception ex) { _log.Debug(ex, "PropagateMinMax failed for target {Target}", target); }
Expand All @@ -254,14 +271,17 @@ private void PropagateMinMax(string profileId, HashSet<nint> group, nint sourceH
private void PropagateForeground(string profileId, HashSet<nint> group, nint sourceHwnd)
{
var now = Environment.TickCount64;
if (_lastForegroundTickByProfile.TryGetValue(profileId, out var lastTick) &&
now - lastTick < 250 &&
_lastForegroundByProfile.TryGetValue(profileId, out var lastHwnd) &&
lastHwnd == sourceHwnd)
return;
lock (_syncLock)
{
if (_lastForegroundTickByProfile.TryGetValue(profileId, out var lastTick) &&
now - lastTick < ForegroundDebounceMs &&
_lastForegroundByProfile.TryGetValue(profileId, out var lastHwnd) &&
lastHwnd == sourceHwnd)
return;

_lastForegroundTickByProfile[profileId] = now;
_lastForegroundByProfile[profileId] = sourceHwnd;
_lastForegroundTickByProfile[profileId] = now;
_lastForegroundByProfile[profileId] = sourceHwnd;
}

int count = 0;
foreach (var target in group)
Expand All @@ -287,18 +307,21 @@ private void PropagateForeground(string profileId, HashSet<nint> group, nint sou

private List<(string Id, HashSet<nint> Group)> GetGroupsContainingHwnd(nint hwnd)
{
var result = new List<(string, HashSet<nint>)>();
foreach (var (id, group) in _syncGroups)
lock (_syncLock)
{
if (group.Contains(hwnd))
result.Add((id, group));
var result = new List<(string, HashSet<nint>)>();
foreach (var (id, group) in _syncGroups)
{
if (group.Contains(hwnd))
result.Add((id, group));
}
return result;
}
return result;
}

private void TryRebuild()
{
if (Environment.TickCount64 - _lastRebuildTick > 2000)
if (Environment.TickCount64 - _lastRebuildTick > RebuildCooldownMs)
ScheduleRebuild(50);
}

Expand Down
3 changes: 1 addition & 2 deletions src/WindowController.Core/AppSettingsStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ public class AppSettingsStore
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

private readonly string _filePath;
Expand Down
36 changes: 30 additions & 6 deletions src/WindowController.Core/PathNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,44 @@ public static string Normalize(string? path)
// Drive path: C:\\\\foo -> C:\foo
if (path.Length >= 3 && char.IsLetter(path[0]) && path[1] == ':' && path[2] == '\\')
{
while (path.Contains("\\\\"))
path = path.Replace("\\\\", "\\");
return path;
return NormalizeBackslashes(path);
}

// UNC: \\\\server\\share -> \\server\share
if (path.StartsWith("\\\\"))
{
var tail = path.TrimStart('\\');
while (tail.Contains("\\\\"))
tail = tail.Replace("\\\\", "\\");
return "\\\\" + tail;
return "\\\\" + NormalizeBackslashes(tail);
}

return path;
}

/// <summary>
/// Replace consecutive backslashes with a single backslash in one pass.
/// </summary>
private static string NormalizeBackslashes(string value)
{
// Fast-path: no double backslash present
if (!value.Contains("\\\\"))
return value;

var sb = new System.Text.StringBuilder(value.Length);
bool prevWasBackslash = false;
foreach (var ch in value)
{
if (ch == '\\')
{
if (!prevWasBackslash)
sb.Append(ch);
prevWasBackslash = true;
}
else
{
sb.Append(ch);
prevWasBackslash = false;
}
}
return sb.ToString();
}
}
15 changes: 7 additions & 8 deletions src/WindowController.Core/ProfileStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ public class ProfileStore
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = null, // respect JsonPropertyName attributes
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
PropertyNamingPolicy = null // respect JsonPropertyName attributes
};

private string _filePath;
Expand Down Expand Up @@ -62,12 +61,12 @@ public void Load()
{
var json = File.ReadAllText(_filePath);
var parsed = JsonSerializer.Deserialize<ProfilesRoot>(json, JsonOptions);

// Detect migration need before normalization
bool needsMigration = parsed != null && parsed.Profiles.Any(p =>
string.IsNullOrEmpty(p.Id) ||
bool needsMigration = parsed != null && parsed.Profiles.Any(p =>
string.IsNullOrEmpty(p.Id) ||
parsed.Profiles.Count(x => x.Id == p.Id) > 1);

var normalized = NormalizeData(parsed ?? new ProfilesRoot());
Data = normalized;

Expand Down Expand Up @@ -156,11 +155,11 @@ public bool DeleteProfileById(string id)

var trimmed = desiredName.Trim();
var finalName = ResolveUniqueName(trimmed, profileId);

// Skip save if the name hasn't actually changed
if (finalName == profile.Name)
return finalName;

profile.Name = finalName;
profile.UpdatedAt = DateTime.Now.ToString("yyyy-MM-dd'T'HH:mm:ss");
Save();
Expand Down
Loading