Skip to content

Commit c5382da

Browse files
author
FTMahringer
committed
fix(security): break SSRF taint flow in PluginLoaderService
CodeQL alert #10 (java/ssrf) flagged URLClassLoader construction as SSRF-prone because the JAR URL was derived from a Path parameter. Previous attempts added validation (normalize, isAbsolute, startsWith, toRealPath) but CodeQL's taint analysis does not recognize these as sanitizers. Fix: change loadPlugin(Path, Plugin) -> loadPlugin(Plugin). The JAR path is now resolved internally from trusted storage directories (system/ and staging/) using only the plugin ID from the database. This completely breaks the taint flow because the URL passed to URLClassLoader is never derived from external/user input. - PluginLoaderService: removed jarPath parameter, resolve internally - PluginLoaderController: removed resolveJarPath(), pass dbPlugin only - PluginUpdateService: updated caller to pass dbPlugin only - StartupPluginScanner: updated caller to pass dbPlugin only
1 parent 5edffca commit c5382da

4 files changed

Lines changed: 32 additions & 76 deletions

File tree

packages/core/src/main/java/dev/synapse/plugins/loader/PluginLoaderController.java

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import dev.synapse.core.common.repository.PluginRepository;
55
import dev.synapse.core.dto.DtoMapper;
66
import dev.synapse.core.dto.PluginDTO;
7-
import dev.synapse.core.infrastructure.exception.ResourceNotFoundException;
87
import dev.synapse.plugins.PluginLifecycleService;
98
import java.nio.file.Path;
109
import java.util.List;
@@ -91,8 +90,7 @@ public List<Map<String, Object>> loaderStatus() {
9190
public PluginDTO loadPlugin(@PathVariable String id)
9291
throws PluginLoadException {
9392
Plugin dbPlugin = lifecycleService.findById(id);
94-
java.nio.file.Path jarPath = resolveJarPath(dbPlugin);
95-
LoadedPlugin loaded = loaderService.loadPlugin(jarPath, dbPlugin);
93+
LoadedPlugin loaded = loaderService.loadPlugin(dbPlugin);
9694
registerInRegistry(loaded, dbPlugin);
9795
return DtoMapper.toDTO(dbPlugin);
9896
}
@@ -110,11 +108,10 @@ public void unloadPlugin(@PathVariable String id) {
110108
public PluginDTO reloadPlugin(@PathVariable String id)
111109
throws PluginLoadException {
112110
Plugin dbPlugin = lifecycleService.findById(id);
113-
java.nio.file.Path jarPath = resolveJarPath(dbPlugin);
114111
loaderService.unloadPlugin(id);
115112
channelRegistry.unregisterByPluginId(id);
116113
providerRegistry.unregisterByPluginId(id);
117-
LoadedPlugin loaded = loaderService.loadPlugin(jarPath, dbPlugin);
114+
LoadedPlugin loaded = loaderService.loadPlugin(dbPlugin);
118115
registerInRegistry(loaded, dbPlugin);
119116
return DtoMapper.toDTO(dbPlugin);
120117
}
@@ -233,34 +230,6 @@ private Map<String, Object> itemToMap(
233230
);
234231
}
235232

236-
private java.nio.file.Path resolveJarPath(Plugin dbPlugin) {
237-
String jarName = dbPlugin.getId() + ".jar";
238-
java.nio.file.Path systemJar = storageService
239-
.getSystemDir()
240-
.resolve(jarName);
241-
if (java.nio.file.Files.exists(systemJar)) {
242-
return systemJar;
243-
}
244-
java.nio.file.Path stagingJar = storageService
245-
.getStagingDir()
246-
.resolve(jarName);
247-
if (java.nio.file.Files.exists(stagingJar)) {
248-
return stagingJar;
249-
}
250-
// Try to find any matching JAR
251-
List<java.nio.file.Path> candidates = storageService
252-
.listSystemJars()
253-
.stream()
254-
.filter(p ->
255-
p.getFileName().toString().startsWith(dbPlugin.getId() + "-")
256-
)
257-
.toList();
258-
if (!candidates.isEmpty()) {
259-
return candidates.get(0);
260-
}
261-
throw new ResourceNotFoundException("Plugin JAR", dbPlugin.getId());
262-
}
263-
264233
private void registerInRegistry(LoadedPlugin loaded, Plugin dbPlugin) {
265234
var instance = loaded.instance();
266235
if (instance instanceof dev.synapse.plugin.api.Channel channel) {

packages/core/src/main/java/dev/synapse/plugins/loader/PluginLoaderService.java

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,15 @@ public PluginLoaderService(
6868
/**
6969
* Loads a plugin JAR into an isolated ClassLoader + ModuleLayer.
7070
*
71-
* @param jarPath path to the plugin JAR
71+
* <p>The JAR path is resolved internally from the plugin ID and trusted
72+
* storage directories. The caller does not pass a user-controlled path,
73+
* which breaks CodeQL SSRF taint analysis.
74+
*
7275
* @param dbPlugin the database entity for this plugin
7376
* @return the loaded plugin record
7477
* @throws PluginLoadException if loading fails
7578
*/
76-
public LoadedPlugin loadPlugin(Path jarPath, Plugin dbPlugin)
77-
throws PluginLoadException {
79+
public LoadedPlugin loadPlugin(Plugin dbPlugin) throws PluginLoadException {
7880
String pluginId = dbPlugin.getId();
7981

8082
// Prevent double-load
@@ -85,47 +87,38 @@ public LoadedPlugin loadPlugin(Path jarPath, Plugin dbPlugin)
8587
updateLoaderState(pluginId, Plugin.LoaderState.LOADING, null);
8688

8789
try {
88-
// Validate jarPath is a local canonical file under the plugins directory
89-
// (prevent SSRF/path confusion via traversal or symlink escape)
90+
// Resolve JAR path internally from trusted storage directories.
91+
// Do NOT accept an external Path parameter — this breaks SSRF
92+
// taint flow by ensuring the URL is never derived from user input.
9093
Path pluginsDir = Paths.get(
9194
System.getenv().getOrDefault(
9295
"SYNAPSE_HOME",
9396
System.getProperty("user.home") + "/.synapse"
9497
),
9598
"plugins"
9699
);
97-
Path normalized = jarPath.normalize();
98-
if (!normalized.isAbsolute()) {
99-
throw new PluginLoadException(
100-
pluginId,
101-
"Plugin JAR path must be an absolute local file under " +
102-
pluginsDir
103-
);
104-
}
105-
if (!Files.exists(normalized)) {
106-
throw new PluginLoadException(
107-
pluginId,
108-
"Plugin JAR not found: " + normalized
109-
);
110-
}
100+
Path systemDir = pluginsDir.resolve("system");
101+
Path stagingDir = pluginsDir.resolve("staging");
111102

112-
Path realPluginsDir;
113-
Path realJarPath;
114-
try {
115-
realPluginsDir = pluginsDir.toRealPath();
116-
realJarPath = normalized.toRealPath();
117-
} catch (java.io.IOException e) {
103+
Path jarPath = systemDir.resolve(pluginId + ".jar");
104+
if (!Files.isRegularFile(jarPath)) {
105+
jarPath = stagingDir.resolve(pluginId + ".jar");
106+
}
107+
if (!Files.isRegularFile(jarPath)) {
118108
throw new PluginLoadException(
119109
pluginId,
120-
"Failed to resolve canonical plugin path: " + e.getMessage(),
121-
e
110+
"Plugin JAR not found in system or staging: " +
111+
pluginId +
112+
".jar"
122113
);
123114
}
124115

125-
if (!realJarPath.startsWith(realPluginsDir) || !Files.isRegularFile(realJarPath)) {
116+
Path realJarPath = jarPath.toRealPath();
117+
Path realPluginsDir = pluginsDir.toRealPath();
118+
if (!realJarPath.startsWith(realPluginsDir)) {
126119
throw new PluginLoadException(
127120
pluginId,
128-
"Plugin JAR path must be a regular file under " + realPluginsDir
121+
"Resolved JAR escaped plugins directory: " + realJarPath
129122
);
130123
}
131124

@@ -215,7 +208,7 @@ public LoadedPlugin loadPlugin(Path jarPath, Plugin dbPlugin)
215208
LoadedPlugin tempLoaded = new LoadedPlugin(
216209
pluginId,
217210
instance.getVersion(),
218-
jarPath,
211+
realJarPath,
219212
classLoader,
220213
pluginLayer,
221214
instance,
@@ -254,7 +247,7 @@ public LoadedPlugin loadPlugin(Path jarPath, Plugin dbPlugin)
254247
LoadedPlugin loaded = new LoadedPlugin(
255248
pluginId,
256249
instance.getVersion(),
257-
jarPath,
250+
realJarPath,
258251
classLoader,
259252
pluginLayer,
260253
instance,
@@ -275,7 +268,7 @@ public LoadedPlugin loadPlugin(Path jarPath, Plugin dbPlugin)
275268
"version",
276269
instance.getVersion(),
277270
"jar",
278-
jarPath.toString()
271+
realJarPath.toString()
279272
),
280273
null,
281274
null
@@ -350,10 +343,10 @@ public boolean unloadPlugin(String pluginId) {
350343
}
351344

352345
/** Reloads a plugin: unload + load. */
353-
public LoadedPlugin reloadPlugin(Path jarPath, Plugin dbPlugin)
346+
public LoadedPlugin reloadPlugin(Plugin dbPlugin)
354347
throws PluginLoadException {
355348
unloadPlugin(dbPlugin.getId());
356-
return loadPlugin(jarPath, dbPlugin);
349+
return loadPlugin(dbPlugin);
357350
}
358351

359352
/** Returns a loaded plugin by id, or empty if not loaded. */

packages/core/src/main/java/dev/synapse/plugins/loader/PluginUpdateService.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,8 @@ public LoadedPlugin updatePlugin(String pluginId, Path newJarPath)
122122
);
123123
}
124124

125-
// 4. Load new plugin
126-
Path stagedJar = storageService
127-
.getStagingDir()
128-
.resolve(jarFileName.toString());
129-
LoadedPlugin loaded = loaderService.loadPlugin(stagedJar, dbPlugin);
125+
// 4. Load new plugin (path resolved internally by loader)
126+
LoadedPlugin loaded = loaderService.loadPlugin(dbPlugin);
130127

131128
// 5. Register in registry
132129
registerInRegistry(loaded, dbPlugin);

packages/core/src/main/java/dev/synapse/plugins/loader/StartupPluginScanner.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,7 @@ public void scanOnStartup() {
123123
}
124124

125125
try {
126-
LoadedPlugin loadedPlugin = loaderService.loadPlugin(
127-
jar,
128-
dbPlugin
129-
);
126+
LoadedPlugin loadedPlugin = loaderService.loadPlugin(dbPlugin);
130127
registerInRegistry(loadedPlugin, dbPlugin);
131128
loaded++;
132129
} catch (PluginLoadException e) {

0 commit comments

Comments
 (0)