mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-09-06 12:09:01 +00:00
fix: take the API token as a SecureString and stop exposing session credentials (#240)
Connect-PveServer -ApiToken was a plain string, so the token landed verbatim in PSReadLine history and any transcript, and PveSession published ApiToken, Ticket and CsrfToken as public getters, so Format-List *, ConvertTo-Json and Export-Clixml of a session printed them. The parameter is now a SecureString, extracted at the cmdlet boundary with the Marshal/ZeroFree pattern ADR 0002 established for passwords. A plain string still binds for one minor release through an argument transformation, and the cmdlet warns that the string form goes away in the next major; the marker lives in a ConditionalWeakTable keyed on the converted instance, so an abandoned binding neither retains the secret nor mislabels a later call. The three session getters become internal. PveHttpClient is in the same assembly and the xUnit project already has InternalsVisibleTo, so the header construction and its tests are unchanged. Refs ADR 0028, issue #147. Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
8d955a0ea8
commit
c8a7e809bb
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Xunit;
|
||||
using PSProxmoxVE.Core.Authentication;
|
||||
|
||||
@@ -184,6 +186,49 @@ namespace PSProxmoxVE.Core.Tests.Authentication
|
||||
Assert.True(session.SkipCertificateCheck);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ApiToken")]
|
||||
[InlineData("Ticket")]
|
||||
[InlineData("CsrfToken")]
|
||||
public void CredentialProperties_AreNotPubliclyReadable(string propertyName)
|
||||
{
|
||||
var property = typeof(PveSession).GetProperty(
|
||||
propertyName,
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
|
||||
Assert.NotNull(property);
|
||||
Assert.False(property!.GetMethod!.IsPublic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicProperties_ExposeNoApiToken()
|
||||
{
|
||||
const string token = "root@pam!mytoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
var session = new PveSession(TestHostname, TestPort, false, token);
|
||||
|
||||
Assert.DoesNotContain(RenderPublicProperties(session), v => v.Contains(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicProperties_ExposeNoTicketOrCsrfToken()
|
||||
{
|
||||
const string ticket = "PVE:root@pam:SECRETTICKET";
|
||||
const string csrf = "SECRETCSRFTOKEN";
|
||||
var session = new PveSession(TestHostname, TestPort, false, "root@pam", ticket, csrf,
|
||||
DateTime.UtcNow.AddHours(2));
|
||||
|
||||
var rendered = RenderPublicProperties(session);
|
||||
Assert.DoesNotContain(rendered, v => v.Contains(ticket));
|
||||
Assert.DoesNotContain(rendered, v => v.Contains(csrf));
|
||||
}
|
||||
|
||||
private static string[] RenderPublicProperties(PveSession session) =>
|
||||
typeof(PveSession)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Select(p => p.GetValue(session)?.ToString())
|
||||
.Where(v => v != null)
|
||||
.ToArray()!;
|
||||
|
||||
[Fact]
|
||||
public void Timeout_DefaultIs100Seconds()
|
||||
{
|
||||
|
||||
@@ -35,12 +35,73 @@ Describe 'Connect-PveServer' {
|
||||
Connect-PveServer `
|
||||
-Server 'pve.example.com' `
|
||||
-Credential $cred `
|
||||
-ApiToken 'root@pam!mytoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' `
|
||||
-ApiToken (ConvertTo-SecureString 'root@pam!mytoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' -AsPlainText -Force) `
|
||||
-ErrorAction Stop
|
||||
} | Should -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Context 'ApiToken accepts a SecureString and warns on a plain string' {
|
||||
BeforeAll {
|
||||
# Rejected by the token-format check before any HTTP call, so these cases stay offline.
|
||||
$script:BadToken = 'not-a-valid-token'
|
||||
}
|
||||
|
||||
It 'ApiToken should be typed SecureString' {
|
||||
(Get-Command 'Connect-PveServer').Parameters['ApiToken'].ParameterType |
|
||||
Should -Be ([System.Security.SecureString])
|
||||
}
|
||||
|
||||
It 'Should bind a plain string and warn that it is deprecated' {
|
||||
$warnings = @()
|
||||
$err = $null
|
||||
try {
|
||||
Connect-PveServer -Server 'pve.example.com' -ApiToken $script:BadToken `
|
||||
-ErrorAction Stop -WarningVariable +warnings
|
||||
} catch { $err = $_ }
|
||||
$err.FullyQualifiedErrorId | Should -Match 'PveAuthenticationFailed'
|
||||
($warnings -join "`n") | Should -Match 'deprecated'
|
||||
}
|
||||
|
||||
It 'Should not warn about deprecation when given a SecureString' {
|
||||
$secure = ConvertTo-SecureString $script:BadToken -AsPlainText -Force
|
||||
$warnings = @()
|
||||
$err = $null
|
||||
try {
|
||||
Connect-PveServer -Server 'pve.example.com' -ApiToken $secure `
|
||||
-ErrorAction Stop -WarningVariable +warnings
|
||||
} catch { $err = $_ }
|
||||
$err.FullyQualifiedErrorId | Should -Match 'PveAuthenticationFailed'
|
||||
($warnings -join "`n") | Should -Not -Match 'deprecated'
|
||||
}
|
||||
|
||||
It 'Should not warn when a SecureString follows a binding failure that transformed a string' {
|
||||
$securePass = ConvertTo-SecureString 'hunter2' -AsPlainText -Force
|
||||
$cred = [System.Management.Automation.PSCredential]::new('root@pam', $securePass)
|
||||
try {
|
||||
Connect-PveServer -Server 'pve.example.com' -Credential $cred `
|
||||
-ApiToken $script:BadToken -ErrorAction Stop
|
||||
} catch { }
|
||||
|
||||
$secure = ConvertTo-SecureString $script:BadToken -AsPlainText -Force
|
||||
$warnings = @()
|
||||
try {
|
||||
Connect-PveServer -Server 'pve.example.com' -ApiToken $secure `
|
||||
-ErrorAction Stop -WarningVariable +warnings
|
||||
} catch { }
|
||||
($warnings -join "`n") | Should -Not -Match 'deprecated'
|
||||
}
|
||||
|
||||
It 'Should reject an empty SecureString as an invalid argument' {
|
||||
$err = $null
|
||||
try {
|
||||
Connect-PveServer -Server 'pve.example.com' `
|
||||
-ApiToken (New-Object System.Security.SecureString) -ErrorAction Stop
|
||||
} catch { $err = $_ }
|
||||
$err.FullyQualifiedErrorId | Should -Match 'ApiTokenEmpty'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'Parameter metadata' {
|
||||
BeforeAll {
|
||||
$script:Cmd = Get-Command 'Connect-PveServer'
|
||||
|
||||
Reference in New Issue
Block a user