Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ add_executable(plc_main
${CMAKE_SOURCE_DIR}/core/src/plc_app/scan_cycle_manager.c
${CMAKE_SOURCE_DIR}/core/src/drivers/plugin_driver.c
${CMAKE_SOURCE_DIR}/core/src/drivers/plugin_config.c
${CMAKE_SOURCE_DIR}/core/src/drivers/vpp_plugin_seal.c
${CMAKE_SOURCE_DIR}/core/src/plc_app/unix_socket.c
${CMAKE_SOURCE_DIR}/core/src/plc_app/debug_handler.c
${CMAKE_SOURCE_DIR}/core/src/plc_app/client_tcp_udp.c
Expand Down
82 changes: 81 additions & 1 deletion core/src/drivers/plugin_config.c
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,65 @@ static void remove_newline(char *str)
}
}

int parse_plugin_config(const char *config_file, plugin_config_t *configs, int max_configs)
/**
* Reject a plugin path that could point outside the runtime tree.
*
* The `path` field of a plugin config is handed straight to dlopen (VPP) or
* used as a Python module location, so a config the runtime did not write is
* arbitrary-code selection. vpp_plugins.conf IS such a config: it arrives
* verbatim inside the user's upload. The Python side contains it too
* (webserver/plcapp_management.py validate_vpp_plugins_conf); this check is
* here so containment does not depend on one language alone.
*
* @param require_contained 0 to only reject `..` traversal, 1 to also reject
* absolute paths. Config files the runtime itself owns (plugins.conf)
* pass 0: an operator with a hand-written absolute path there is not
* the threat, and refusing it would break working installations. Only
* the upload-supplied config is parsed with 1.
* @return 1 when the path is acceptable, 0 when it must be rejected.
*/
static int plugin_path_is_acceptable(const char *path, int require_contained)
{
if (!path || path[0] == '\0')
{
return 0;
}

/* Any ".." component escapes, in every config, with no legitimate use. */
const char *cursor = path;
while (*cursor != '\0')
{
if (cursor[0] == '.' && cursor[1] == '.' &&
(cursor[2] == '\0' || cursor[2] == '/' || cursor[2] == '\\'))
{
/* Only a ".." that starts a component counts, so a file legally
* named "libfoo..so" is not rejected. */
if (cursor == path || cursor[-1] == '/' || cursor[-1] == '\\')
{
return 0;
}
}
cursor++;
}

if (require_contained)
{
if (path[0] == '/' || path[0] == '\\')
Comment thread
marconetsf marked this conversation as resolved.
{
return 0;
}
/* Windows-style drive prefix ("C:\..."), reachable on the Cygwin build. */
if (path[1] == ':')
{
return 0;
}
}

return 1;
}

static int parse_plugin_config_internal(const char *config_file, plugin_config_t *configs,
int max_configs, int require_contained)
{
FILE *file = fopen(config_file, "r");
if (!file)
Expand Down Expand Up @@ -57,6 +115,17 @@ int parse_plugin_config(const char *config_file, plugin_config_t *configs, int m
configs[config_count].path[sizeof(configs[config_count].path) - 1] = '\0';
remove_newline(configs[config_count].path);

/* Containment: drop the whole entry rather than load from a path that
* escapes the runtime tree. Skipping the entry (instead of aborting the
* parse) keeps one bad line from disabling every other plugin. */
if (!plugin_path_is_acceptable(configs[config_count].path, require_contained))
{
log_error("[PLUGIN] rejected plugin '%s' from %s: path '%s' is not contained in the "
"runtime tree",
configs[config_count].name, config_file, configs[config_count].path);
continue;
}

// Parsing enabled
token = strtok(NULL, ",");
if (!token)
Expand Down Expand Up @@ -110,3 +179,14 @@ int parse_plugin_config(const char *config_file, plugin_config_t *configs, int m
fclose(file);
return config_count;
}

int parse_plugin_config(const char *config_file, plugin_config_t *configs, int max_configs)
{
return parse_plugin_config_internal(config_file, configs, max_configs, 0);
}

int parse_plugin_config_contained(const char *config_file, plugin_config_t *configs,
int max_configs)
{
return parse_plugin_config_internal(config_file, configs, max_configs, 1);
}
19 changes: 19 additions & 0 deletions core/src/drivers/plugin_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ typedef struct
char venv_path[MAX_PLUGIN_PATH_LEN]; // Path to virtual environment
} plugin_config_t;

/**
* Parse a plugin config the runtime owns (plugins.conf).
*
* Entries whose `path` contains a ".." component are rejected and skipped;
* absolute paths are allowed, because an operator may legitimately point a
* hand-written plugins.conf at one.
*/
int parse_plugin_config(const char *config_file, plugin_config_t *configs, int max_configs);

/**
* Parse a plugin config that came from an upload (vpp_plugins.conf).
*
* As above, plus absolute (and Windows drive-prefixed) paths are rejected: the
* `path` field of this file is chosen by whoever produced the upload and is fed
* to dlopen, so it must stay inside the runtime tree. Mirrors the Python-side
* containment in webserver/plcapp_management.py so neither side is the only
* thing standing between an upload and dlopen.
*/
int parse_plugin_config_contained(const char *config_file, plugin_config_t *configs,
int max_configs);

#endif // PLUGIN_CONFIG_H
23 changes: 22 additions & 1 deletion core/src/drivers/plugin_driver.c
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include "../plc_app/utils/utils.h"
#include "plugin_config.h"
#include "plugin_driver.h"
#include "vpp_plugin_seal.h"
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
Expand Down Expand Up @@ -523,7 +524,10 @@ int plugin_driver_append_config(plugin_driver_t *driver, const char *config_file
}

plugin_config_t configs[MAX_PLUGINS];
int config_count = parse_plugin_config(config_file, configs, MAX_PLUGINS);
/* This config file comes from the user's upload (the editor writes it and
* webserver/plcapp_management.py copies it verbatim), so its paths are
* parsed under containment: no "..", no absolute paths. */
int config_count = parse_plugin_config_contained(config_file, configs, MAX_PLUGINS);
if (config_count < 0)
{
return -1;
Expand Down Expand Up @@ -1341,6 +1345,23 @@ int native_plugin_get_symbols(plugin_instance_t *plugin)
return -1;
}

/* Last metre before execution: a VPP plugin .so must match the hash
* scripts/compile.sh sealed after building it from a signature-verified
* upload. The upload gate proved the plugin's INPUTS came from a signed
* package; it cannot speak for the .so, which is linked here afterwards.
* Without this check an object dropped into build/vpp/ after the compile
* would be dlopen'ed with no provenance at all.
*
* Built-in plugins from plugins.conf are produced by the runtime's own
* CMake build and are not sealed -- vpp_plugin_seal_required() scopes the
* check to objects that resolve inside build/vpp/. */
if (vpp_plugin_seal_required(plugin->config.path) &&
vpp_plugin_seal_verify(plugin->config.path) != 0)
{
free(native_bundle);
return -1;
}

// Load the shared library
void *handle = dlopen(plugin->config.path, RTLD_LOCAL | RTLD_NOW);
if (!handle)
Expand Down
Loading