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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Security

- **Perplexity account backups kept forever and readable by other users** (`AccountService`, `CredentialBackups`): removing a Perplexity account (or resetting all of them) copied its session token into `.backup/` inside the accounts folder with default permissions and never deleted it. These backups now go to the same `credential-backups/{timestamp}/perplexity/` folder as the CLIProxyAPI ones, with owner-only permissions (0700 folder, 0600 file), and are deleted after 7 days; old backups left in `.backup/` are locked to the owner (0700) and deleted after 7 days too. Account files are now created as 0600 inside a 0700 folder.

## [1.1.11] - 2026-10-02

### Fixed
Expand Down
60 changes: 2 additions & 58 deletions src/TunnelAgent.Avalonia/Services/ProviderCatalogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public async Task InitializeAsync()
existing.Kind = provider.Kind;
}
BuildProviderList();
PruneCredentialBackups(CredentialBackupRoot, DateTime.UtcNow);
CredentialBackups.Prune(CredentialBackups.DefaultRoot, DateTime.UtcNow);
}

// ── Public API ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -321,67 +321,11 @@ private static IEnumerable<string> EnumerateManagedCredentialFiles(string authDi
private static IEnumerable<string> EnumerateOAuthCredentialFiles(string authDir, string prefix, string? email = null)
=> OAuthTokenDetector.GetTokenFiles(authDir, prefix, email);

// Backups must live outside auth-dir: CLIProxyAPI's own management UI scans
// auth-dir for credential files and would otherwise list these backups as accounts.
private static string CredentialBackupRoot =>
Path.Combine(IPlatformInfo.Current.LocalDataDirectory, "credential-backups");

private const string CredentialBackupStampFormat = "yyyyMMddHHmmss";

/// <summary>Backups hold live refresh tokens, so a removed account stays recoverable only this long.</summary>
internal static readonly TimeSpan CredentialBackupRetention = TimeSpan.FromDays(7);

/// <summary>Deletes backup folders older than <see cref="CredentialBackupRetention"/>.</summary>
internal static void PruneCredentialBackups(string root, DateTime nowUtc)
{
if (!Directory.Exists(root)) return;
foreach (var dir in Directory.GetDirectories(root))
{
if (!DateTime.TryParseExact(Path.GetFileName(dir), CredentialBackupStampFormat, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var createdUtc))
continue;
if (nowUtc - createdUtc < CredentialBackupRetention) continue;
try { Directory.Delete(dir, recursive: true); }
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[ProviderCatalogService] Failed to prune credential backup {dir}: {ex.Message}");
}
}
}

private static void CreateOwnerOnlyDirectory(string path)
{
Directory.CreateDirectory(path);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}

/// <summary>A path in <paramref name="backupDir"/> that doesn't exist yet, so a backup never replaces an earlier one.</summary>
internal static string UniqueBackupPath(string backupDir, string fileName)
{
var path = Path.Combine(backupDir, fileName);
var stem = Path.GetFileNameWithoutExtension(fileName);
var ext = Path.GetExtension(fileName);
for (var i = 2; File.Exists(path); i++)
path = Path.Combine(backupDir, $"{stem}.{i}{ext}");
return path;
}

