ftppr: the server's answer to USER reaches the client (#1284)
Some checks are pending
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI MacOS / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI Windows / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ubuntu-latest (wolfSSL) (push) Waiting to run

A server which logs in on USER alone (230, anonymous FTP often does) or
refuses it had its answer read but not handed back: ftplogin() gave the
length of what it read only for the answer to PASS. The client, told
331 by ftppr and waiting for the answer to its PASS, waited until the
time out. The answer to USER is the client's now when there is no PASS.

The test case logs in through ftppr to a server answering 230, a
multiline 230, 331 then 230, 530, and 331 then 530.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vladimir Dubrovin 2026-10-26 17:39:25 +03:00
parent 359ce5f8b4
commit e3ef7bb54a
2 changed files with 110 additions and 1 deletions

View File

@ -38,7 +38,7 @@ int ftplogin(struct clientparam *param, char *nbuf, int *innbuf) {
if(i < 3) return 706; if(i < 3) return 706;
buf[i] = 0; buf[i] = 0;
if(atoi(buf)/100 != 2) { if(atoi(buf)/100 != 2) {
*innbuf = i; if(innbuf)*innbuf = i;
return 702; return 702;
} }
sprintf(buf, "USER %.128s\r\n", *param->extusername?param->extusername:(unsigned char *)"anonymous"); sprintf(buf, "USER %.128s\r\n", *param->extusername?param->extusername:(unsigned char *)"anonymous");
@ -74,6 +74,9 @@ int ftplogin(struct clientparam *param, char *nbuf, int *innbuf) {
return 701; return 701;
} }
} }
/* USER answered at once, logged in (230) or refused: that answer is
the client's, waiting for one to its PASS */
else if(innbuf)*innbuf = i;
if(res != 2) { if(res != 2) {
return 700; return 700;
} }

106
tests/cases/ftppr.py Normal file
View File

@ -0,0 +1,106 @@
"""The FTP proxy logging in to its server: whatever the server answers the
login with reaches the client, which waits for the answer to its PASS."""
import socket
import threading
# what the server answers USER with, and PASS if it asks for one
LOGINS = {
"230 to USER": ([b"230 Anonymous user logged in"], None),
"multiline 230 to USER": ([b"230-Welcome", b"230-to the server", b"230 Login successful."], None),
"331 to USER, 230 to PASS": ([b"331 Please specify the password."], [b"230 Login successful."]),
"530 to USER": ([b"530 Not allowed here"], None),
"331 to USER, 530 to PASS": ([b"331 Please specify the password."], [b"530 Login incorrect."]),
}
def ftp_server(listener, user, password):
"""Greets, answers the login as told, then NOOP and QUIT."""
while True:
try:
c, _ = listener.accept()
except OSError:
return
try:
f = c.makefile("rb")
c.sendall(b"220 Fake FTP\r\n")
if not f.readline().upper().startswith(b"USER "):
continue
c.sendall(b"".join(line + b"\r\n" for line in user))
if password is not None:
if not f.readline().upper().startswith(b"PASS "):
continue
c.sendall(b"".join(line + b"\r\n" for line in password))
for line in f:
if line.upper().startswith(b"NOOP"):
c.sendall(b"200 NOOP ok\r\n")
elif line.upper().startswith(b"QUIT"):
c.sendall(b"221 Bye\r\n")
break
else:
c.sendall(b"502 Not implemented\r\n")
except OSError:
pass
finally:
c.close()
def reply(f):
"""The last line of a reply, multiline or not; empty at the end or on a
time out."""
while True:
try:
line = f.readline()
except OSError:
return b""
if not line:
return b""
if len(line) < 4 or line[3:4] != b"-":
return line.rstrip()
def run(t):
servers = {}
for name, (user, password) in LOGINS.items():
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen(5)
threading.Thread(target=ftp_server, args=(listener, user, password), daemon=True).start()
servers[name] = listener
port = t.free_port()
t.start("ftppr", f"""
log
auth none
ftppr -p{port}
""", ports=[port])
# without WITH_FTP the service closes the connection at once
s = socket.create_connection(("127.0.0.1", port), timeout=5)
built = reply(s.makefile("rb"))
s.close()
if not built:
t.skip("the FTP proxy (not built in this configuration)")
for listener in servers.values():
listener.close()
t.stop_all()
return
for name, listener in servers.items():
s = socket.create_connection(("127.0.0.1", port), timeout=5)
f = s.makefile("rb")
reply(f)
s.sendall(b"USER anonymous@127.0.0.1:%d\r\n" % listener.getsockname()[1])
reply(f)
s.sendall(b"PASS test@example.com\r\n")
got = reply(f)
ok = LOGINS[name][1] if LOGINS[name][1] is not None else LOGINS[name][0]
t.eq(ok[-1][:3], got[:3], f"{name}: the client gets the server's answer")
if got.startswith(b"230"):
s.sendall(b"NOOP\r\n")
t.eq(b"200", reply(f)[:3], f"{name}: and the session goes on")
s.close()
for listener in servers.values():
listener.close()
t.stop_all()