From d0a0700275236f83d961491d8deef506f9e34840 Mon Sep 17 00:00:00 2001 From: kl Date: Thu, 8 Oct 2026 14:04:15 +0800 Subject: [PATCH] fix: reject control characters in FTP command arguments --- .../main/java/cn/keking/utils/FtpUtils.java | 13 ++++++ .../keking/utils/FtpCommandSecurityTests.java | 46 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 server/src/test/java/cn/keking/utils/FtpCommandSecurityTests.java diff --git a/server/src/main/java/cn/keking/utils/FtpUtils.java b/server/src/main/java/cn/keking/utils/FtpUtils.java index e3ac7c46..0a185943 100644 --- a/server/src/main/java/cn/keking/utils/FtpUtils.java +++ b/server/src/main/java/cn/keking/utils/FtpUtils.java @@ -154,9 +154,22 @@ public class FtpUtils { } catch (URISyntaxException e) { throw new IOException("无效的FTP URL: " + ftpUrl, e); } + // URI#getPath decodes percent escapes. Validate the values actually + // sent as FTP command arguments, before opening any connection. + validateCommandArgument(info.remoteFilePath); + validateCommandArgument(info.username); + validateCommandArgument(info.password); + validateCommandArgument(new String(info.remoteFilePath.getBytes(info.controlEncoding), + StandardCharsets.ISO_8859_1)); return info; } + private static void validateCommandArgument(String value) throws IOException { + if (value != null && value.chars().anyMatch(c -> c < 0x20 || c == 0x7f)) { + throw new IOException("FTP command arguments must not contain control characters"); + } + } + /** * FTP连接信息对象 */ diff --git a/server/src/test/java/cn/keking/utils/FtpCommandSecurityTests.java b/server/src/test/java/cn/keking/utils/FtpCommandSecurityTests.java new file mode 100644 index 00000000..d5b4235a --- /dev/null +++ b/server/src/test/java/cn/keking/utils/FtpCommandSecurityTests.java @@ -0,0 +1,46 @@ +package cn.keking.utils; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +class FtpCommandSecurityTests { + @ParameterizedTest + @ValueSource(strings = { + "file.txt%0D%0ANOOP", "file.txt%0d%0aNOOP/clean.txt", + "file.txt%0D%0ANOOP?fullfilename=clean.txt", "file.txt%0aNOOP", "file.txt%00" + }) + void rejectsDecodedControlsBeforeDownloadOrPreviewConnects(String path) { + String url = "ftp://127.0.0.1/" + path; + IOException download = assertThrows(IOException.class, + () -> FtpUtils.download(url, "1", "unused", "user", "pass", "UTF-8")); + IOException preview = assertThrows(IOException.class, + () -> FtpUtils.preview(url, "1", "unused", "user", "pass", "UTF-8")); + assertTrue(download.getMessage().contains("control characters")); + assertTrue(preview.getMessage().contains("control characters")); + } + + @Test + void rejectsControlsInCredentialsBeforeConnecting() { + IOException error = assertThrows(IOException.class, + () -> FtpUtils.preview("ftp://127.0.0.1/file.txt", "1", "unused", + "user\r\nNOOP", "pass", "UTF-8")); + assertTrue(error.getMessage().contains("control characters")); + error = assertThrows(IOException.class, + () -> FtpUtils.download("ftp://127.0.0.1/file.txt", "1", "unused", + "user", "pass\nNOOP", "UTF-8")); + assertTrue(error.getMessage().contains("control characters")); + } + + @Test + void preservesOrdinaryEncodedNamesAndDoesNotDoubleDecode() { + Object info = ReflectionTestUtils.invokeMethod(FtpUtils.class, "parseFtpConnectionInfo", + "ftp://files.example/a%20b/%E4%B8%AD%E6%96%87%250D.txt", "21", "user", "pass", "UTF-8"); + assertEquals("/a b/中文%0D.txt", ReflectionTestUtils.getField(info, "remoteFilePath")); + } +}