private static void BackupAndDeleteCredentialFile(string file, string reason)
{
try
{
var root = CredentialBackupRoot;
PruneCredentialBackups(root, DateTime.UtcNow);
var backupDir = Path.Combine(root, DateTime.UtcNow.ToString(CredentialBackupStampFormat, CultureInfo.InvariantCulture));
CreateOwnerOnlyDirectory(root);
CreateOwnerOnlyDirectory(backupDir);

var backupPath = UniqueBackupPath(backupDir, Path.GetFileName(file));
File.Copy(file, backupPath, overwrite: false);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(backupPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
File.Delete(file);
var backupPath = CredentialBackups.BackupAndDelete(file, CredentialBackups.DefaultRoot);
System.Diagnostics.Debug.WriteLine($"[ProviderCatalogService] Deleted auth file ({reason}): {file}; backup: {backupPath}");
}
catch (Exception ex)
Expand Down
57 changes: 39 additions & 18 deletions src/TunnelAgent.Infrastructure/Engine/Perplexity/AccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,45 @@ namespace TunnelAgent.Infrastructure.Engine.Perplexity;
public sealed class AccountService
{
private readonly string _dir;
private readonly string _backupRoot;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };

public AccountService() : this(IPlatformInfo.Current.PerplexityAccountsDirectory) { }
/// <summary>Folder inside each backup snapshot that holds Perplexity account files.</summary>
public const string BackupSubdirectory = "perplexity";

public AccountService(string directory) => _dir = directory;
public AccountService() : this(IPlatformInfo.Current.PerplexityAccountsDirectory, CredentialBackups.DefaultRoot) { }

private void EnsureDir() => Directory.CreateDirectory(_dir);
public AccountService(string directory, string backupRoot)
{
_dir = directory;
_backupRoot = backupRoot;
PruneBackups(DateTime.UtcNow);
}

// Backups used to be written to {_dir}/.backup without permissions or retention;
// drop those along with the shared ones once they pass the retention window.
private void PruneBackups(DateTime nowUtc)
{
try
{
CredentialBackups.Prune(_backupRoot, nowUtc);
var legacy = Path.Combine(_dir, ".backup");
if (Directory.Exists(legacy))
CredentialBackups.CreateOwnerOnlyDirectory(legacy);
CredentialBackups.Prune(legacy, nowUtc);
if (Directory.Exists(legacy) && !Directory.EnumerateFileSystemEntries(legacy).Any())
Directory.Delete(legacy);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[AccountService] Failed to prune Perplexity backups: {ex.Message}");
}
}

private void BackupAndDelete(string file) =>
CredentialBackups.BackupAndDelete(file, _backupRoot, BackupSubdirectory);

private void EnsureDir() => CredentialBackups.CreateOwnerOnlyDirectory(_dir);

private string FilePath(string id) => Path.Combine(_dir, $"{id}.json");

Expand Down Expand Up @@ -88,13 +120,7 @@ public bool Remove(string accountId)
catch { }

// Backup then delete
try
{
var backupDir = Path.Combine(_dir, ".backup", DateTime.UtcNow.ToString("yyyyMMddHHmmss"));
Directory.CreateDirectory(backupDir);
File.Copy(path, Path.Combine(backupDir, Path.GetFileName(path)), overwrite: true);
File.Delete(path);
}
try { BackupAndDelete(path); }
catch { return false; }

// If it was default, promote the next account
Expand Down Expand Up @@ -148,13 +174,7 @@ public void RemoveAll()
if (!Directory.Exists(_dir)) return;
foreach (var file in Directory.GetFiles(_dir, "*.json"))
{
try
{
var backupDir = Path.Combine(_dir, ".backup", DateTime.UtcNow.ToString("yyyyMMddHHmmss"));
Directory.CreateDirectory(backupDir);
File.Copy(file, Path.Combine(backupDir, Path.GetFileName(file)), overwrite: true);
File.Delete(file);
}
try { BackupAndDelete(file); }
catch { }
}
}
Expand All @@ -163,7 +183,8 @@ private void Save(PerplexityAccountSettings account)
{
EnsureDir();
var json = JsonSerializer.Serialize(account, JsonOptions);
File.WriteAllText(FilePath(account.Id), json);
var path = FilePath(account.Id);
CredentialBackups.WriteOwnerOnly(path, json);
}

private void ClearDefault(IEnumerable<PerplexityAccountSettings> accounts)
Expand Down
102 changes: 102 additions & 0 deletions src/TunnelAgent.Infrastructure/Services/CredentialBackups.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System;
using System.Globalization;
using System.IO;

namespace TunnelAgent.Services;

/// <summary>
/// Copies of deleted credential files, kept under {LocalData}/credential-backups/{yyyyMMddHHmmss}/
/// with owner-only permissions and removed after <see cref="Retention"/>.
/// </summary>
public static class CredentialBackups
{
// Backups must live outside engine directories: CLIProxyAPI's management UI scans
// auth-dir for credential files and would otherwise list these backups as accounts.
public static string DefaultRoot =>
Path.Combine(IPlatformInfo.Current.LocalDataDirectory, "credential-backups");

internal const string StampFormat = "yyyyMMddHHmmss";

/// <summary>Backups hold live tokens, so a removed account stays recoverable only this long.</summary>
public static readonly TimeSpan Retention = TimeSpan.FromDays(7);

/// <summary>Deletes backup folders under <paramref name="root"/> older than <see cref="Retention"/>.</summary>
public static void Prune(string root, DateTime nowUtc)
{
if (!Directory.Exists(root)) return;
foreach (var dir in Directory.GetDirectories(root))
{
if (!DateTime.TryParseExact(Path.GetFileName(dir), StampFormat, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var createdUtc))
continue;
if (nowUtc - createdUtc < Retention) continue;
try { Directory.Delete(dir, recursive: true); }
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[CredentialBackups] Failed to prune {dir}: {ex.Message}");
}
}
}

/// <summary>
/// Copies <paramref name="file"/> into a new owner-only backup under <paramref name="root"/>
/// (optionally inside <paramref name="subdirectory"/>), then deletes the original.
/// Returns the backup path; throws if either step fails.
/// </summary>
public static string BackupAndDelete(string file, string root, string? subdirectory = null)
{
var now = DateTime.UtcNow;
Prune(root, now);
var stampDir = Path.Combine(root, now.ToString(StampFormat, CultureInfo.InvariantCulture));
var backupDir = subdirectory is null ? stampDir : Path.Combine(stampDir, subdirectory);
CreateOwnerOnlyDirectory(root);
CreateOwnerOnlyDirectory(stampDir);
CreateOwnerOnlyDirectory(backupDir);

var backupPath = UniquePath(backupDir, Path.GetFileName(file));
File.Copy(file, backupPath, overwrite: false);
RestrictToOwner(backupPath);
File.Delete(file);
return backupPath;
}

/// <summary>A path in <paramref name="dir"/> that doesn't exist yet, so a backup never replaces an earlier one.</summary>
public static string UniquePath(string dir, string fileName)
{
var path = Path.Combine(dir, fileName);
var stem = Path.GetFileNameWithoutExtension(fileName);
var ext = Path.GetExtension(fileName);
for (var i = 2; File.Exists(path); i++)
path = Path.Combine(dir, $"{stem}.{i}{ext}");
return path;
}

/// <summary>Sets a file to 0600 on Unix; no-op on Windows (per-user profile ACLs apply).</summary>
public static void RestrictToOwner(string file)
{
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}

/// <summary>
/// Writes <paramref name="contents"/> to <paramref name="file"/>, creating it as 0600 on Unix so the
/// data is never readable by other users, even briefly.
/// </summary>
public static void WriteOwnerOnly(string file, string contents)
{
var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write };
if (!OperatingSystem.IsWindows())
options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
using (var writer = new StreamWriter(file, new System.Text.UTF8Encoding(false), options))
writer.Write(contents);
RestrictToOwner(file);
}

