下载服务管理员口令移出源码并限定根目录
- 管理员直取文件的口令改由配置/环境变量提供(STORAGE_DOWNLOAD_ADMIN_CODE 优先), 未配置即关闭该能力;不再有写死的 alone - 口令比较使用固定时间算法,避免按前缀泄露 - 管理员请求路径限定在 AdminDownloadRoot 之下(默认 /root/gallery/gallery), ../、根目录之外绝对路径与符号链接逃逸一律 404 - 新增覆盖:口令开关、固定时间比较、目录约束与符号链接逃逸、配置读取
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user