Sicherer Dateitransfer mit SFTP in Lua
SFTP überträgt Dateien über SSH. Das Modul socket.ftp von LuaSocket implementiert
unverschlüsseltes FTP, ein anderes Protokoll, und kann keine SFTP-Verbindung herstellen. Dieser
Artikel behält seine veröffentlichte URL, ersetzt aber die früheren FTP-Beispiele durch einen
echten SFTP-Transport: Lua-cURL auf Basis von libcurl mit SSH-Unterstützung.
Sicherheitsaspekte
Beziehen Sie den öffentlichen Hostschlüssel des Servers über einen authentifizierten Kanal und
stellen Sie vor dem Verbindungsaufbau eine private Datei known_hosts bereit.
Akzeptieren Sie unbekannte Schlüssel nicht automatisch. Verwenden Sie ein dediziertes SSH-Konto
mit ausschließlich Leserechten und einen zugehörigen Schlüssel. Das Konto muss serverseitig auf
das vorgesehene Importverzeichnis beschränkt sein.
Der Client weist Namen mit Pfadtraversierung zurück und wertet Dateinamen niemals als Befehle aus. Diese lexikalische Prüfung kann nicht verhindern, dass der Server einen symbolischen Link auf ein Ziel außerhalb eines Verzeichnisses auflöst. Setzen Sie vor der Automatisierung von Importen eine serverseitige chroot-Umgebung oder eine gleichwertige Beschränkung voraus, ohne Links auf sensible Inhalte. Verwenden Sie ein vertrauenswürdiges Serververzeichnis, dessen Eintragsnamen keine Zeilenumbrüche enthalten: Die reine Namensauflistung von libcurl ist zeilenweise getrennt und kann solche Dateinamen nicht eindeutig darstellen.
SFTP-Transport einrichten
Verwenden Sie LuaJIT 2.1, Lua-cURL 0.3.13-1,
LuaFileSystem 1.8.0-1 und einen unterstützten libcurl-Build mit
SFTP-Unterstützung. Das Beispiel wurde mit libcurl 8.21.0 und libssh2
1.11.1 getestet. Das ausführbare Systemprogramm curl
kann eine andere Bibliothek verwenden als das Lua-Modul. Prüfen Sie die tatsächlich mit Lua-cURL
verknüpfte Bibliothek. LuaRocks muss die Header Ihres LuaJIT-Interpreters verwenden.
luarocks --lua-version=5.1 install Lua-cURL 0.3.13-1
luarocks --lua-version=5.1 install luafilesystem 1.8.0-1
Geben Sie bei Bedarf die LuaRocks-Einstellung CURL_DIR für die
libcurl-Installation mit SSH-Unterstützung an.
Siehe das Lua-cURL-Projekt, die
Hostschlüsselprüfung von libcurl und
Verzeichnisauflistungen nur mit Namen.
Verbindung zu einem SFTP-Server herstellen
Speichern Sie das folgende vollständige Modul als sftp_import.lua. Die
Verbindungseinstellungen stammen aus einer vertrauenswürdigen Bereitstellungskonfiguration.
root ist ein absolutes Verzeichnis innerhalb des eingeschränkten
Serverkontos. Die Dateien mit dem öffentlichen und dem privaten Schlüssel müssen zur dedizierten
Client-Identität dieses Kontos gehören.
local curl = require("lcurl.safe")
local lfs = require("lfs")
local M = {}
local function name_ok(name)
return type(name) == "string" and #name <= 255 and name ~= "." and name ~= ".."
and name:match("^[A-Za-z0-9][A-Za-z0-9._ -]*$") ~= nil
end
local function encode(path)
return (path:gsub("([^A-Za-z0-9/_.~-])", function(byte)
return string.format("%%%02X", byte:byte())
end))
end
function M.remote_path(root, name)
assert(type(root) == "string" and root:sub(1, 1) == "/", "Absolute remote root required")
assert(not root:find("//", 1, true), "Invalid remote root")
for part in root:gmatch("[^/]+") do assert(name_ok(part), "Invalid remote root component") end
if name ~= nil then assert(name_ok(name), "Unsafe remote name") end
local prefix = root:gsub("/+$", "") .. "/"
return prefix .. (name or "")
end
local function transfer(config, path, listing, sink)
assert(config.host:match("^[A-Za-z0-9.-]+$"), "Invalid SSH hostname")
local port = config.port or 22
assert(type(port) == "number" and port == math.floor(port) and port > 0 and port < 65536, "Invalid SSH port")
local handle = assert(curl.easy({
url = "sftp://" .. config.host .. ":" .. port .. encode(path),
protocols = curl.PROTO_SFTP,
proxy = "",
username = assert(config.user),
ssh_auth_types = curl.SSH_AUTH_PUBLICKEY,
ssh_knownhosts = assert(config.known_hosts),
ssh_private_keyfile = assert(config.private_key),
ssh_public_keyfile = assert(config.public_key),
connecttimeout = 10,
timeout = 60,
dirlistonly = listing,
writefunction = sink
}))
local ok, result, err = pcall(function() return handle:perform() end)
handle:close()
if not ok or not result then return nil, "SFTP transfer failed" end
return true
end
function M.list_directory(config)
local chunks, length = {}, 0
local ok, err = transfer(config, M.remote_path(config.root), true, function(chunk)
length = length + #chunk
if length > 1024 * 1024 then return 0 end
chunks[#chunks + 1] = chunk
return #chunk
end)
if not ok then return nil, err end
local entries = {}
for name in table.concat(chunks):gmatch("([^\n]+)") do
if name ~= "." and name ~= ".." then
if not name_ok(name) then return nil, "Unsupported directory entry" end
entries[#entries + 1] = name
end
end
table.sort(entries)
return entries
end
function M.download(config, name, output_directory)
local remote = M.remote_path(config.root, name)
-- An exclusive directory prevents overwriting an existing file or following its symlink.
assert(lfs.mkdir(output_directory), "Output directory must be new, beneath a private parent")
local destination = output_directory .. "/" .. name
local file = assert(io.open(destination, "wb"))
local size = 0
local called, ok, err = pcall(transfer, config, remote, false, function(chunk)
size = size + #chunk
if size > 64 * 1024 * 1024 then return 0 end
if not file:write(chunk) then return 0 end
return #chunk
end)
local closed = file:close()
if not called or not ok or not closed then
os.remove(destination)
return nil, "Could not complete download"
end
return destination
end
return M
Dateien und Verzeichnisse auflisten
list_directory() fordert SFTP-Verzeichniseinträge an, keinen Dateiabruf. Es gibt nur
Namen zurück; diese können sich auf Dateien oder Verzeichnisse beziehen. Es analysiert keine
Unix-Ausgabe von ls -l, setzt nicht voraus, dass jeder Eintrag eine Datei
ist, und behandelt eine erfolgreiche Auflistung nicht als Berechtigung, alles herunterzuladen.
Die restriktive Namensrichtlinie akzeptiert gewöhnliche Namen mit Leerzeichen, Punkten,
Unterstrichen und Bindestrichen. Sie weist Schrägstriche, umgekehrte Schrägstriche, Steuerzeichen,
Prozent-Escape-Sequenzen sowie . oder ..
zurück. Erweitern Sie diese Richtlinie nur mit passenden Tests für die Codierung und die
Einhaltung der Verzeichnisgrenzen. Bei einem nicht unterstützten Eintrag in der Auflistung gibt
list_directory() die Werte nil und
Unsupported directory entry zurück, ohne Teilergebnisse auszugeben.
Dateien vom SFTP-Server herunterladen
Speichern Sie dieses aufrufende Skript als import.lua. Es listet das
konfigurierte Verzeichnis auf und lädt ausschließlich die Datei herunter, deren exakten Namen
die ausführende Person angegeben hat. Das übergeordnete Ausgabeverzeichnis muss bereits privat
sein und der Anwendung gehören. Das neue Unterverzeichnis und seine Datei übernehmen die unten
gezeigte restriktive Einstellung umask.
local sftp = require("sftp_import")
local config = {
host = assert(os.getenv("SFTP_HOST")),
port = tonumber(os.getenv("SFTP_PORT") or "22"),
user = assert(os.getenv("SFTP_USER")),
known_hosts = assert(os.getenv("SFTP_KNOWN_HOSTS")),
private_key = assert(os.getenv("SFTP_PRIVATE_KEY")),
public_key = assert(os.getenv("SFTP_PUBLIC_KEY")),
root = assert(os.getenv("SFTP_ROOT"))
}
local wanted = assert(arg[1], "remote filename required")
local entries, err = sftp.list_directory(config)
assert(entries, err)
local found = false
for _, name in ipairs(entries) do if name == wanted then found = true end end
assert(found, "Requested entry is not in the directory")
local saved, failure = sftp.download(config, wanted, assert(arg[2], "new output directory required"))
assert(saved, failure)
print("Download complete")
umask 077
luajit import.lua 'report September.csv' private-imports/job-001
Downloads sind auf 64 MiB begrenzt. Ein Verzeichniseintrag, eine fehlende
Datei, ein abweichender Hostschlüssel, eine unterbrochene Übertragung oder eine Überschreitung
der Größenbegrenzung dürfen nicht als erfolgreicher Dateidownload gemeldet werden. Nach
Übertragungsfehlern werden unvollständige lokale Dateien entfernt. Das private Job-Verzeichnis
bleibt zur Überprüfung erhalten.
SFTP-Dateiimporte automatisieren
Rufen Sie dasselbe Modul über einen Scheduler mit expliziten Dateinamen und einem neuen Job-Verzeichnis auf. Entscheiden Sie im Anwendungscode, welche Einträge infrage kommen. Reine Namensauflistungen enthalten keine Informationen zu Dateitypen oder symbolischen Links. Auflistung und Download sind separate Vorgänge, daher kann der Server eine Datei dazwischen ändern. Vereinbaren Sie mit dem Absender unveränderliche Dateien oder eine atomare Veröffentlichung auf dem Server.
Fehlerbehandlung und bewährte Verfahren
Brechen Sie bei abweichenden Hostschlüsseln sicher ab. Wiederholen Sie den Vorgang erst, nachdem Sie den Betriebsfehler eingeordnet haben. Ein Authentifizierungsfehler ist kein Grund, die Hostprüfung zu deaktivieren. Halten Sie Schlüssel und Protokolle privat. Das Beispiel verwendet Bibliotheksaufrufe für Übertragungen und die Verzeichniserstellung, sodass Shell-Metazeichen Dateinamen nicht in Befehle verwandeln können. Serverseitige Zugriffsbeschränkungen bleiben unerlässlich.
Alternative Ansätze für sichere Übertragungen
Der Client sftp von OpenSSH ist eine weitere Option, wenn er über eine
API aufgerufen wird, die ein Argument-Array akzeptiert. Interpolieren Sie keinen Dateinamen in
os.execute() oder io.popen(). HTTPS ist nur dann eine separate
Option, wenn die Quelle einen authentifizierten HTTPS-Downloaddienst bereitstellt.
Fazit
Verwenden Sie für SFTP-Importe eine SSH-fähige Bibliothek, geprüfte Hostschlüssel und explizite Regeln für Remotenamen. Der FTP-Client von LuaSocket ist keine SFTP-Implementierung. Für verwaltete Importe bietet Transloadit den 🤖 Robot /sftp/import.