/// <summary>Creates <paramref name="path"/> if needed and sets it to 0700 on Unix.</summary>
public static void CreateOwnerOnlyDirectory(string path)
{
Directory.CreateDirectory(path);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
}
50 changes: 49 additions & 1 deletion tests/TunnelAgent.Tests/EngineRegistryAndPerplexityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public void AccountService_AddSetDefaultRemove_Works()
{
using var temp = new TestTempDirectory();
var accountsDir = System.IO.Path.Combine(temp.Path, "perplexity-accounts");
var service = new TunnelAgent.Infrastructure.Engine.Perplexity.AccountService(accountsDir);
var service = new TunnelAgent.Infrastructure.Engine.Perplexity.AccountService(accountsDir, temp.File("credential-backups"));

var first = service.Add("Primary", "token-1");
var second = service.Add("Backup", "token-2");
Expand All @@ -55,4 +55,52 @@ public void AccountService_AddSetDefaultRemove_Works()
Assert.Equal(first.Id, service.GetDefault()!.Id);
Assert.Single(service.List());
}

[Fact]
public void AccountService_Remove_BacksUpOutsideAccountsDirWithOwnerOnlyPermissions()
{
using var temp = new TestTempDirectory();
var accountsDir = temp.File("perplexity-accounts");
var backupRoot = temp.File("credential-backups");
var service = new TunnelAgent.Infrastructure.Engine.Perplexity.AccountService(accountsDir, backupRoot);
var account = service.Add("Primary", "token-1");

Assert.True(service.Remove(account.Id));

Assert.False(System.IO.Directory.Exists(System.IO.Path.Combine(accountsDir, ".backup")));
var backup = Assert.Single(System.IO.Directory.GetFiles(backupRoot, "*.json", System.IO.SearchOption.AllDirectories));
Assert.Equal($"{account.Id}.json", System.IO.Path.GetFileName(backup));
Assert.Equal("perplexity", System.IO.Path.GetFileName(System.IO.Path.GetDirectoryName(backup)));
Assert.Contains("token-1", System.IO.File.ReadAllText(backup));
if (!OperatingSystem.IsWindows())
{
const System.IO.UnixFileMode ownerRw = System.IO.UnixFileMode.UserRead | System.IO.UnixFileMode.UserWrite;
Assert.Equal(ownerRw, System.IO.File.GetUnixFileMode(backup));
Assert.Equal(ownerRw, System.IO.File.GetUnixFileMode(System.IO.Path.Combine(accountsDir, $"{service.Add("Other", "t").Id}.json")));
Assert.Equal(ownerRw | System.IO.UnixFileMode.UserExecute, System.IO.File.GetUnixFileMode(accountsDir));
}
}

