Compare commits

...
Author SHA1 Message Date
layue13 04427aeec2 ci: use actions/gradle-actions on security fix branch
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) In progress
PR #14 still referenced gradle/actions; nest act failed cloning that
path. Align with main (#15).
2026-11-09 17:08:45 +00:00
layue13 fef2dfad7e fix: harden MultiMC/authlib/IO trust boundaries (#8–#13)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 25m31s
- #8: do not import MultiMC PreLaunch/Wrapper/JVM overrides from packs
- #9: filter dangerous +jvmArgs from MultiMC patches / lwjgl3ify
- #10: cap HTTP body reads at 16 MiB
- #11: guard FileUtils.copyDirectory against path escape
- #12: allow only http/https/mailto in openLink
- #13: follow authlib api-location only on same-host HTTPS
2026-11-09 15:19:16 +00:00
layue13 bdacd65473 Merge pull request 'chore: 移除误提交的 java/ 目录并忽略' (#6) from fix/remove-committed-java into main
Mirror Repository / Mirror to Gitee (push) Skipped
Mirror Repository / Mirror to CNB (push) Skipped
Java CI / build (push) Canceled after 0s
Reviewed-on: #6
2026-11-08 09:18:07 +00:00
layue13andCursor 9d43a6fc0f feat: install TDLM via Server Modpack fileApi and enable lwjgl3ify by default
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 0s
Wire fixed fileApi first-install/update, and apply the pack's tdlm/lwjgl3ify MultiMC companion on install and launch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 16:44:22 +08:00
24 changed files with 720 additions and 59 deletions

No files matched your search

+2 -1
View File
@@ -31,7 +31,8 @@ jobs:
java-version: '17'
java-package: 'jdk+fx'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
# Nest hosts this under actions/gradle-actions (mirror of github.com/gradle/actions).
uses: actions/gradle-actions/setup-gradle@v5
with:
cache-cleanup: never
# Only main writes the dependency cache; pull requests read it.
@@ -141,6 +141,30 @@ public final class Metadata {
return null;
}
/// Default client Server Modpack fileApi published by TDLMPack (Gitea generic package `latest`).
public static final String DEFAULT_CLIENT_FILE_API =
"https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-client/latest";
/// Stable client update root (`server-manifest.json` and `overrides/` live under this URL).
///
/// Override with system property `hmcl.client.fileApi` or jar attribute `hmcl.client.fileApi`.
public static String getClientFileApi() {
String fromProperty = System.getProperty("hmcl.client.fileApi", "").trim();
if (StringUtils.isNotBlank(fromProperty)) {
return StringUtils.removeSuffix(fromProperty, "/");
}
String fromJar = JarUtils.getAttribute("hmcl.client.fileApi", null);
if (StringUtils.isNotBlank(fromJar)) {
return StringUtils.removeSuffix(fromJar.trim(), "/");
}
return DEFAULT_CLIENT_FILE_API;
}
/// Absolute URL of the client `server-manifest.json` used for first install and differential updates.
public static String getClientManifestUrl() {
return getClientFileApi() + "/server-manifest.json";
}
/// Server address used when the installed modpack manifest does not provide one.
///
/// Can be set at build or run time with the `hmcl.server.address` system property; empty by default.
@@ -150,9 +174,10 @@ public final class Metadata {
/// The server name shown in the title bar.
///
/// Can be set with the `hmcl.server.name` system property; empty by default.
/// Can be set with the `hmcl.server.name` system property; defaults to `TDLM`.
public static String getServerName() {
return System.getProperty("hmcl.server.name", "").trim();
String name = System.getProperty("hmcl.server.name", "TDLM").trim();
return name.isEmpty() ? "TDLM" : name;
}
}
@@ -19,7 +19,6 @@ import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
@@ -44,6 +43,7 @@ import java.util.Set;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Utilities for reading, installing, and applying modpack-specific game settings.
@NotNullByDefault
@@ -314,18 +314,13 @@ public final class ModpackHelper {
setting.minMemoryProperty().setValue(c.getMinMemory());
}
// MultiMC packs are untrusted input: never auto-apply PreLaunch/Wrapper/JVM overrides.
if (c.isOverrideCommands()) {
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
));
setting.commandWrapperProperty().setValue(Objects.requireNonNullElse(c.getWrapperCommand(), ""));
setting.preLaunchCommandProperty().setValue(Objects.requireNonNullElse(c.getPreLaunchCommand(), ""));
LOG.warning("Ignoring MultiMC OverrideCommands (PreLaunch/Wrapper) from modpack for security");
}
if (c.isOverrideJavaArgs()) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
setting.jvmOptionsProperty().setValue(Objects.requireNonNullElse(c.getJvmArgs(), ""));
LOG.warning("Ignoring MultiMC OverrideJavaArgs from modpack for security");
}
if (c.isOverrideConsole()) {
@@ -348,18 +343,13 @@ public final class ModpackHelper {
}
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
// Same policy as toGameSettings: do not import executable commands / raw JVM args from packs.
if (c.isOverrideCommands()) {
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
));
setting.commandWrapperProperty().setValue(Lang.nonNull(c.getWrapperCommand(), ""));
setting.preLaunchCommandProperty().setValue(Lang.nonNull(c.getPreLaunchCommand(), ""));
LOG.warning("Ignoring MultiMC OverrideCommands (PreLaunch/Wrapper) from modpack update for security");
}
if (c.isOverrideJavaArgs()) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
setting.jvmOptionsProperty().setValue(Lang.nonNull(c.getJvmArgs(), ""));
LOG.warning("Ignoring MultiMC OverrideJavaArgs from modpack update for security");
}
}
@@ -27,11 +27,16 @@ import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.ui.account.AccountListPage;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.construct.*;
@@ -354,35 +359,59 @@ public final class Controllers {
);
}
/// Installs the package found by [Metadata#findBundledModpackFile()] asynchronously, if present.
///
/// Shows progress without a cancel action and attempts to delete the package after success.
/// Failures are logged; a retained package may be retried at the next startup.
/// Installs a launcher-bundled modpack when present; otherwise, when the repository has no
/// instances yet, pulls the org client [Metadata#getClientManifestUrl] and opens the install wizard.
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
if (modpackFile == null) {
if (modpackFile != null) {
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
return ModpackHelper.getInstallTask(
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
return;
}
try {
Files.deleteIfExists(modpackFile);
} catch (IOException e) {
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
}
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
);
return;
}
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
if (repository.getDisplayInstances().findAny().isPresent()) {
return;
}
String manifestUrl = Metadata.getClientManifestUrl();
LOG.info("No instances yet; fetching client Server Modpack from " + manifestUrl);
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
return ModpackHelper.getInstallTask(
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
new GetTask(WebURL.parse(manifestUrl))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
LOG.warning("Failed to fetch client Server Modpack manifest", exception);
return;
}
try {
Files.deleteIfExists(modpackFile);
} catch (IOException e) {
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
ServerModpackManifest manifest =
JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
if (manifest == null) {
LOG.warning("Malformed client Server Modpack manifest from " + manifestUrl);
return;
}
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, manifest),
i18n("install.modpack"));
}),
i18n("message.downloading"),
TaskCancellationAction.NO_CANCEL
);
}
@@ -602,6 +602,10 @@ public final class FXUtils {
return;
String url = NetworkUtils.encodeLocation(link);
if (!isAllowedOpenLink(url)) {
LOG.warning("Refusing to open link with disallowed scheme: " + link);
return;
}
thread(() -> {
try {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
@@ -635,6 +639,23 @@ public final class FXUtils {
});
}
/// Only {@code http}/{@code https}/{@code mailto} may be handed to the OS URL handler.
static boolean isAllowedOpenLink(String link) {
try {
URI uri = new URI(link);
String scheme = uri.getScheme();
if (scheme == null) {
return false;
}
return switch (scheme.toLowerCase(java.util.Locale.ROOT)) {
case "http", "https", "mailto" -> true;
default -> false;
};
} catch (Exception e) {
return false;
}
}
public static <T> void bind(JFXTextField textField, Property<T> property, StringConverter<T> converter) {
TextFieldBinding<T> binding = new TextFieldBinding<>(textField, property, converter);
binding.updateTextField();
@@ -28,25 +28,40 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final Path file;
private final String updateVersion;
private final ServerModpackManifest remoteManifest;
private String iconUrl;
private boolean hasSource;
public ModpackInstallWizardProvider(HMCLGameRepository repository) {
this(repository, null, null);
this(repository, null, null, null);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile) {
this(repository, modpackFile, null);
this(repository, modpackFile, null, null);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, GameInstanceID updateInstanceId) {
this(repository, null, updateInstanceId);
this(repository, null, updateInstanceId, null);
}
/// Starts the wizard with a remote Server Modpack manifest (TDLM fileApi install).
public ModpackInstallWizardProvider(HMCLGameRepository repository, ServerModpackManifest remoteManifest) {
this(repository, null, null, remoteManifest);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile, GameInstanceID updateInstanceId) {
this(repository, modpackFile, updateInstanceId, null);
}
private ModpackInstallWizardProvider(
HMCLGameRepository repository,
Path modpackFile,
GameInstanceID updateInstanceId,
ServerModpackManifest remoteManifest) {
this.repository = repository;
this.file = modpackFile;
this.updateVersion = updateInstanceId != null ? updateInstanceId.toString() : null;
this.remoteManifest = remoteManifest;
}
public void setIconUrl(String iconUrl) {
@@ -57,6 +72,8 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
public void start(SettingsMap settings) {
if (file != null)
settings.put(LocalModpackPage.MODPACK_FILE, file);
if (remoteManifest != null)
settings.put(RemoteModpackPage.MODPACK_SERVER_MANIFEST, remoteManifest);
if (updateVersion != null)
settings.put(LocalModpackPage.MODPACK_NAME, updateVersion);
if (StringUtils.isNotBlank(iconUrl))
@@ -13,6 +13,9 @@ import org.jackhuang.hmcl.download.game.GameDownloadTask;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.*;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
@@ -45,9 +48,10 @@ public final class Instances {
private Instances() {
}
/// Installs or updates the server client from the launcher-bundled modpack when present.
/// Installs or updates the TDLM client.
///
/// Falls back to the local/remote modpack import wizard when no bundled package is found.
/// Prefers a launcher-bundled zip, then the org [Metadata#getClientManifestUrl] Server Modpack,
/// then the generic local/remote import wizard.
public static void installServerModpack() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (!repository.isLoaded()) {
@@ -62,7 +66,33 @@ public final class Instances {
return;
}
importModpack();
Controllers.taskDialog(
new GetTask(WebURL.parse(Metadata.getClientManifestUrl()))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
Controllers.dialog(
exception.getMessage(),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR);
importModpack();
return;
}
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
result, ServerModpackManifest.class);
if (manifest == null) {
Controllers.dialog(
i18n("modpack.type.server.malformed"),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR);
importModpack();
return;
}
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, manifest),
i18n("install.modpack"));
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL);
}
public static void importModpack() {
@@ -42,6 +42,11 @@ public final class DesktopUtils {
}
public static void openLink(String link) {
if (!isAllowedOpenLink(link)) {
System.err.println("Refusing to open link with disallowed scheme: " + link);
return;
}
String osName = System.getProperty("os.name").toLowerCase(Locale.ROOT);
Throwable e1 = null;
@@ -79,6 +84,21 @@ public final class DesktopUtils {
}
}
/// Only {@code http}/{@code https}/{@code mailto} may be handed to the OS URL handler.
static boolean isAllowedOpenLink(String link) {
try {
URI uri = new URI(link);
String scheme = uri.getScheme();
if (scheme == null) {
return false;
}
String lower = scheme.toLowerCase(Locale.ROOT);
return "http".equals(lower) || "https".equals(lower) || "mailto".equals(lower);
} catch (Exception e) {
return false;
}
}
private DesktopUtils() {
}
}
@@ -45,10 +45,18 @@ public class AuthlibInjectorServer implements Observable {
String ali = conn.getHeaderField("x-authlib-injector-api-location");
if (ali != null) {
WebURL absoluteAli = WebURL.parse(ali, WebURL.of(conn.getURL()));
// Only follow same-host HTTPS redirects; open redirects to other hosts are rejected.
if (!urlEqualsIgnoreSlash(webURL.toString(), absoluteAli.toString())) {
conn.disconnect();
url = absoluteAli.toString();
conn = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(absoluteAli));
if (!"https".equalsIgnoreCase(absoluteAli.getScheme())
|| absoluteAli.getHost() == null
|| webURL.getHost() == null
|| !absoluteAli.getHost().equalsIgnoreCase(webURL.getHost())) {
LOG.warning("Ignoring x-authlib-injector-api-location redirect outside original HTTPS host: " + absoluteAli);
} else {
conn.disconnect();
url = absoluteAli.toString();
conn = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(absoluteAli));
}
}
}
@@ -327,6 +327,10 @@ public class LaunchOptions implements Serializable {
return this;
}
public Path getGameDir() {
return options.gameDir;
}
public Builder setJava(JavaRuntime java) {
options.java = java;
return this;
@@ -7,6 +7,7 @@ import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.JvmArgumentFilter;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -282,7 +283,7 @@ public final class MultiMCInstancePatch {
if (minecraftArguments == null & patch.getMinecraftArguments() != null) {
minecraftArguments = StringUtils.tokenize(patch.getMinecraftArguments());
}
for (String jvmArg : patch.getJvmArgs()) {
for (String jvmArg : JvmArgumentFilter.filter(patch.getJvmArgs())) {
jvmArguments.add(new StringArgument(jvmArg));
}
mainClass = Lang.requireNonNullElse(mainClass, patch.getMainClass());
@@ -0,0 +1,220 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.modpack.server;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameDownloadTask;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.modpack.multimc.MultiMCComponents;
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstancePatch;
import org.jackhuang.hmcl.modpack.multimc.MultiMCManifest;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
/// Applies the default-on lwjgl3ify MultiMC companion under {@link Lwjgl3ifySupport#ROOT}.
///
/// Replaces the plain Forge install produced by {@link org.jackhuang.hmcl.download.GameBuilder} with the
/// resolved MultiMC artifact (libraries, JVM args, bootstrap main class) so Java 25 launches work
/// without a player toggle.
@NotNullByDefault
public final class Lwjgl3ifyApplyTask extends Task<Void> {
private final DefaultDependencyManager dependencyManager;
private final DefaultGameRepository repository;
private final GameInstanceID instanceId;
private final @Nullable DefaultGameInstance updateTarget;
private final List<Task<?>> dependents = new ArrayList<>();
private final List<Task<?>> dependencies = new ArrayList<>();
private @Nullable MultiMCInstancePatch.ResolvedInstance artifact;
public Lwjgl3ifyApplyTask(
DefaultDependencyManager dependencyManager,
GameInstanceID instanceId,
@Nullable DefaultGameInstance updateTarget) {
this.dependencyManager = dependencyManager;
this.repository = dependencyManager.getGameRepository();
this.instanceId = instanceId;
this.updateTarget = updateTarget;
setStage("hmcl.modpack");
setSignificance(TaskSignificance.MODERATE);
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
if (!Lwjgl3ifySupport.isPresent(instanceRoot)) {
return;
}
try {
dependents.add(new AssemblePatchesTask(instanceRoot));
} catch (IOException e) {
dependents.add(Task.failed(e));
}
}
/// Runs after Server Modpack file sync when the companion is present; otherwise a completed no-op.
public static Task<?> afterCompletion(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
if (!Lwjgl3ifySupport.isPresent(instance.getInstanceRoot())) {
return Task.completed(null);
}
return new Lwjgl3ifyApplyTask(dependencyManager, instance.getId(), instance);
}
@Override
public List<Task<?>> getDependents() {
return dependents;
}
@Override
public List<Task<?>> getDependencies() {
return dependencies;
}
@Override
public void execute() throws Exception {
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
if (!Lwjgl3ifySupport.isPresent(instanceRoot)) {
return;
}
@Nullable AssemblePatchesTask assemble = null;
for (Task<?> dependent : dependents) {
if (dependent instanceof AssemblePatchesTask patchesTask) {
assemble = patchesTask;
break;
}
}
Objects.requireNonNull(assemble, "lwjgl3ify patch assemble task");
artifact = MultiMCInstancePatch.resolveArtifact(assemble.getResult(), instanceId);
Path embeddedLibraries = Lwjgl3ifySupport.root(instanceRoot).resolve("libraries");
if (Files.isDirectory(embeddedLibraries)) {
FileUtils.copyDirectory(embeddedLibraries, instanceRoot.resolve("libraries"));
}
for (Library library : artifact.getManifest().getLibraries()) {
if ("local".equals(library.hint())) {
Path from = repository.getLayout().getLibraryFile(artifact.getManifest().id(), library);
Path target = repository.getLayout().getLibraryFile(artifact.getManifest().id(), library.withoutCommunityFields());
if (Files.exists(from)) {
Files.createDirectories(target.getParent());
Files.copy(from, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
try (InputStream input = Objects.requireNonNull(
Lwjgl3ifyApplyTask.class.getResourceAsStream("/assets/game/HMCLMultiMCBootstrap-1.0.jar"),
"Bundled HMCLMultiMCBootstrap is missing.")) {
Path libraryPath = repository.getLayout().getLibraryFile(artifact.getManifest().id(), MultiMCInstancePatch.BOOTSTRAP_LIBRARY);
Files.createDirectories(libraryPath.getParent());
Files.copy(input, libraryPath, StandardCopyOption.REPLACE_EXISTING);
}
GameInstanceManifest instanceManifest = artifact.getManifest();
repository.save(instanceManifest);
dependencies.add(new GameAssetDownloadTask(dependencyManager, instanceManifest, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true));
dependencies.add(new GameLibrariesTask(
dependencyManager,
instanceManifest.withLibraries(Lang.merge(instanceManifest.getLibraries(), artifact.getMavenOnlyFiles())),
true));
Path instanceJar = getPrimaryJarFile();
dependencies.add(new GameDownloadTask(dependencyManager, instanceManifest)
.thenAcceptAsync(cachedJar -> FileUtils.copyFile(cachedJar, instanceJar))
.withStage("hmcl.modpack.download"));
}
private Path getPrimaryJarFile() {
if (updateTarget == null) {
return repository.getLayout().getInstanceJarFile(instanceId);
}
Path manifestFile = updateTarget.getManifestFile();
return manifestFile.resolveSibling(FileUtils.getNameWithoutExtension(manifestFile) + ".jar");
}
private static final class AssemblePatchesTask extends Task<List<MultiMCInstancePatch>> {
private final Path companionRoot;
private final String mcVersion;
private final List<Task<MultiMCInstancePatch>> patches = new ArrayList<>();
AssemblePatchesTask(Path instanceRoot) throws IOException {
this.companionRoot = Lwjgl3ifySupport.root(instanceRoot);
MultiMCManifest mmcPack = Lwjgl3ifySupport.readMmcPack(instanceRoot);
List<MultiMCManifest.MultiMCManifestComponent> components = Objects.requireNonNull(
mmcPack.getComponents(), "components");
String resolvedMc = null;
for (MultiMCManifest.MultiMCManifestComponent component : components) {
if (MultiMCComponents.getComponent(component.getUid()) == GameComponentType.GAME) {
resolvedMc = component.getVersion();
break;
}
}
if (resolvedMc == null) {
throw new IOException("lwjgl3ify mmc-pack.json has no Minecraft component");
}
this.mcVersion = resolvedMc;
for (MultiMCManifest.MultiMCManifestComponent component : components) {
String componentID = Objects.requireNonNull(component.getUid(), "Component ID");
Path patchPath = companionRoot.resolve("patches").resolve(componentID + ".json");
if (Files.isRegularFile(patchPath)) {
MultiMCInstancePatch patch = MultiMCInstancePatch.read(componentID, Files.readString(patchPath));
patches.add(Task.supplyAsync(() -> patch));
} else {
patches.add(new GetTask(MultiMCComponents.getMetaURL(componentID, component.getVersion(), mcVersion))
.thenApplyAsync(s -> MultiMCInstancePatch.read(componentID, s)));
}
}
}
@Override
public Collection<? extends Task<?>> getDependents() {
return patches;
}
@Override
public void execute() throws Exception {
Map<String, MultiMCInstancePatch> existed = new LinkedHashMap<>();
for (Task<MultiMCInstancePatch> patch : patches) {
MultiMCInstancePatch result = patch.getResult();
existed.put(result.getID(), result);
}
checking:
while (true) {
for (MultiMCInstancePatch patch : existed.values()) {
for (MultiMCManifest.MultiMCManifestCachedRequires require : patch.getRequires()) {
String componentID = require.getID();
if (!existed.containsKey(componentID)) {
Task<MultiMCInstancePatch> task = new GetTask(MultiMCComponents.getMetaURL(
componentID,
Lang.requireNonNullElse(require.getEqualsVersion(), require.getSuggests()),
mcVersion
)).thenApplyAsync(s -> MultiMCInstancePatch.read(componentID, s));
task.run();
MultiMCInstancePatch result = Objects.requireNonNull(task.getResult());
existed.put(result.getID(), result);
continue checking;
}
}
}
break;
}
setResult(new ArrayList<>(existed.values()));
}
}
}
@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.modpack.server;
import org.jackhuang.hmcl.game.LaunchOptions;
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstancePatch;
import org.jackhuang.hmcl.modpack.multimc.MultiMCManifest;
import org.jackhuang.hmcl.util.JvmArgumentFilter;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Default-on lwjgl3ify companion shipped under {@code tdlm/lwjgl3ify/} in the TDLM Server Modpack.
@NotNullByDefault
public final class Lwjgl3ifySupport {
/// Instance-root-relative directory written by the TDLM pack builder.
public static final String ROOT = "tdlm/lwjgl3ify";
private Lwjgl3ifySupport() {
}
/// Returns whether the instance carries an enabled lwjgl3ify MultiMC companion.
public static boolean isPresent(Path instanceRoot) {
Path root = root(instanceRoot);
return Files.isRegularFile(root.resolve("enabled")) && Files.isRegularFile(root.resolve("mmc-pack.json"));
}
/// Returns the companion root under the instance.
public static Path root(Path instanceRoot) {
return instanceRoot.resolve(ROOT);
}
/// Injects {@code +jvmArgs} from every local patch when the companion is present.
///
/// Version-json application via {@link Lwjgl3ifyApplyTask} is the primary path; this covers launches
/// where the resolved manifest has not yet absorbed those args.
public static void injectLaunchOptions(Path instanceRoot, LaunchOptions.Builder builder) {
if (!isPresent(instanceRoot)) {
return;
}
Path patches = root(instanceRoot).resolve("patches");
if (!Files.isDirectory(patches)) {
return;
}
List<String> args = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(patches, "*.json")) {
for (Path patchFile : stream) {
try {
MultiMCInstancePatch patch = MultiMCInstancePatch.read(
patchFile.getFileName().toString(), Files.readString(patchFile));
args.addAll(JvmArgumentFilter.filter(patch.getJvmArgs()));
} catch (IOException | RuntimeException e) {
LOG.warning("Failed to read lwjgl3ify patch " + patchFile, e);
}
}
} catch (IOException e) {
LOG.warning("Failed to list lwjgl3ify patches under " + patches, e);
return;
}
for (String arg : args) {
if (!builder.getJavaArguments().contains(arg)) {
builder.getJavaArguments().add(arg);
}
}
}
/// Parses {@code mmc-pack.json} from the companion root.
public static MultiMCManifest readMmcPack(Path instanceRoot) throws IOException {
Path mmcPack = root(instanceRoot).resolve("mmc-pack.json");
return JsonUtils.fromNonNullJson(Files.readString(mmcPack), MultiMCManifest.class);
}
}
@@ -32,6 +32,7 @@ public class ServerModpackLocalInstallTask extends Task<Void> {
/// Existing instance selecting update mode, or `null` for a new installation.
private final @Nullable DefaultGameInstance updateTarget;
private final DefaultDependencyManager dependencyManager;
private final DefaultGameRepository repository;
private final List<Task<?>> dependencies = new ArrayList<>();
private final List<Task<?>> dependents = new ArrayList<>(4);
@@ -97,6 +98,7 @@ public class ServerModpackLocalInstallTask extends Task<Void> {
this.manifest = manifest;
this.instanceId = instanceId;
this.updateTarget = updateTarget;
this.dependencyManager = dependencyManager;
this.repository = dependencyManager.getGameRepository();
Path run = repository.getLayout().getInstanceRoot(instanceId);
@@ -148,5 +150,9 @@ public class ServerModpackLocalInstallTask extends Task<Void> {
@Override
public void execute() throws Exception {
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
if (Lwjgl3ifySupport.isPresent(instanceRoot)) {
dependencies.add(new Lwjgl3ifyApplyTask(dependencyManager, instanceId, updateTarget));
}
}
}
@@ -6,6 +6,7 @@ import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.game.DefaultGameInstance;
import org.jackhuang.hmcl.game.LaunchOptions;
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackProvider;
@@ -29,7 +30,8 @@ public final class ServerModpackProvider implements ModpackProvider {
@Override
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
return new ServerModpackCompletionTask(dependencyManager, instance);
return new ServerModpackCompletionTask(dependencyManager, instance)
.thenComposeAsync(ignored -> Lwjgl3ifyApplyTask.afterCompletion(dependencyManager, instance));
}
@Override
@@ -51,4 +53,12 @@ public final class ServerModpackProvider implements ModpackProvider {
ServerModpackManifest manifest = JsonUtils.fromNonNullJson(json, ServerModpackManifest.class);
return manifest.toModpack();
}
@Override
public void injectLaunchOptions(String modpackConfigurationJson, LaunchOptions.Builder builder) {
Path gameDir = builder.getGameDir();
if (gameDir != null) {
Lwjgl3ifySupport.injectLaunchOptions(gameDir, builder);
}
}
}
@@ -126,10 +126,12 @@ public class ServerModpackRemoteInstallTask extends Task<Void> {
@Override
public void execute() throws Exception {
DefaultGameInstance instance = repository.getInstance(instanceId);
dependencies.add(new ServerModpackCompletionTask(
dependency,
repository.getInstance(instanceId),
new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList())));
instance,
new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))
.thenComposeAsync(ignored -> Lwjgl3ifyApplyTask.afterCompletion(dependency, instance)));
}
public static final String MODPACK_TYPE = "Server";
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* Filters JVM arguments coming from untrusted modpack / MultiMC patch input.
*
* <p>Denies agent injection and {@code -XX:On*Error} command hooks while allowing ordinary
* memory / GC / system-property args used by companions such as lwjgl3ify.
*/
public final class JvmArgumentFilter {
private JvmArgumentFilter() {
}
/// Returns whether a single JVM argument is safe to accept from modpack / patch metadata.
public static boolean isAllowed(@Nullable String arg) {
if (arg == null) {
return false;
}
String trimmed = arg.trim();
if (trimmed.isEmpty()) {
return false;
}
String lower = trimmed.toLowerCase(Locale.ROOT);
if (lower.startsWith("-javaagent")
|| lower.startsWith("-agentpath")
|| lower.startsWith("-agentlib")
|| lower.startsWith("-xbootclasspath")
|| lower.startsWith("-xx:onoutofmemoryerror")
|| lower.startsWith("-xx:onerror")
|| lower.startsWith("-xx:exitonoutofmemoryerror")) {
return false;
}
return true;
}
/// Copies {@code args}, dropping disallowed entries and logging each skip.
public static List<String> filter(Iterable<String> args) {
List<String> out = new ArrayList<>();
for (String arg : args) {
if (isAllowed(arg)) {
out.add(arg);
} else {
LOG.warning("Ignoring disallowed JVM argument from modpack/patch: " + arg);
}
}
return out;
}
}
@@ -306,6 +306,7 @@ public final class FileUtils {
}
public static void copyDirectory(Path src, Path dest, Predicate<String> filePredicate) throws IOException {
Path destRoot = dest.toAbsolutePath().normalize();
Files.walkFileTree(src, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
@@ -313,7 +314,11 @@ public final class FileUtils {
return FileVisitResult.SKIP_SUBTREE;
}
Path destFile = dest.resolve(src.relativize(file).toString());
Path destFile = destRoot.resolve(src.relativize(file).toString()).toAbsolutePath().normalize();
if (!destFile.startsWith(destRoot)) {
throw new IOException("Refusing to copy outside destination directory: " + file);
}
Files.createDirectories(destFile.getParent());
Files.copy(file, destFile, StandardCopyOption.REPLACE_EXISTING);
return FileVisitResult.CONTINUE;
}
@@ -324,7 +329,10 @@ public final class FileUtils {
return FileVisitResult.SKIP_SUBTREE;
}
Path destDir = dest.resolve(src.relativize(dir).toString());
Path destDir = destRoot.resolve(src.relativize(dir).toString()).toAbsolutePath().normalize();
if (!destDir.startsWith(destRoot)) {
throw new IOException("Refusing to create directory outside destination: " + dir);
}
Files.createDirectories(destDir);
return FileVisitResult.CONTINUE;
}
@@ -10,6 +10,7 @@ import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
@@ -134,9 +135,10 @@ public abstract class HttpRequest {
con = resolveConnection(con);
checkResponseCode(con);
return IOUtils.readFullyAsString("gzip".equals(con.getContentEncoding())
InputStream body = "gzip".equals(con.getContentEncoding())
? IOUtils.wrapFromGZip(con.getInputStream())
: con.getInputStream());
: con.getInputStream();
return IOUtils.readFullyAsString(body, UTF_8, IOUtils.DEFAULT_MAX_HTTP_BODY_BYTES);
}, retryTimes);
}
}
@@ -5,7 +5,13 @@ package org.jackhuang.hmcl.util.io;
import kala.encdet.EncodingDetector;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
@@ -29,6 +35,9 @@ public final class IOUtils {
public static final int DEFAULT_BUFFER_SIZE = 32 * 1024;
/// Soft cap for HTTP / metadata bodies read into memory (authlib, API JSON, etc.).
public static final long DEFAULT_MAX_HTTP_BODY_BYTES = 16L * 1024L * 1024L;
public static BufferedReader newBufferedReaderMaybeNativeEncoding(Path file) throws IOException {
if (NATIVE_CHARSET == UTF_8)
return Files.newBufferedReader(file);
@@ -68,8 +77,29 @@ public final class IOUtils {
}
public static byte[] readFully(InputStream stream) throws IOException {
return readFully(stream, -1L);
}
/// Reads the stream fully, optionally refusing bodies larger than {@code maxBytes}.
///
/// @param maxBytes maximum accepted size, or {@code <= 0} for no limit
public static byte[] readFully(InputStream stream, long maxBytes) throws IOException {
try (stream) {
return stream.readAllBytes();
if (maxBytes <= 0) {
return stream.readAllBytes();
}
ByteArrayOutputStream out = new ByteArrayOutputStream(Math.min(DEFAULT_BUFFER_SIZE, (int) Math.min(maxBytes, Integer.MAX_VALUE)));
byte[] buf = new byte[DEFAULT_BUFFER_SIZE];
long total = 0;
int n;
while ((n = stream.read(buf)) >= 0) {
total += n;
if (total > maxBytes) {
throw new IOException("Response body exceeds limit of " + maxBytes + " bytes");
}
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
@@ -81,6 +111,10 @@ public final class IOUtils {
return new String(readFully(stream), charset);
}
public static String readFullyAsString(InputStream stream, Charset charset, long maxBytes) throws IOException {
return new String(readFully(stream, maxBytes), charset);
}
public static void skipNBytes(InputStream input, long n) throws IOException {
while (n > 0) {
long ns = input.skip(n);
@@ -437,13 +437,13 @@ public final class NetworkUtils {
Charset charset = getCharsetFromContentType(con.getHeaderField("Content-Type"));
try (InputStream stdout = con.getInputStream()) {
return IOUtils.readFullyAsString(contentEncoding.wrap(stdout), charset);
return IOUtils.readFullyAsString(contentEncoding.wrap(stdout), charset, IOUtils.DEFAULT_MAX_HTTP_BODY_BYTES);
} catch (IOException e) {
if (con instanceof HttpURLConnection) {
try (InputStream stderr = ((HttpURLConnection) con).getErrorStream()) {
if (stderr == null)
throw e;
return IOUtils.readFullyAsString(contentEncoding.wrap(stderr), charset);
return IOUtils.readFullyAsString(contentEncoding.wrap(stderr), charset, IOUtils.DEFAULT_MAX_HTTP_BODY_BYTES);
}
} else {
throw e;
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class JvmArgumentFilterTest {
@Test
public void allowsOrdinaryArgs() {
assertTrue(JvmArgumentFilter.isAllowed("-Xmx2G"));
assertTrue(JvmArgumentFilter.isAllowed("-Djava.library.path=/tmp"));
assertTrue(JvmArgumentFilter.isAllowed("--add-opens=java.base/java.lang=ALL-UNNAMED"));
}
@Test
public void rejectsAgentsAndOnErrorHooks() {
assertFalse(JvmArgumentFilter.isAllowed("-javaagent:/tmp/evil.jar"));
assertFalse(JvmArgumentFilter.isAllowed("-agentpath:/tmp/evil.so"));
assertFalse(JvmArgumentFilter.isAllowed("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"));
assertFalse(JvmArgumentFilter.isAllowed("-Xbootclasspath/a:/tmp/evil.jar"));
assertFalse(JvmArgumentFilter.isAllowed("-XX:OnOutOfMemoryError=curl evil.example"));
assertFalse(JvmArgumentFilter.isAllowed("-XX:OnError=id"));
}
@Test
public void filterDropsDisallowed() {
assertEquals(List.of("-Xmx1G"),
JvmArgumentFilter.filter(List.of("-Xmx1G", "-javaagent:x.jar")));
}
}
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util.io;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
public class CopyDirectoryTraversalTest {
@TempDir
Path temp;
@Test
public void copiesWithinDestination() throws IOException {
Path src = temp.resolve("src");
Path dest = temp.resolve("dest");
Files.createDirectories(src.resolve("a"));
Files.writeString(src.resolve("a/b.txt"), "hi");
FileUtils.copyDirectory(src, dest);
assertEquals("hi", Files.readString(dest.resolve("a/b.txt")));
}
@Test
public void destinationRootGuardMatchesUnzipperStyle() {
Path destRoot = temp.resolve("dest").toAbsolutePath().normalize();
Path escape = destRoot.resolve("sub/../../outside.txt").toAbsolutePath().normalize();
assertFalse(escape.startsWith(destRoot), "normalize + startsWith must reject escapes");
Path inside = destRoot.resolve("sub/file.txt").toAbsolutePath().normalize();
assertTrue(inside.startsWith(destRoot));
}
}
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util.io;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
public class IOUtilsReadLimitTest {
@Test
public void rejectsOversizedBody() {
byte[] data = new byte[100];
assertThrows(IOException.class, () -> IOUtils.readFully(new ByteArrayInputStream(data), 50));
}
@Test
public void acceptsWithinLimit() throws IOException {
byte[] data = "hello".getBytes(StandardCharsets.UTF_8);
assertArrayEquals(data, IOUtils.readFully(new ByteArrayInputStream(data), 16));
assertEquals("hello", IOUtils.readFullyAsString(new ByteArrayInputStream(data), StandardCharsets.UTF_8, 16));
}
}