Read FileZilla site exports, decoding the Base64 password

FileZilla works, every copy of its settings fails: the <Pass> 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.
This commit is contained in:
2026-08-30 21:03:57 -05:00
parent 1edd6520a3
commit b34360c917
+146 -28
View File
@@ -83,46 +83,150 @@ try {
exit 1 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 "" 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() $serverHost = ($serverHost -replace '^\w+://', '').Split('/')[0].Trim()
$protoChoice = 0
Write-Host "" Write-Host ""
Write-Host "Protocol:" -ForegroundColor White Write-Host "Protocol:" -ForegroundColor White
Write-Host " 1. SFTP" Write-Host " 1. SFTP"
Write-Host " 2. FTPS" Write-Host " 2. FTPS"
Write-Host " 3. FTP" Write-Host " 3. FTP"
while ($protoChoice -lt 1 -or $protoChoice -gt 3) { $protoDefault = if ($fromXml) { $fromXml.Proto } else { 1 }
Write-Host "Choose 1-3 " -NoNewline -ForegroundColor White $protoChoice = Read-Number "Choose 1-3" 1 3 $protoDefault
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
}
$protocol = @("sftp", "ftps", "ftp")[$protoChoice - 1] $protocol = @("sftp", "ftps", "ftp")[$protoChoice - 1]
$defaultPort = if ($protocol -eq "sftp") { 22 } else { 21 }
$port = [int](Read-Answer "Port" $defaultPort) $defaultPort = if ($fromXml -and $fromXml.Port) { $fromXml.Port }
$username = Read-Answer "Username" $null elseif ($protocol -eq "sftp") { 22 } else { 21 }
Write-Host "" $port = Read-Number "Port" 1 65535 $defaultPort
Write-Host "Paste the password. It is not echoed and not saved anywhere." -ForegroundColor DarkGray $username = Read-Answer "Username" $(if ($fromXml) { $fromXml.User } else { $null })
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 if ($fromXml -and $fromXml.Password) {
# one. Typing it twice is the only way to be sure of what actually went in. $password = $fromXml.Password
Write-Host "Type it once more to check: " -NoNewline -ForegroundColor White } else {
$secure2 = Read-Host -AsSecureString Write-Host ""
$bstr2 = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure2) Write-Host "Paste the password. It is not echoed and not saved anywhere." -ForegroundColor DarkGray
$password2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr2) Write-Host "Password: " -NoNewline -ForegroundColor White
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr2) $secure = Read-Host -AsSecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
$password = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
if ($password -ne $password2) { # 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 ""
Write-Host " !! The two did not match." -ForegroundColor Red Write-Host " !! The two did not match." -ForegroundColor Red
Write-Host " First entry: $($password.Length) characters. Second: $($password2.Length)." -ForegroundColor Red Write-Host " First entry: $($password.Length) characters. Second: $($password2.Length)." -ForegroundColor Red
@@ -130,6 +234,7 @@ if ($password -ne $password2) {
Write-Host " Start over and paste carefully, or type it by hand." -ForegroundColor Yellow Write-Host " Start over and paste carefully, or type it by hand." -ForegroundColor Yellow
Write-Host "" Write-Host ""
exit 1 exit 1
}
} }
# -- What the credentials actually contain ------------------------------------ # -- 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 Write-Host " !! The password contains a non-standard character." -ForegroundColor Yellow
$warned = $true $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) { if (-not $warned) {
Write-Host " Nothing unusual about either string." -ForegroundColor Green Write-Host " Nothing unusual about either string." -ForegroundColor Green
} }