From e3ef7bb54ad34f5178115af5a7c8d3532b2e76bf Mon Sep 17 00:00:00 2001 From: Vladimir Dubrovin <3proxy@3proxy.ru> Date: Mon, 26 Oct 2026 17:39:25 +0300 Subject: [PATCH] ftppr: the server's answer to USER reaches the client (#1284) 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 --- src/ftp.c | 5 +- tests/cases/ftppr.py | 106 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 tests/cases/ftppr.py diff --git a/src/ftp.c b/src/ftp.c index 29c970e..a8a1671 100644 --- a/src/ftp.c +++ b/src/ftp.c @@ -38,7 +38,7 @@ int ftplogin(struct clientparam *param, char *nbuf, int *innbuf) { if(i < 3) return 706; buf[i] = 0; if(atoi(buf)/100 != 2) { - *innbuf = i; + if(innbuf)*innbuf = i; return 702; } 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; } } + /* 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) { return 700; } diff --git a/tests/cases/ftppr.py b/tests/cases/ftppr.py new file mode 100644 index 0000000..0fd9837 --- /dev/null +++ b/tests/cases/ftppr.py @@ -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()