From b34360c917653adae48515c7e8b168d8c58772d2 Mon Sep 17 00:00:00 2001 From: Brad Lance Date: Sun, 30 Aug 2026 21:03:57 -0500 Subject: [PATCH] Read FileZilla site exports, decoding the Base64 password FileZilla works, every copy of its settings fails: the value in an exported sitemanager XML is Base64, not the password. Copied out verbatim it is rejected by the server every time, which is exactly the "works in the GUI, fails in a script" shape we have been chasing. The test can now read the export itself - listing sites by name so the right practice is picked out of the nine - and decodes the password, reporting both lengths so the difference is visible. A master-password protected export (encoding="crypt") cannot be read, and says so. A typed password that is still Base64 is flagged the same way, since pasting it by hand is the other route to the same mistake. Guarded against false positives: it must decode to printable ASCII, so ordinary passwords are not accused. --- Test-Login.ps1 | 188 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 153 insertions(+), 35 deletions(-) diff --git a/Test-Login.ps1 b/Test-Login.ps1 index 9e63689..f0457d1 100644 --- a/Test-Login.ps1 +++ b/Test-Login.ps1 @@ -83,53 +83,158 @@ try { exit 1 } +function Read-Number($question, $min, $max, $default) { + while ($true) { + Write-Host "$question " -NoNewline -ForegroundColor White + Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray + Write-Host ": " -NoNewline -ForegroundColor White + $a = Read-Host + if ([string]::IsNullOrWhiteSpace($a)) { $a = [string]$default } + $n = 0 + if ([int]::TryParse(([string]$a).Trim(), [ref]$n) -and $n -ge $min -and $n -le $max) { return $n } + Write-Host " (a number between $min and $max)" -ForegroundColor Yellow + } +} + +# FileZilla keeps site passwords Base64-encoded, so the value sitting in the +# exported XML is not the password - it has to be decoded first. Copying it +# out verbatim fails against the server every time, which is exactly what a +# working FileZilla login plus a failing script looks like. +function Import-FileZillaSite { + Write-Host "" + Write-Host "In FileZilla: File > Export, tick 'Site Manager entries', save the .xml." -ForegroundColor Gray + $path = Read-Answer "Path to the exported .xml" $null + $path = $path.Trim('"').Trim() + if (-not (Test-Path $path)) { + Write-Host " Nothing at that path." -ForegroundColor Red + return $null + } + + try { + $xml = [xml](Get-Content $path -Raw) + } catch { + Write-Host " That file is not readable XML - $($_.Exception.Message)" -ForegroundColor Red + return $null + } + + $servers = @($xml.SelectNodes("//Server")) + if ($servers.Count -eq 0) { + Write-Host " No sites found in that file." -ForegroundColor Red + return $null + } + + $node = $servers[0] + if ($servers.Count -gt 1) { + Write-Host "" + Write-Host "Sites in that file:" -ForegroundColor White + for ($i = 0; $i -lt $servers.Count; $i++) { + $n = $servers[$i] + Write-Host (" {0}. {1} {2}@{3}:{4}" -f ($i + 1), $n.Name, $n.User, $n.Host, $n.Port) + } + $node = $servers[(Read-Number "Which one" 1 $servers.Count 1) - 1] + } + + $pass = $null + $passNode = $node.SelectSingleNode("Pass") + if ($passNode) { + $encoding = $passNode.GetAttribute("encoding") + if ($encoding -eq "base64") { + try { + $pass = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($passNode.InnerText)) + Write-Host "" + Write-Host " Decoded the password from Base64." -ForegroundColor Green + Write-Host " The XML holds $($passNode.InnerText.Length) characters; the real" -ForegroundColor Gray + Write-Host " password is $($pass.Length). Using the decoded one." -ForegroundColor Gray + } catch { + Write-Host " The password in the XML is not valid Base64." -ForegroundColor Yellow + } + } elseif ($encoding -eq "crypt") { + Write-Host "" + Write-Host " This export is protected by a FileZilla master password," -ForegroundColor Yellow + Write-Host " so the password cannot be read out of it. Type it instead." -ForegroundColor Yellow + } else { + $pass = $passNode.InnerText + } + } + + # FileZilla's protocol numbering: 1 is SFTP, 0 is plain FTP, and the + # TLS variants use higher values. Only used as the menu default. + $proto = 1 + switch ([string]$node.Protocol) { + "0" { $proto = 3 } + "1" { $proto = 1 } + "3" { $proto = 2 } + "4" { $proto = 2 } + } + + return @{ + Host = [string]$node.Host + Port = [string]$node.Port + User = [string]$node.User + Password = $pass + Proto = $proto + } +} + +$fromXml = $null Write-Host "" -$serverHost = Read-Answer "Server" $null +Write-Host "Where are the details coming from?" -ForegroundColor White +Write-Host " 1. I will type them in" +Write-Host " 2. Read them from a FileZilla site export (.xml)" +if ((Read-Number "Choose 1-2" 1 2 1) -eq 2) { + $fromXml = Import-FileZillaSite + if (-not $fromXml) { + Write-Host "" + Write-Host "Falling back to typing them in." -ForegroundColor Yellow + } +} + +Write-Host "" +$serverHost = Read-Answer "Server" $(if ($fromXml) { $fromXml.Host } else { $null }) $serverHost = ($serverHost -replace '^\w+://', '').Split('/')[0].Trim() -$protoChoice = 0 Write-Host "" Write-Host "Protocol:" -ForegroundColor White Write-Host " 1. SFTP" Write-Host " 2. FTPS" Write-Host " 3. FTP" -while ($protoChoice -lt 1 -or $protoChoice -gt 3) { - Write-Host "Choose 1-3 " -NoNewline -ForegroundColor White - Write-Host "[1]" -NoNewline -ForegroundColor DarkGray - Write-Host ": " -NoNewline -ForegroundColor White - $a = Read-Host - if ([string]::IsNullOrWhiteSpace($a)) { $a = "1" } - [int]::TryParse($a.Trim(), [ref]$protoChoice) | Out-Null -} +$protoDefault = if ($fromXml) { $fromXml.Proto } else { 1 } +$protoChoice = Read-Number "Choose 1-3" 1 3 $protoDefault $protocol = @("sftp", "ftps", "ftp")[$protoChoice - 1] -$defaultPort = if ($protocol -eq "sftp") { 22 } else { 21 } -$port = [int](Read-Answer "Port" $defaultPort) -$username = Read-Answer "Username" $null -Write-Host "" -Write-Host "Paste the password. It is not echoed and not saved anywhere." -ForegroundColor DarkGray -Write-Host "Password: " -NoNewline -ForegroundColor White -$secure = Read-Host -AsSecureString -$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) -$password = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) -[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) +$defaultPort = if ($fromXml -and $fromXml.Port) { $fromXml.Port } + elseif ($protocol -eq "sftp") { 22 } else { 21 } +$port = Read-Number "Port" 1 65535 $defaultPort +$username = Read-Answer "Username" $(if ($fromXml) { $fromXml.User } else { $null }) -# Nothing echoes while typing, so a slipped paste looks identical to a good -# one. Typing it twice is the only way to be sure of what actually went in. -Write-Host "Type it once more to check: " -NoNewline -ForegroundColor White -$secure2 = Read-Host -AsSecureString -$bstr2 = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure2) -$password2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr2) -[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr2) - -if ($password -ne $password2) { +if ($fromXml -and $fromXml.Password) { + $password = $fromXml.Password +} else { Write-Host "" - Write-Host " !! The two did not match." -ForegroundColor Red - Write-Host " First entry: $($password.Length) characters. Second: $($password2.Length)." -ForegroundColor Red - Write-Host " One of them is wrong, so this test would prove nothing." -ForegroundColor Red - Write-Host " Start over and paste carefully, or type it by hand." -ForegroundColor Yellow - Write-Host "" - exit 1 + Write-Host "Paste the password. It is not echoed and not saved anywhere." -ForegroundColor DarkGray + Write-Host "Password: " -NoNewline -ForegroundColor White + $secure = Read-Host -AsSecureString + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) + $password = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + + # Nothing echoes while typing, so a slipped paste looks identical to a good + # one. Typing it twice is the only way to be sure of what actually went in. + Write-Host "Type it once more to check: " -NoNewline -ForegroundColor White + $secure2 = Read-Host -AsSecureString + $bstr2 = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure2) + $password2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr2) + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr2) + + if ($password -ne $password2) { + Write-Host "" + Write-Host " !! The two did not match." -ForegroundColor Red + Write-Host " First entry: $($password.Length) characters. Second: $($password2.Length)." -ForegroundColor Red + Write-Host " One of them is wrong, so this test would prove nothing." -ForegroundColor Red + Write-Host " Start over and paste carefully, or type it by hand." -ForegroundColor Yellow + Write-Host "" + exit 1 + } } # -- What the credentials actually contain ------------------------------------ @@ -163,6 +268,19 @@ if ($password -match '[^\x20-\x7E]') { Write-Host " !! The password contains a non-standard character." -ForegroundColor Yellow $warned = $true } +# A password copied straight out of a FileZilla export is still Base64. +if (-not $fromXml -and $password.Length -ge 8 -and $password -cmatch '^[A-Za-z0-9+/]+={0,2}$' -and ($password.Length % 4) -eq 0) { + $decoded = $null + try { $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($password)) } catch { } + if ($decoded -and $decoded -match '^[\x20-\x7E]+$') { + Write-Host " !! This looks like Base64, not a password." -ForegroundColor Yellow + Write-Host " FileZilla stores passwords Base64-encoded in its exported" -ForegroundColor Yellow + Write-Host " XML. Decoded, this is $($decoded.Length) characters - that is" -ForegroundColor Yellow + Write-Host " probably the real password. Re-run and choose option 2 to" -ForegroundColor Yellow + Write-Host " read the export directly, or paste the decoded value." -ForegroundColor Yellow + $warned = $true + } +} if (-not $warned) { Write-Host " Nothing unusual about either string." -ForegroundColor Green }