Detect tampering with signed plugins more reliably and stop plugin classes from loading in the case of suspected tampering

This commit is contained in:
grimsi
2025-04-02 17:37:20 +02:00
parent 84df7ee2bc
commit d3d922146a
10 changed files with 194 additions and 97 deletions
@@ -4,13 +4,15 @@ import {
PauseCircle,
PlayCircle,
Power,
Question,
QuestionMark,
SealCheck,
SealQuestion,
SealWarning,
SlidersHorizontal,
StopCircle,
WarningCircle
WarningCircle,
XCircle
} from "@phosphor-icons/react";
import {PluginManagementEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
@@ -19,25 +21,29 @@ import React, {ReactNode, useEffect, useState} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
import PluginLogo from "Frontend/components/general/PluginLogo";
import PluginTrustLevel from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginTrustLevel";
import PluginConfigValidationResult
from "Frontend/generated/de/grimsi/gameyfin/core/plugins/config/PluginConfigValidationResult";
export function PluginManagementCard({plugin, updatePlugin}: {
plugin: PluginDto,
updatePlugin: (plugin: PluginDto) => void
}) {
const pluginDetailsModal = useDisclosure();
const [configValid, setConfigValid] = useState<boolean | undefined>(undefined);
const [configValidationResult, setConfigValidationResult] = useState<PluginConfigValidationResult | undefined>(undefined);
useEffect(() => {
PluginManagementEndpoint.validatePluginConfig(plugin.id).then((response: boolean) => {
PluginManagementEndpoint.validatePluginConfig(plugin.id).then((response: PluginConfigValidationResult | undefined) => {
if (response === undefined) return;
setConfigValid(response);
setConfigValidationResult(response);
});
}, [pluginDetailsModal.isOpen]);
function borderColor(state: PluginState | undefined): "success" | "warning" | "danger" | "default" {
function borderColor(state: PluginState | undefined, trustLevel: PluginTrustLevel | undefined): "success" | "warning" | "danger" | "default" {
if (trustLevel === PluginTrustLevel.UNTRUSTED) return "danger";
if (isDisabled(state)) return "warning";
if (configValid === undefined) return "default";
if (!configValid) return "danger";
if (configValidationResult === undefined) return "default";
if (!configValidationResult) return "danger";
return stateToColor(state);
}
@@ -62,11 +68,37 @@ export function PluginManagementCard({plugin, updatePlugin}: {
return <PauseCircle/>;
case PluginState.FAILED:
return <StopCircle/>;
case PluginState.UNLOADED:
case PluginState.RESOLVED:
return <XCircle/>;
default:
return <QuestionMark/>;
}
}
function configValidationResultToChip(validationResult: PluginConfigValidationResult | undefined): ReactNode {
switch (validationResult) {
case PluginConfigValidationResult.VALID:
return <Tooltip content="Config valid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="success">
<CheckCircle/>
</Chip>
</Tooltip>
case PluginConfigValidationResult.INVALID:
return <Tooltip content="Config invalid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="danger">
<WarningCircle/>
</Chip>
</Tooltip>;
default:
return <Tooltip content="Config could not be validated" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs">
<Question/>
</Chip>
</Tooltip>
}
}
function trustLevelToBadge(trustLevel: PluginTrustLevel | undefined): React.ReactNode {
switch (trustLevel) {
case PluginTrustLevel.OFFICIAL:
@@ -82,7 +114,7 @@ export function PluginManagementCard({plugin, updatePlugin}: {
<SealWarning/>
</Tooltip>;
case PluginTrustLevel.UNTRUSTED:
return <Tooltip color="foreground" placement="bottom" content="Invlalid plugin signature">
return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature">
<SealWarning weight="fill" className="fill-danger"/>
</Tooltip>;
default:
@@ -117,11 +149,16 @@ export function PluginManagementCard({plugin, updatePlugin}: {
// @ts-ignore
return (
<>
<Card className={`flex flex-row justify-between p-2 border-2 border-${borderColor(plugin.state)}`}>
<Card
className={`flex flex-row justify-between p-2 border-2 border-${borderColor(plugin.state, plugin.trustLevel)}`}>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content={`${isDisabled(plugin.state) ? "Enable" : "Disable"} plugin`} placement="bottom"
color="foreground">
<Button isIconOnly variant="light" onPress={() => togglePluginEnabled()}>
<Button isIconOnly
variant="light"
onPress={() => togglePluginEnabled()}
isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED}
>
<Power/>
</Button>
</Tooltip>
@@ -145,19 +182,9 @@ export function PluginManagementCard({plugin, updatePlugin}: {
{stateToIcon(plugin.state)}
</Tooltip>
</Chip>
{configValid === undefined ?
<Skeleton className="rounded-md h-6 w-9"/>
: configValid ?
<Tooltip content="Config valid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="success">
<CheckCircle/>
</Chip>
</Tooltip> :
<Tooltip content="Config invalid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="danger">
<WarningCircle/>
</Chip>
</Tooltip>
{configValidationResult === undefined ?
<Skeleton className="rounded-md h-6 w-9"/> :
configValidationResultToChip(configValidationResult)
}
</div>
</div>
@@ -16,7 +16,15 @@ class PluginConfigService(
fun getConfigMetadata(pluginId: String): List<PluginConfigElement> {
log.info { "Getting config metadata for plugin $pluginId" }
val plugin = pluginManager.getPlugin(pluginId).plugin as GameyfinPlugin
val plugin = try {
pluginManager.getPlugin(pluginId).plugin
} catch (_: NoClassDefFoundError) {
return emptyList()
}
if (plugin !is GameyfinPlugin) return emptyList()
return plugin.configMetadata
}
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.core.plugins.config
enum class PluginConfigValidationResult {
VALID,
INVALID,
UNKNWOWN,
}
@@ -9,7 +9,7 @@ import java.nio.file.Path
/**
* @see https://stackoverflow.com/questions/73654174/my-application-cant-find-the-extension-with-pf4j
*/
class GameyfinPluginLoader(
class GameyfinDevelopmentPluginLoader(
pluginManager: PluginManager,
private val parentClassLoader: ClassLoader
) : DevelopmentPluginLoader(pluginManager) {
@@ -0,0 +1,31 @@
package de.grimsi.gameyfin.core.plugins.management
import org.pf4j.DevelopmentPluginLoader
import org.pf4j.PluginDescriptor
import org.pf4j.PluginManager
import org.pf4j.util.FileUtils
import java.nio.file.Files
import java.nio.file.Path
/**
* JAR plugin loader using a [GameyfinPluginClassLoader]
*/
class GameyfinJarPluginLoader(
pluginManager: PluginManager
) : DevelopmentPluginLoader(pluginManager) {
override fun isApplicable(pluginPath: Path): Boolean {
return Files.exists(pluginPath) && FileUtils.isJarFile(pluginPath)
}
override fun loadPlugin(pluginPath: Path, pluginDescriptor: PluginDescriptor?): ClassLoader {
if (pluginDescriptor == null) {
throw IllegalArgumentException("Plugin descriptor cannot be null")
}
val pluginClassLoader = GameyfinPluginClassLoader(pluginManager, pluginDescriptor, javaClass.getClassLoader())
pluginClassLoader.addFile(pluginPath.toFile())
return pluginClassLoader
}
}
@@ -0,0 +1,24 @@
package de.grimsi.gameyfin.core.plugins.management
import org.pf4j.PluginClassLoader
import org.pf4j.PluginDescriptor
import org.pf4j.PluginManager
/**
* Adds custom functionality to the [PluginClassLoader] for Gameyfin (mostly related to JAR signature validation).
*/
class GameyfinPluginClassLoader(
pluginManager: PluginManager,
pluginDescriptor: PluginDescriptor,
parentClassLoader: ClassLoader,
) : PluginClassLoader(pluginManager, pluginDescriptor, parentClassLoader) {
override fun loadClass(className: String?): Class<*>? {
try {
return super.loadClass(className)
} catch (_: SecurityException) {
}
return null
}
}
@@ -1,6 +1,7 @@
package de.grimsi.gameyfin.core.plugins.management
import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
import de.grimsi.gameyfin.core.plugins.config.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.*
@@ -51,13 +52,10 @@ class GameyfinPluginManager(
}
override fun createPluginLoader(): PluginLoader {
val compoundPluginLoader = CompoundPluginLoader()
val developmentPluginLoader = GameyfinPluginLoader(this, javaClass.classLoader)
val jarPluginLoader = JarPluginLoader(this)
return compoundPluginLoader
.add(developmentPluginLoader, this::isDevelopment)
.add(jarPluginLoader, this::isNotDevelopment)
return when (this.isDevelopment) {
true -> GameyfinDevelopmentPluginLoader(this, javaClass.classLoader)
false -> GameyfinJarPluginLoader(this)
}
}
override fun createPluginStatusProvider(): PluginStatusProvider {
@@ -69,9 +67,6 @@ class GameyfinPluginManager(
if (pluginWrapper == null || pluginPath == null) return null
// Inject config after loading, before starting
configurePlugin(pluginWrapper)
var pluginManagementEntry = pluginManagementRepository.findByIdOrNull(pluginWrapper.pluginId)
if (pluginManagementEntry == null) {
@@ -104,6 +99,16 @@ class GameyfinPluginManager(
}
}
// If the plugin is untrusted, we disable it regardless of the previous state
if (pluginManagementEntry.trustLevel == PluginTrustLevel.UNTRUSTED) {
log.warn { "Plugin ${pluginWrapper.pluginId} is untrusted, disabling" }
pluginManagementEntry.enabled = false
}
// Inject config after loading and verification, before starting
// Note: If the plugin is untrusted, we don't want to inject the config
if (pluginManagementEntry.trustLevel != PluginTrustLevel.UNTRUSTED) configurePlugin(pluginWrapper)
log.debug { "Plugin ${pluginWrapper.pluginId} verification status: ${pluginManagementEntry.trustLevel}" }
pluginManagementRepository.save(pluginManagementEntry)
@@ -113,8 +118,17 @@ class GameyfinPluginManager(
override fun startPlugin(pluginId: String?): PluginState? {
if (pluginId == null) return PluginState.FAILED
val trustLevel = pluginManagementRepository.findByIdOrNull(pluginId)?.trustLevel ?: PluginTrustLevel.UNKNOWN
if (trustLevel == PluginTrustLevel.UNTRUSTED) {
val pluginWrapper = getPlugin(pluginId)
val pluginState = PluginState.UNLOADED
this.firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState))
return pluginState
}
// Validate config before starting the plugin
if (!validatePluginConfig(pluginId)) {
if (validatePluginConfig(pluginId) == PluginConfigValidationResult.INVALID) {
log.warn { "Plugin $pluginId has invalid configuration" }
val pluginWrapper = getPlugin(pluginId)
@@ -127,34 +141,8 @@ class GameyfinPluginManager(
}
override fun startPlugins() {
for (pluginWrapper in resolvedPlugins) {
val pluginState = pluginWrapper.pluginState
if (!pluginState.isDisabled && !pluginState.isStarted) {
// Validate config before starting the plugin
if (!validatePluginConfig(pluginWrapper.pluginId)) {
log.error { "Plugin ${pluginWrapper.pluginId} has invalid configuration" }
pluginWrapper.pluginState = PluginState.FAILED
firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState))
return
}
try {
log.info { "Start plugin '${getPluginLabel(pluginWrapper.descriptor)}'" }
pluginWrapper.plugin.start()
pluginWrapper.pluginState = PluginState.STARTED
pluginWrapper.failedException = null
startedPlugins.add(pluginWrapper)
} catch (e: Exception) {
pluginWrapper.pluginState = PluginState.FAILED
pluginWrapper.failedException = e
log.error { "Unable to start plugin '${getPluginLabel(pluginWrapper.descriptor)}': $e" }
} finally {
firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginState))
}
}
}
val pluginsToStart = resolvedPlugins.filter { !it.pluginState.isDisabled && !it.pluginState.isStarted }
pluginsToStart.forEach { startPlugin(it.pluginId) }
}
fun restart(pluginId: String) {
@@ -164,12 +152,18 @@ class GameyfinPluginManager(
startPlugin(pluginId)
}
fun validatePluginConfig(pluginId: String): Boolean {
val plugin = getPlugin(pluginId)?.plugin ?: return false
if (plugin is GameyfinPlugin) {
return plugin.validateConfig()
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult {
val plugin = try {
getPlugin(pluginId)?.plugin
} catch (_: NoClassDefFoundError) {
return PluginConfigValidationResult.UNKNWOWN
}
return false
if (plugin is GameyfinPlugin && plugin.validateConfig()) {
return PluginConfigValidationResult.VALID
}
return PluginConfigValidationResult.INVALID
}
private fun configurePlugin(pluginWrapper: PluginWrapper) {
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.core.plugins.management
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.core.plugins.config.PluginConfigValidationResult
import jakarta.annotation.security.RolesAllowed
@Endpoint
@@ -23,7 +24,8 @@ class PluginManagementEndpoint(
fun disablePlugin(pluginId: String) = pluginManagementService.disablePlugin(pluginId)
fun validatePluginConfig(pluginId: String): Boolean = pluginManagementService.validatePluginConfig(pluginId)
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult =
pluginManagementService.validatePluginConfig(pluginId)
fun setPluginPriority(pluginId: String, priority: Int) =
pluginManagementService.setPluginPriority(pluginId, priority)
@@ -1,7 +1,9 @@
package de.grimsi.gameyfin.core.plugins.management
import de.grimsi.gameyfin.core.plugins.config.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import org.pf4j.ExtensionPoint
import org.pf4j.PluginWrapper
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import java.io.InputStream
@@ -9,37 +11,15 @@ import java.io.InputStream
@Service
class PluginManagementService(
private val pluginManager: GameyfinPluginManager,
private val pluginManagementRepository: PluginManagementRepository
private val pluginManagementRepository: PluginManagementRepository,
) {
fun getPluginDtos(): List<PluginDto> {
return pluginManager.plugins.map {
val pluginManagementEntry = getPluginManagementEntry(it.pluginId)
PluginDto(
it.pluginId,
it.descriptor.pluginDescription,
it.descriptor.version,
it.descriptor.provider,
(it.plugin as GameyfinPlugin).hasLogo(),
it.pluginState,
pluginManagementEntry.priority,
pluginManagementEntry.trustLevel
)
}
return pluginManager.plugins.map { toDto(it) }
}
fun getPluginDto(pluginId: String): PluginDto {
val plugin = pluginManager.getPlugin(pluginId)
val pluginManagementEntry = getPluginManagementEntry(pluginId)
return PluginDto(
plugin.pluginId,
plugin.descriptor.pluginDescription,
plugin.descriptor.version,
plugin.descriptor.provider,
(plugin.plugin as GameyfinPlugin).hasLogo(),
plugin.pluginState,
pluginManagementEntry.priority,
pluginManagementEntry.trustLevel
)
return toDto(plugin)
}
fun getPluginManagementEntry(pluginId: String): PluginManagementEntry {
@@ -73,7 +53,7 @@ class PluginManagementService(
pluginManager.disablePlugin(pluginId)
}
fun validatePluginConfig(pluginId: String): Boolean {
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult {
return pluginManager.validatePluginConfig(pluginId)
}
@@ -95,4 +75,28 @@ class PluginManagementService(
val plugin = pluginManager.getPlugin(pluginId).plugin as GameyfinPlugin
return plugin.getLogo()
}
private fun toDto(pluginWrapper: PluginWrapper): PluginDto {
val pluginManagementEntry = getPluginManagementEntry(pluginWrapper.pluginId)
val hasLogo = try {
when (pluginWrapper.plugin is GameyfinPlugin) {
true -> (pluginWrapper.plugin as GameyfinPlugin).hasLogo()
false -> false
}
} catch (_: NoClassDefFoundError) {
false
}
return PluginDto(
pluginWrapper.pluginId,
pluginWrapper.descriptor.pluginDescription,
pluginWrapper.descriptor.version,
pluginWrapper.descriptor.provider,
hasLogo,
pluginWrapper.pluginState,
pluginManagementEntry.priority,
pluginManagementEntry.trustLevel
)
}
}
+1 -1
View File
@@ -274,7 +274,7 @@ function statsExtracterPlugin(): PluginOption {
const frontendFiles: Record<string, string> = {};
frontendFiles['index.html'] = createHash('sha256').update(customIndexData.replace(/\r\n/g, '\n'), 'utf8').digest('hex');
const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map', '.'];
const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map'];
const isThemeComponentsResource = (id: string) =>
id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))