From 8e23231dc3c471dbe180b43382d07947c57738fa Mon Sep 17 00:00:00 2001 From: us9929 Date: Wed, 23 Sep 2026 01:16:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=9C=8D=E5=8A=A1=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E5=8F=A3=E4=BB=A4=E7=A7=BB=E5=87=BA=E6=BA=90?= =?UTF-8?q?=E7=A0=81=E5=B9=B6=E9=99=90=E5=AE=9A=E6=A0=B9=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 管理员直取文件的口令改由配置/环境变量提供(STORAGE_DOWNLOAD_ADMIN_CODE 优先), 未配置即关闭该能力;不再有写死的 alone - 口令比较使用固定时间算法,避免按前缀泄露 - 管理员请求路径限定在 AdminDownloadRoot 之下(默认 /root/gallery/gallery), ../、根目录之外绝对路径与符号链接逃逸一律 404 - 新增覆盖:口令开关、固定时间比较、目录约束与符号链接逃逸、配置读取 --- src/main/java/lion/Config/Config.java | 10 ++ .../java/lion/MultiThreadedHTTPServer.java | 93 ++++++++++++++----- src/test/java/lion/ConfigTest.java | 27 ++++++ .../lion/MultiThreadedHTTPServerTest.java | 67 +++++++++++++ 4 files changed, 174 insertions(+), 23 deletions(-) diff --git a/src/main/java/lion/Config/Config.java b/src/main/java/lion/Config/Config.java index 41ac141..4467f81 100644 --- a/src/main/java/lion/Config/Config.java +++ b/src/main/java/lion/Config/Config.java @@ -22,6 +22,12 @@ public class Config { public static int subscriptionHttpWorkers; public static int subscriptionSocketTimeoutMs; + /** 下载服务的管理员口令;为空表示 8888 的管理员直取文件能力关闭。 */ + public static String adminDownloadCode = ""; + + /** 管理员直取文件时允许访问的根目录,越界一律拒绝。 */ + public static String adminDownloadRoot = "/root/gallery/gallery"; + public static void loadConfig(){ loadConfig(CONFIG_PATH); } @@ -62,6 +68,10 @@ public class Config { subscriptionHttpPort = Integer.parseInt(value(prop, "SubscriptionHttpPort", "8889")); subscriptionHttpWorkers = Integer.parseInt(value(prop, "SubscriptionHttpWorkers", "4")); subscriptionSocketTimeoutMs = Integer.parseInt(value(prop, "SubscriptionSocketTimeoutMs", "10000")); + // 口令不再写死在源码里:优先取环境变量,其次取配置文件,都为空即关闭该能力。 + adminDownloadCode = System.getenv().getOrDefault("STORAGE_DOWNLOAD_ADMIN_CODE", + value(prop, "AdminDownloadCode", "")); + adminDownloadRoot = value(prop, "AdminDownloadRoot", "/root/gallery/gallery"); if (subscriptionSyncEnabled && subscriptionSyncSecret.isBlank()) throw new IllegalStateException("启用订阅同步时必须配置 SUBSCRIPTION_SYNC_SECRET"); } diff --git a/src/main/java/lion/MultiThreadedHTTPServer.java b/src/main/java/lion/MultiThreadedHTTPServer.java index bf3f7e9..a8780df 100644 --- a/src/main/java/lion/MultiThreadedHTTPServer.java +++ b/src/main/java/lion/MultiThreadedHTTPServer.java @@ -1,10 +1,13 @@ package lion; +import lion.Config.Config; import lombok.extern.slf4j.Slf4j; import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -85,32 +88,37 @@ public class MultiThreadedHTTPServer { if (method.equals("GET")) { // Set the file path for download File file; - if(paramMap.get("AuthCode") != null) - if(paramMap.get("AuthCode").equals("alone")){ - String path = URLDecoder.decode(requestParts[1].split("\\?")[0], StandardCharsets.UTF_8); - file = new File(path); - } - else { - String filePath = "/root/gallery/gallery"; - String gid = paramMap.get("gid"); - file = gid == null ? null : findGalleryZipByGid(new File(filePath), gid); - - //兼容没有gid参数的旧下载链接,再尝试按链接中的文件名查找 - if(file == null){ - String path = URLDecoder.decode(requestParts[1].split("\\?")[0], StandardCharsets.UTF_8); - if(path.contains(".")){ - String name = path.substring(0, path.lastIndexOf('.')); - name = filePath + name + "/" + name + ".zip"; - file = new File(name); - }else{ - file = FILE_NOT_FOUND; - } - } - } - else{ + String authCode = paramMap.get("AuthCode"); + if (authCode == null) { CustomUtil.sendErrorResponse(socket, "403 Forbidden"); return; } + String requestPath = URLDecoder.decode(requestParts[1].split("\\?")[0], StandardCharsets.UTF_8); + if (isAdminRequest(authCode)) { + // 管理员直取文件从「任意绝对路径」改为「限定在配置根目录之下」: + // ../、绝对路径、符号链接逃逸等越界一律按找不到处理。 + file = resolveAdminFile(requestPath); + if (file == null) { + log.warn("管理员下载越界,已拒绝:{}", requestPath); + CustomUtil.sendErrorResponse(socket, "404 Not Found"); + return; + } + } else { + String filePath = "/root/gallery/gallery"; + String gid = paramMap.get("gid"); + file = gid == null ? null : findGalleryZipByGid(new File(filePath), gid); + + //兼容没有gid参数的旧下载链接,再尝试按链接中的文件名查找 + if (file == null) { + if (requestPath.contains(".")) { + String name = requestPath.substring(0, requestPath.lastIndexOf('.')); + name = filePath + name + "/" + name + ".zip"; + file = new File(name); + } else { + file = FILE_NOT_FOUND; + } + } + } fileName = file.getName(); log.info(file.getAbsolutePath()); // Check if the file exists and is readable @@ -157,6 +165,45 @@ public class MultiThreadedHTTPServer { } } + /** + * 该请求是否走管理员直取文件通道。 + * + *

口令不再写死在源码里,改为配置(环境变量 STORAGE_DOWNLOAD_ADMIN_CODE 优先, + * 其次配置文件 AdminDownloadCode)。未配置即关闭该能力;比较用固定时间算法, + * 避免按前缀长度泄露口令。 + */ + static boolean isAdminRequest(String authCode){ + String expected = Config.adminDownloadCode; + if (expected == null || expected.isBlank() || authCode == null) + return false; + return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), + authCode.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 把管理员的请求路径解析为受配置根目录约束的文件。 + * + *

用真实路径做前缀判断,因此 {@code ..} 与指向根目录之外的符号链接都会被拒。 + * 文件不存在(含越界)返回 null,由调用方回 404。 + */ + static File resolveAdminFile(String requestPath){ + if (requestPath == null || requestPath.isBlank()) + return null; + try { + Path root = Path.of(Config.adminDownloadRoot).toRealPath(); + // 兼容两种调用:绝对路径(主站历史上就是这么传的)与相对根目录的路径。 + Path raw = Path.of(requestPath); + Path candidate = (raw.isAbsolute() ? raw : root.resolve(raw)).normalize(); + if (!candidate.startsWith(root)) + return null; + // 必须落到真实路径再判断一次:这样指向根目录之外的符号链接也会被拒。 + Path resolved = candidate.toRealPath(); + return resolved.startsWith(root) && resolved.toFile().isFile() ? resolved.toFile() : null; + } catch (Exception e) { + return null; + } + } + private static File findGalleryZipByGid(File galleryRoot, String gid){ File[] galleryDirectories = galleryRoot.listFiles(File::isDirectory); if(galleryDirectories == null) diff --git a/src/test/java/lion/ConfigTest.java b/src/test/java/lion/ConfigTest.java index 3434570..0c3386a 100644 --- a/src/test/java/lion/ConfigTest.java +++ b/src/test/java/lion/ConfigTest.java @@ -67,6 +67,33 @@ class ConfigTest { assertEquals(8889, Config.subscriptionHttpPort, "缺失文件应回到默认值"); } + /** + * 管理员口令默认必须为空(即关闭 8888 的管理员直取能力), + * 且不再有写死在源码里的默认口令。 + */ + @Test + void adminDownloadCodeDefaultsToDisabled(@TempDir Path root) throws Exception { + Path file = Files.writeString(root.resolve("config.properties"), ""); + + Config.loadConfig(file.toString()); + + assertEquals("", Config.adminDownloadCode, "未配置口令时管理员能力必须关闭"); + assertEquals("/root/gallery/gallery", Config.adminDownloadRoot, "默认根目录应与下载目录一致"); + } + + /** 口令与根目录都应能从配置文件读入。 */ + @Test + void readsAdminDownloadSettings(@TempDir Path root) throws Exception { + Path file = Files.writeString(root.resolve("config.properties"), String.join("\n", + "AdminDownloadCode=alone", + "AdminDownloadRoot=/root/gallery")); + + Config.loadConfig(file.toString()); + + assertEquals("alone", Config.adminDownloadCode); + assertEquals("/root/gallery", Config.adminDownloadRoot); + } + /** * 启用订阅同步却没有密钥时必须拒绝启动。 * 否则备机会以空密钥运行,签名校验形同虚设。 diff --git a/src/test/java/lion/MultiThreadedHTTPServerTest.java b/src/test/java/lion/MultiThreadedHTTPServerTest.java index 0bf2772..e9657b0 100644 --- a/src/test/java/lion/MultiThreadedHTTPServerTest.java +++ b/src/test/java/lion/MultiThreadedHTTPServerTest.java @@ -138,6 +138,70 @@ class MultiThreadedHTTPServerTest { assertTrue(head.startsWith("HTTP/1.1 416"), "越界起点应回 416:" + head.lines().findFirst().orElse("")); } + // ---- 管理员直取文件的口令与目录约束 ---- + + /** 未配置口令时,管理员能力必须关闭,任何 AuthCode 都不能直取文件。 */ + @Test + void adminCapabilityIsOffUntilCodeConfigured() { + lion.Config.Config.adminDownloadCode = ""; + + assertFalse(MultiThreadedHTTPServer.isAdminRequest("alone")); + assertFalse(MultiThreadedHTTPServer.isAdminRequest("")); + } + + /** 配置口令后只认该口令,旧写死值与其他值都不再放行。 */ + @Test + void onlyConfiguredAdminCodeIsAccepted() { + lion.Config.Config.adminDownloadCode = "s3cret-code"; + try { + assertTrue(MultiThreadedHTTPServer.isAdminRequest("s3cret-code")); + assertFalse(MultiThreadedHTTPServer.isAdminRequest("alone"), "写死的旧值不应再有效"); + assertFalse(MultiThreadedHTTPServer.isAdminRequest("s3cret")); + assertFalse(MultiThreadedHTTPServer.isAdminRequest(null)); + } finally { + lion.Config.Config.adminDownloadCode = ""; + } + } + + /** 管理员直取文件只允许根目录之下的真实文件。 */ + @Test + void adminDownloadIsConfinedToConfiguredRoot(@TempDir Path root) throws Exception { + Path galleryRoot = Files.createDirectories(root.resolve("gallery")); + Path archive = Files.write(galleryRoot.resolve("sample.zip"), "zip".getBytes(StandardCharsets.UTF_8)); + Path outside = Files.write(root.resolve("secret.txt"), "top secret".getBytes(StandardCharsets.UTF_8)); + lion.Config.Config.adminDownloadRoot = galleryRoot.toString(); + try { + assertEquals(archive.toRealPath().toFile(), MultiThreadedHTTPServer.resolveAdminFile(archive.toString())); + assertEquals(archive.toRealPath().toFile(), MultiThreadedHTTPServer.resolveAdminFile("sample.zip")); + + assertNull(MultiThreadedHTTPServer.resolveAdminFile("../secret.txt"), "../ 越界必须被拒"); + assertNull(MultiThreadedHTTPServer.resolveAdminFile(outside.toString()), "根目录之外的绝对路径必须被拒"); + assertNull(MultiThreadedHTTPServer.resolveAdminFile("/etc/passwd"), "任意系统文件必须被拒"); + assertNull(MultiThreadedHTTPServer.resolveAdminFile(galleryRoot.toString()), "目录本身不是可下载文件"); + } finally { + lion.Config.Config.adminDownloadRoot = "/root/gallery/gallery"; + } + } + + /** 指向根目录之外的符号链接也不得把文件泄漏出去。 */ + @Test + void adminDownloadRejectsSymlinkEscape(@TempDir Path root) throws Exception { + Path galleryRoot = Files.createDirectories(root.resolve("gallery")); + Path outside = Files.write(root.resolve("secret.txt"), "top secret".getBytes(StandardCharsets.UTF_8)); + Path link = galleryRoot.resolve("link.zip"); + try { + Files.createSymbolicLink(link, outside); + } catch (UnsupportedOperationException | IOException e) { + return; // 平台不支持符号链接时跳过 + } + lion.Config.Config.adminDownloadRoot = galleryRoot.toString(); + try { + assertNull(MultiThreadedHTTPServer.resolveAdminFile("link.zip"), "指向根目录外的符号链接必须被拒"); + } finally { + lion.Config.Config.adminDownloadRoot = "/root/gallery/gallery"; + } + } + // ---- 测试脚手架 ---- /** 在守护线程里接受一个连接并交给真实的请求处理器。 */ @@ -160,6 +224,9 @@ class MultiThreadedHTTPServerTest { /** 走 AuthCode=alone 路径请求指定文件,返回响应头。 */ private static String fetchRange(Path root, Path archive, String range) throws IOException { + lion.Config.Config.adminDownloadCode = "alone"; + // 管理员直取已限定在 adminDownloadRoot 之下,测试把根目录指到临时目录。 + lion.Config.Config.adminDownloadRoot = root.toString(); try (ServerSocket server = new ServerSocket(0); Socket client = new Socket("127.0.0.1", server.getLocalPort())) { client.setSoTimeout(5_000); Thread handler = startHandler(server);