mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-09-03 18:55:33 +00:00
fix: escape dynamic path segments in three Remove-* cmdlets (#161)
* fix: escape dynamic path segments in three Remove-* cmdlets Wrap Storage, Vnet, and Zone parameters with Uri.EscapeDataString() in RemovePveStorageCmdlet, RemovePveSdnVnetCmdlet, and RemovePveSdnZoneCmdlet to prevent path traversal attacks via the API path. Add xUnit test demonstrating that escaped paths preserve percent-encoding (preventing path collapse) while unescaped paths allow segment traversal. Fixes #145 * fix: route Remove-Pve{Storage,SdnVnet,SdnZone} through their services Delete the private PveHttpClient construction and inline Uri.EscapeDataString call in RemovePveStorageCmdlet, RemovePveSdnVnetCmdlet and RemovePveSdnZoneCmdlet; call StorageService.RemoveStorage / NetworkService.RemoveSdnZone / NetworkService.RemoveSdnVnet instead, which already escape the identifier identically and are now the single place doing so. Add ValidatePattern on the Storage/Vnet/Zone parameters as defense in depth, anchored with \A/\z so a trailing newline cannot slip a disallowed character past the gate. Replace PveHttpClientPathEscapingTests with a version that actually regression-tests the real Uri parser (asserts both that %2F survives and that the unescaped form is absent), and add StorageServiceTests/NetworkServiceTests cases that mock IPveHttpClient and verify the exact escaped DELETE path for a traversal-attempt name. --------- Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
68f953075d
commit
1bf7483a4e
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PSProxmoxVE.Core.Authentication;
|
||||
using PSProxmoxVE.Core.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace PSProxmoxVE.Core.Tests.Client
|
||||
{
|
||||
public class PveHttpClientPathEscapingTests
|
||||
{
|
||||
private static void SetInnerHttpClient(PveHttpClient client, HttpClient newInner)
|
||||
{
|
||||
var field = typeof(PveHttpClient).GetField("_httpClient",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
((HttpClient)field.GetValue(client)!).Dispose();
|
||||
field.SetValue(client, newInner);
|
||||
}
|
||||
|
||||
private static (PveHttpClient client, ScriptedHandler handler) NewClient(
|
||||
params (HttpStatusCode status, string body)[] responses)
|
||||
{
|
||||
var session = new PveSession("pve.example.com", 8006, false,
|
||||
"root@pam!token=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
var client = new PveHttpClient(session);
|
||||
var handler = new ScriptedHandler(responses);
|
||||
SetInnerHttpClient(client, new HttpClient(handler));
|
||||
return (client, handler);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WithEscapedPathSegment_DoesNotCollapseAcrossTheRealUriParser()
|
||||
{
|
||||
var maliciousName = "../access/users/root@pam!t";
|
||||
var (client, handler) = NewClient(
|
||||
(HttpStatusCode.OK, "{\"data\":null}"));
|
||||
|
||||
using (client)
|
||||
{
|
||||
await client.DeleteAsync($"storage/{Uri.EscapeDataString(maliciousName)}");
|
||||
}
|
||||
|
||||
Assert.Single(handler.Uris);
|
||||
var uri = handler.Uris[0];
|
||||
Assert.Contains("storage/..%2Faccess", uri);
|
||||
Assert.DoesNotContain("storage/../", uri);
|
||||
}
|
||||
|
||||
private sealed class ScriptedHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly (HttpStatusCode status, string body)[] _responses;
|
||||
private int _index;
|
||||
|
||||
public List<string> Uris { get; } = new List<string>();
|
||||
|
||||
public ScriptedHandler((HttpStatusCode status, string body)[] responses)
|
||||
{
|
||||
_responses = responses;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Uris.Add(request.RequestUri!.ToString());
|
||||
|
||||
if (_index >= _responses.Length)
|
||||
throw new InvalidOperationException("ScriptedHandler ran out of responses.");
|
||||
|
||||
var (status, body) = _responses[_index++];
|
||||
return new HttpResponseMessage(status) { Content = new StringContent(body) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using PSProxmoxVE.Core.Authentication;
|
||||
using PSProxmoxVE.Core.Client;
|
||||
using PSProxmoxVE.Core.Services;
|
||||
|
||||
namespace PSProxmoxVE.Core.Tests.Services
|
||||
{
|
||||
public class NetworkServiceTests
|
||||
{
|
||||
private readonly Mock<IPveHttpClient> _mockClient;
|
||||
private readonly NetworkService _service;
|
||||
private readonly PveSession _session;
|
||||
|
||||
public NetworkServiceTests()
|
||||
{
|
||||
_mockClient = new Mock<IPveHttpClient>();
|
||||
_service = new NetworkService(_mockClient.Object);
|
||||
_session = new PveSession(
|
||||
"pve.example.com",
|
||||
8006,
|
||||
skipCertificateCheck: true,
|
||||
apiToken: "root@pam!test=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// RemoveSdnZone
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RemoveSdnZone_EscapesPathTraversalInName()
|
||||
{
|
||||
// Arrange
|
||||
_mockClient.Setup(c => c.DeleteAsync("cluster/sdn/zones/..%2Faccess%2Fusers%2Fx"))
|
||||
.ReturnsAsync(@"{""data"":null}");
|
||||
|
||||
// Act
|
||||
_service.RemoveSdnZone(_session, "../access/users/x");
|
||||
|
||||
// Assert
|
||||
_mockClient.Verify(c => c.DeleteAsync("cluster/sdn/zones/..%2Faccess%2Fusers%2Fx"), Times.Once);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// RemoveSdnVnet
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RemoveSdnVnet_EscapesPathTraversalInName()
|
||||
{
|
||||
// Arrange
|
||||
_mockClient.Setup(c => c.DeleteAsync("cluster/sdn/vnets/..%2Faccess%2Fusers%2Fx"))
|
||||
.ReturnsAsync(@"{""data"":null}");
|
||||
|
||||
// Act
|
||||
_service.RemoveSdnVnet(_session, "../access/users/x");
|
||||
|
||||
// Assert
|
||||
_mockClient.Verify(c => c.DeleteAsync("cluster/sdn/vnets/..%2Faccess%2Fusers%2Fx"), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,20 @@ namespace PSProxmoxVE.Core.Tests.Services
|
||||
_mockClient.Verify(c => c.DeleteAsync("storage/nfs-backup"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveStorage_EscapesPathTraversalInName()
|
||||
{
|
||||
// Arrange
|
||||
_mockClient.Setup(c => c.DeleteAsync("storage/..%2Faccess%2Fusers%2Fx"))
|
||||
.ReturnsAsync(@"{""data"":null}");
|
||||
|
||||
// Act
|
||||
_service.RemoveStorage(_session, "../access/users/x");
|
||||
|
||||
// Assert
|
||||
_mockClient.Verify(c => c.DeleteAsync("storage/..%2Faccess%2Fusers%2Fx"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveStorage_NullSession_ThrowsArgumentNullException()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user