[Fact]
public void AccountService_PrunesExpiredLegacyBackupsInsideAccountsDir()
{
using var temp = new TestTempDirectory();
var accountsDir = temp.File("perplexity-accounts");
var legacy = System.IO.Path.Combine(accountsDir, ".backup");
var expired = System.IO.Path.Combine(legacy, DateTime.UtcNow.AddDays(-8).ToString("yyyyMMddHHmmss"));
var recent = System.IO.Path.Combine(legacy, DateTime.UtcNow.AddDays(-1).ToString("yyyyMMddHHmmss"));
foreach (var dir in new[] { expired, recent })
{
System.IO.Directory.CreateDirectory(dir);
System.IO.File.WriteAllText(System.IO.Path.Combine(dir, "a.json"), "{}");
}

_ = new TunnelAgent.Infrastructure.Engine.Perplexity.AccountService(accountsDir, temp.File("credential-backups"));

Assert.False(System.IO.Directory.Exists(expired));
Assert.True(System.IO.Directory.Exists(recent));
if (!OperatingSystem.IsWindows())
Assert.Equal(System.IO.UnixFileMode.UserRead | System.IO.UnixFileMode.UserWrite | System.IO.UnixFileMode.UserExecute,
System.IO.File.GetUnixFileMode(legacy));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public void Dispose()
private static PerplexityAccountCatalogService CreateService(TestTempDirectory temp)
{
var dir = System.IO.Path.Combine(temp.Path, "perplexity-accounts");
return new PerplexityAccountCatalogService(new AccountService(dir));
return new PerplexityAccountCatalogService(new AccountService(dir, System.IO.Path.Combine(temp.Path, "credential-backups")));
}

private sealed class InMemoryUserEnvironmentService : TunnelAgent.Services.IUserEnvironmentService
Expand Down
6 changes: 3 additions & 3 deletions tests/TunnelAgent.Tests/ProviderCatalogServiceEdgeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,9 @@ public void UniqueBackupPath_NeverReturnsAnExistingFile()
File.WriteAllText(Path.Combine(dir, "claude-me@example.com.2.json"), "{}");

Assert.Equal(Path.Combine(dir, "claude-me@example.com.3.json"),
ProviderCatalogService.UniqueBackupPath(dir, "claude-me@example.com.json"));
CredentialBackups.UniquePath(dir, "claude-me@example.com.json"));
Assert.Equal(Path.Combine(dir, "codex-me@example.com.json"),
ProviderCatalogService.UniqueBackupPath(dir, "codex-me@example.com.json"));
CredentialBackups.UniquePath(dir, "codex-me@example.com.json"));
}

[Fact]
Expand All @@ -251,7 +251,7 @@ public void PruneCredentialBackups_DeletesOnlyBackupsPastRetention()
File.WriteAllText(Path.Combine(dir, "claude-user@example.com.json"), "{\"refresh_token\":\"r\"}");
}

ProviderCatalogService.PruneCredentialBackups(root, now);
CredentialBackups.Prune(root, now);

Assert.False(Directory.Exists(expired));
Assert.True(Directory.Exists(recent));
Expand Down
Loading