Skip to content

Commit 96a31fa

Browse files
committed
feat(loaders): inspect Forge-family client installer processors safely
1 parent a216849 commit 96a31fa

1 file changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.loader
2+
3+
import java.nio.file.Files
4+
import java.nio.file.LinkOption
5+
import java.nio.file.Path
6+
import java.util.zip.ZipFile
7+
import kotlinx.serialization.json.Json
8+
import kotlinx.serialization.json.JsonArray
9+
import kotlinx.serialization.json.JsonObject
10+
import kotlinx.serialization.json.JsonPrimitive
11+
import kotlinx.serialization.json.contentOrNull
12+
13+
/**
14+
* Read-only preflight for modern Forge/NeoForge client installers.
15+
* This never executes downloaded code or pretends that a processor list is a launch profile.
16+
*/
17+
data class InstallerProcessorDescriptor(
18+
val artifact: String,
19+
val classpath: List<String>,
20+
val args: List<String>,
21+
val outputs: Map<String, String>,
22+
)
23+
24+
data class InstallerProfileInspection(
25+
val gameVersion: String,
26+
val launcherVersionId: String,
27+
val processors: List<InstallerProcessorDescriptor>,
28+
val installerLibraries: List<String>,
29+
val versionLibraries: List<String>,
30+
val embeddedDataNames: Set<String>,
31+
)
32+
33+
/**
34+
* Version 2 install_profile.json + version.json. Legacy v1 profiles and absent
35+
* processors are rejected rather than silently yielding incomplete clients.
36+
*/
37+
class InstallerProfileInspector {
38+
private val json = Json { ignoreUnknownKeys = true }
39+
private val coordinate = Regex("[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[A-Za-z0-9._+~-]+(:[A-Za-z0-9_.-]+)?")
40+
private val safeEntry = Regex("[A-Za-z0-9_./+-]{1,200}")
41+
private val safeLiteral = Regex("[A-Za-z0-9_:\\-./+{}\\[\\]'\",=;$ ]{1,500}")
42+
43+
fun inspect(jar: Path, requestedGameVersion: String): InstallerProfileInspection {
44+
require(Files.isRegularFile(jar, LinkOption.NOFOLLOW_LINKS) &&
45+
Files.size(jar) in 1..(512L * 1024 * 1024)) { "Invalid installer archive." }
46+
require(requestedGameVersion.matches(Regex("[A-Za-z0-9._-]{1,80}")))
47+
ZipFile(jar.toFile()).use { zip ->
48+
fun read(name: String): JsonObject {
49+
val entry = zip.getEntry(name) ?: throw ModLoaderException(
50+
"Installer is missing " + name + "; legacy/installless layouts are unsupported.")
51+
require(!entry.isDirectory && entry.size in 1..(2L * 1024 * 1024)) {
52+
"Oversized or empty installer metadata: " + name
53+
}
54+
val bytes = zip.getInputStream(entry).use { it.readNBytes(2 * 1024 * 1024 + 1) }
55+
require(bytes.size <= 2 * 1024 * 1024) { "Installer metadata too large." }
56+
return json.parseToJsonElement(bytes.toString(Charsets.UTF_8)) as? JsonObject
57+
?: throw ModLoaderException("Invalid installer metadata: " + name)
58+
}
59+
val install = read("install_profile.json")
60+
val version = read("version.json")
61+
val game = install.text("minecraft") ?: throw ModLoaderException(
62+
"Installer has no Minecraft target.")
63+
require(game == requestedGameVersion) { "Installer targets " + game + ", not " + requestedGameVersion }
64+
val inherits = version.text("inheritsFrom")
65+
require(inherits == requestedGameVersion) {
66+
"Generated version profile targets another Minecraft version."
67+
}
68+
val id = version.text("id") ?: error("Generated version id is missing.")
69+
require(id.matches(Regex("[A-Za-z0-9._+~-]{1,120}"))) { "Unsafe installer version id." }
70+
val rawProcessors = install.array("processors")
71+
require(rawProcessors.isNotEmpty() && rawProcessors.size <= 128) {
72+
"Installer lacks a supported processor list."
73+
}
74+
val processors = rawProcessors.mapNotNull { element ->
75+
val processor = element as? JsonObject ?: error("Malformed installer processor.")
76+
val sides = processor.array("sides").mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
77+
if (sides.isNotEmpty() && "client" !in sides) return@mapNotNull null
78+
val artifact = processor.text("jar") ?: error("Processor JAR missing.")
79+
require(validArtifact(artifact)) { "Unsafe processor artifact." }
80+
val classpath = processor.array("classpath").map {
81+
val value = (it as? JsonPrimitive)?.contentOrNull ?: error("Invalid processor classpath.")
82+
require(validArtifact(value)) { "Unsafe processor classpath." }
83+
value
84+
}
85+
val args = processor.array("args").map {
86+
val value = (it as? JsonPrimitive)?.contentOrNull ?: error("Invalid processor argument.")
87+
require(value.length in 1..500 && value.none { c -> c.code < 32 }) {
88+
"Unsafe processor argument."
89+
}
90+
value
91+
}
92+
val outputs = (processor["outputs"] as? JsonObject)?.mapValues { (_, value) ->
93+
(value as? JsonPrimitive)?.contentOrNull ?: error("Invalid processor output.")
94+
}.orEmpty()
95+
require(outputs.size <= 128 && outputs.all { (key, value) ->
96+
key.length <= 500 && value.length <= 500
97+
}) { "Invalid processor outputs." }
98+
InstallerProcessorDescriptor(artifact, classpath, args, outputs)
99+
}
100+
require(processors.isNotEmpty()) { "Installer has no client-side processors." }
101+
fun libraries(objectValue: JsonObject): List<String> =
102+
objectValue.array("libraries").map {
103+
val name = (it as? JsonObject)?.text("name") ?: error("Unnamed installer library.")
104+
require(coordinate.matches(name)) { "Unsafe library coordinate: " + name }
105+
name
106+
}
107+
val embedded = (install["data"] as? JsonObject)?.keys.orEmpty()
108+
require(embedded.size <= 128 && embedded.all { safeEntry.matches(it) && ".." !in it }) {
109+
"Invalid installer data names."
110+
}
111+
return InstallerProfileInspection(
112+
game, id, processors, libraries(install), libraries(version), embedded,
113+
)
114+
}
115+
}
116+
117+
private fun validArtifact(s: String): Boolean {
118+
val coordinateValue = s.removePrefix("[").removeSuffix("]")
119+
return coordinate.matches(coordinateValue) &&
120+
(!s.startsWith("[") || s.endsWith("]"))
121+
}
122+
123+
private fun JsonObject.text(name: String): String? =
124+
(this[name] as? JsonPrimitive)?.contentOrNull
125+
126+
private fun JsonObject.array(name: String): List<kotlinx.serialization.json.JsonElement> =
127+
(this[name] as? JsonArray)?.toList().orEmpty()
128+
}

0 commit comments

Comments
 (0)