下载服务管理员口令移出源码并限定根目录
- 管理员直取文件的口令改由配置/环境变量提供(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 subscriptionHttpWorkers;
|
||||||
public static int subscriptionSocketTimeoutMs;
|
public static int subscriptionSocketTimeoutMs;
|
||||||
|
|
||||||
|
/** 下载服务的管理员口令;为空表示 8888 的管理员直取文件能力关闭。 */
|
||||||
|
public static String adminDownloadCode = "";
|
||||||
|
|
||||||
|
/** 管理员直取文件时允许访问的根目录,越界一律拒绝。 */
|
||||||
|
public static String adminDownloadRoot = "/root/gallery/gallery";
|
||||||
|
|
||||||
public static void loadConfig(){
|
public static void loadConfig(){
|
||||||
loadConfig(CONFIG_PATH);
|
loadConfig(CONFIG_PATH);
|
||||||
}
|
}
|
||||||
@@ -62,6 +68,10 @@ public class Config {
|
|||||||
subscriptionHttpPort = Integer.parseInt(value(prop, "SubscriptionHttpPort", "8889"));
|
subscriptionHttpPort = Integer.parseInt(value(prop, "SubscriptionHttpPort", "8889"));
|
||||||
subscriptionHttpWorkers = Integer.parseInt(value(prop, "SubscriptionHttpWorkers", "4"));
|
subscriptionHttpWorkers = Integer.parseInt(value(prop, "SubscriptionHttpWorkers", "4"));
|
||||||
subscriptionSocketTimeoutMs = Integer.parseInt(value(prop, "SubscriptionSocketTimeoutMs", "10000"));
|
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())
|
if (subscriptionSyncEnabled && subscriptionSyncSecret.isBlank())
|
||||||
throw new IllegalStateException("启用订阅同步时必须配置 SUBSCRIPTION_SYNC_SECRET");
|
throw new IllegalStateException("启用订阅同步时必须配置 SUBSCRIPTION_SYNC_SECRET");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package lion;
|
package lion;
|
||||||
|
|
||||||
|
import lion.Config.Config;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.net.*;
|
import java.net.*;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -85,32 +88,37 @@ public class MultiThreadedHTTPServer {
|
|||||||
if (method.equals("GET")) {
|
if (method.equals("GET")) {
|
||||||
// Set the file path for download
|
// Set the file path for download
|
||||||
File file;
|
File file;
|
||||||
if(paramMap.get("AuthCode") != null)
|
String authCode = paramMap.get("AuthCode");
|
||||||
if(paramMap.get("AuthCode").equals("alone")){
|
if (authCode == null) {
|
||||||
String path = URLDecoder.decode(requestParts[1].split("\\?")[0], StandardCharsets.UTF_8);
|
CustomUtil.sendErrorResponse(socket, "403 Forbidden");
|
||||||
file = new File(path);
|
return;
|
||||||
}
|
}
|
||||||
else {
|
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 filePath = "/root/gallery/gallery";
|
||||||
String gid = paramMap.get("gid");
|
String gid = paramMap.get("gid");
|
||||||
file = gid == null ? null : findGalleryZipByGid(new File(filePath), gid);
|
file = gid == null ? null : findGalleryZipByGid(new File(filePath), gid);
|
||||||
|
|
||||||
//兼容没有gid参数的旧下载链接,再尝试按链接中的文件名查找
|
//兼容没有gid参数的旧下载链接,再尝试按链接中的文件名查找
|
||||||
if(file == null){
|
if (file == null) {
|
||||||
String path = URLDecoder.decode(requestParts[1].split("\\?")[0], StandardCharsets.UTF_8);
|
if (requestPath.contains(".")) {
|
||||||
if(path.contains(".")){
|
String name = requestPath.substring(0, requestPath.lastIndexOf('.'));
|
||||||
String name = path.substring(0, path.lastIndexOf('.'));
|
|
||||||
name = filePath + name + "/" + name + ".zip";
|
name = filePath + name + "/" + name + ".zip";
|
||||||
file = new File(name);
|
file = new File(name);
|
||||||
}else{
|
} else {
|
||||||
file = FILE_NOT_FOUND;
|
file = FILE_NOT_FOUND;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else{
|
|
||||||
CustomUtil.sendErrorResponse(socket, "403 Forbidden");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fileName = file.getName();
|
fileName = file.getName();
|
||||||
log.info(file.getAbsolutePath());
|
log.info(file.getAbsolutePath());
|
||||||
// Check if the file exists and is readable
|
// 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){
|
private static File findGalleryZipByGid(File galleryRoot, String gid){
|
||||||
File[] galleryDirectories = galleryRoot.listFiles(File::isDirectory);
|
File[] galleryDirectories = galleryRoot.listFiles(File::isDirectory);
|
||||||
if(galleryDirectories == null)
|
if(galleryDirectories == null)
|
||||||
|
|||||||
@@ -67,6 +67,33 @@ class ConfigTest {
|
|||||||
assertEquals(8889, Config.subscriptionHttpPort, "缺失文件应回到默认值");
|
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(""));
|
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 路径请求指定文件,返回响应头。 */
|
/** 走 AuthCode=alone 路径请求指定文件,返回响应头。 */
|
||||||
private static String fetchRange(Path root, Path archive, String range) throws IOException {
|
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())) {
|
try (ServerSocket server = new ServerSocket(0); Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
|
||||||
client.setSoTimeout(5_000);
|
client.setSoTimeout(5_000);
|
||||||
Thread handler = startHandler(server);
|
Thread handler = startHandler(server);
|
||||||
|
|||||||
Reference in New Issue
Block a user