下载服务管理员口令移出源码并限定根目录

- 管理员直取文件的口令改由配置/环境变量提供(STORAGE_DOWNLOAD_ADMIN_CODE 优先),
  未配置即关闭该能力;不再有写死的 alone
- 口令比较使用固定时间算法,避免按前缀泄露
- 管理员请求路径限定在 AdminDownloadRoot 之下(默认 /root/gallery/gallery),
  ../、根目录之外绝对路径与符号链接逃逸一律 404
- 新增覆盖:口令开关、固定时间比较、目录约束与符号链接逃逸、配置读取
This commit is contained in:
us9929
2026-09-23 01:16:31 +08:00
parent 80b82121a1
commit 8e23231dc3
4 changed files with 174 additions and 23 deletions
+10
View File
@@ -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");
}
+70 -23
View File
@@ -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 {
}
}
/**
* 该请求是否走管理员直取文件通道。
*
* <p>口令不再写死在源码里,改为配置(环境变量 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));
}
/**
* 把管理员的请求路径解析为受配置根目录约束的文件。
*
* <p>用真实路径做前缀判断,因此 {@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)
+27
View File
@@ -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);
}
/**
* 启用订阅同步却没有密钥时必须拒绝启动。
* 否则备机会以空密钥运行,签名校验形同虚设。
@@ -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);