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
8 changes: 6 additions & 2 deletions src/RemoteAgent/Files/FileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@
}
catch (Exception ex)
{
try { ctx.Response.StatusCode = 500; var b = Encoding.UTF8.GetBytes(ex.Message); ctx.Response.OutputStream.Write(b); ctx.Response.Close(); } catch { /* client gone */ }
// Log the detail server-side; return a generic, non-reflective body so no request-controlled
// data is echoed back into the response.
logger.LogWarning(ex, "File service request failed.");
try { ctx.Response.StatusCode = 500; ctx.Response.ContentType = "text/plain; charset=utf-8"; var b = Encoding.UTF8.GetBytes("Operation failed."); ctx.Response.OutputStream.Write(b); ctx.Response.Close(); } catch { /* client gone */ }

Check failure

Code scanning / CodeQL

Cross-site scripting High

User-provided value
flows to here and is written to HTML or JavaScript.
Comment thread
v1k70rk4 marked this conversation as resolved.
Dismissed
}
}

Expand Down Expand Up @@ -139,7 +142,8 @@

private static void Ok(HttpListenerContext ctx) { ctx.Response.StatusCode = 200; ctx.Response.Close(); }

private void Audit(string op, string detail) => logger.LogInformation("file {Op}: {Detail}", op, detail);
// Strip CR/LF from the request-controlled detail so a crafted path cannot forge extra audit-log lines.
private void Audit(string op, string detail) => logger.LogInformation("file {Op}: {Detail}", op, detail.Replace('\r', ' ').Replace('\n', ' '));

/// <summary>Logged-in user's home (best-effort): the system drive's Users\&lt;active console user&gt;, else the drive root.</summary>
private static string HomeDir()
Expand Down
2 changes: 1 addition & 1 deletion src/RemoteAgent/RemoteAgent.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>RemoteAgent</RootNamespace>
<AssemblyName>RemoteAgent</AssemblyName>
<Version>1.7.0.0</Version>
<Version>1.7.1.0</Version>
<ApplicationIcon>..\..\icon\app.ico</ApplicationIcon>

<!-- The service runs under SYSTEM with no user interaction. -->
Expand Down
27 changes: 24 additions & 3 deletions src/RemoteAgent/Services/VncProvisioningService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -37,7 +39,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
try
{
var secretFile = Path.Combine(_opt.EnrollmentDir, "vnc.secret");
string? password = File.Exists(secretFile) ? File.ReadAllText(secretFile).Trim() : null;
string? password = ReadSecret(secretFile);

var msi = Path.Combine(AppContext.BaseDirectory, "vnc", "tightvnc.msi");

Expand All @@ -48,7 +50,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await VncProvisioner.EnsureInstalledAsync(msi);
VncProvisioner.ApplyHardening(password);
File.WriteAllText(secretFile, password);
WriteSecret(secretFile, password);
logger.LogInformation(L.VncProvisioningService_VNCProvisionedPerDevicePassword);
}
catch (Exception ex)
Expand Down Expand Up @@ -87,14 +89,33 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
try
{
var secretFile = Path.Combine(_opt.EnrollmentDir, "vnc.secret");
var pw = File.Exists(secretFile) ? File.ReadAllText(secretFile).Trim() : null;
var pw = ReadSecret(secretFile);
if (!string.IsNullOrEmpty(pw))
await VncProvisioner.EnsureHealthyAsync(pw, msiPath);
}
catch (Exception ex) { logger.LogDebug(ex, L.VncProvisioningService_VNCPasswordReportFailed); }
}
}

// vnc.secret stores the local VNC password. Encrypt it at rest with DPAPI (machine scope; the agent
// runs as SYSTEM), transparently migrating any legacy plaintext file the first time it is read.
private static string? ReadSecret(string file)
{
if (!File.Exists(file)) return null;
var raw = File.ReadAllBytes(file);
if (raw.Length == 0) return null;
try { return Encoding.UTF8.GetString(ProtectedData.Unprotect(raw, null, DataProtectionScope.LocalMachine)).Trim(); }
catch
{
var plain = Encoding.UTF8.GetString(raw).Trim(); // legacy plaintext
try { WriteSecret(file, plain); } catch { /* best-effort migration */ }
return plain;
}
Comment on lines +107 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Differentiate legacy plaintext from decryption failure to avoid silent secret corruption.

ReadSecret currently treats any Unprotect failure as legacy plaintext. If an encrypted blob is corrupted/tampered, it gets decoded as text, re-written, and used as the active password. That can silently rotate to garbage and break the persisted secret contract.

Proposed hardening
+    private static ReadOnlySpan<byte> SecretHeader => "DPAPI1:"u8;
+
     private static string? ReadSecret(string file)
     {
         if (!File.Exists(file)) return null;
         var raw = File.ReadAllBytes(file);
         if (raw.Length == 0) return null;
-        try { return Encoding.UTF8.GetString(ProtectedData.Unprotect(raw, null, DataProtectionScope.LocalMachine)).Trim(); }
-        catch
+        // New encrypted format: header + DPAPI blob
+        if (raw.AsSpan().StartsWith(SecretHeader))
         {
-            var plain = Encoding.UTF8.GetString(raw).Trim();   // legacy plaintext
-            try { WriteSecret(file, plain); } catch { /* best-effort migration */ }
-            return plain;
+            var blob = raw.AsSpan(SecretHeader.Length).ToArray();
+            return Encoding.UTF8.GetString(
+                ProtectedData.Unprotect(blob, null, DataProtectionScope.LocalMachine)).Trim();
         }
+
+        // Legacy plaintext migration path only.
+        var plain = Encoding.UTF8.GetString(raw).Trim();
+        try { WriteSecret(file, plain); } catch { /* best-effort migration */ }
+        return plain;
     }
 
     private static void WriteSecret(string file, string password) =>
-        File.WriteAllBytes(file, ProtectedData.Protect(Encoding.UTF8.GetBytes(password), null, DataProtectionScope.LocalMachine));
+        File.WriteAllBytes(
+            file,
+            SecretHeader.ToArray()
+                .Concat(ProtectedData.Protect(Encoding.UTF8.GetBytes(password), null, DataProtectionScope.LocalMachine))
+                .ToArray());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/RemoteAgent/Services/VncProvisioningService.cs` around lines 107 - 113,
The ReadSecret method currently treats any ProtectedData.Unprotect failure as
legacy plaintext, which can silently corrupt encrypted blobs that are tampered
or corrupted by decoding them as text and re-writing them. Instead of catching
all exceptions from Unprotect, validate that the raw data actually represents
valid plaintext before treating it as legacy; consider checking if the data
decodes to reasonable text content or if there are specific indicators that
distinguish legitimate legacy plaintext from corrupted encrypted data, and only
fall back to plaintext handling when you are confident the data is not a
corrupted encrypted blob.

}

private static void WriteSecret(string file, string password) =>
File.WriteAllBytes(file, ProtectedData.Protect(Encoding.UTF8.GetBytes(password), null, DataProtectionScope.LocalMachine));

private async Task ReportSecretAsync(string password, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_opt.Telemetry.IngestUrl))
Expand Down