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
4 changes: 4 additions & 0 deletions frontend/src/lib/locales/de/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,10 @@ export default {
routeAuth_session: 'Sitzung',
routeAuth_admin: 'Administrator',
routeAuth_none: 'Öffentlich',
hostFunctionPermissions: 'Host-Funktionsberechtigungen',
permissionGranted: 'Erteilt',
permissionDenied: 'Verweigert',
permissionsDeniedNote: 'Einige deklarierte Berechtigungen sind nicht wirksam.',
moduleSettingsNote: 'Änderungen an Moduleinstellungen werden sofort wirksam. Das Deaktivieren eines Moduls blendet es aus der Navigation aus.',
failedToUpload: 'Hochladen fehlgeschlagen: {error}',
failedToReload: 'Plugin konnte nicht neu geladen werden: {error}',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/locales/en/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,10 @@ export default {
routeAuth_session: 'Session',
routeAuth_admin: 'Admin',
routeAuth_none: 'Public',
hostFunctionPermissions: 'Host Function Permissions',
permissionGranted: 'Granted',
permissionDenied: 'Denied',
permissionsDeniedNote: 'Some declared permissions are not in effect.',
moduleSettingsNote:
'Changes to module settings take effect immediately. Disabling a module will hide it from the navigation.',
failedToUpload: 'Upload failed: {error}',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/locales/fr/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,10 @@ export default {
routeAuth_session: 'Session',
routeAuth_admin: 'Administrateur',
routeAuth_none: 'Public',
hostFunctionPermissions: 'Permissions des fonctions hôtes',
permissionGranted: 'Accordée',
permissionDenied: 'Refusée',
permissionsDeniedNote: 'Certaines permissions déclarées ne sont pas effectives.',
moduleSettingsNote:
'Les modifications des paramètres de module prennent effet immédiatement. Désactiver un module le masquera de la navigation.',
failedToUpload: 'Échec du téléversement : {error}',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/locales/ru/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,10 @@ export default {
routeAuth_session: 'Сессия',
routeAuth_admin: 'Администратор',
routeAuth_none: 'Публичный',
hostFunctionPermissions: 'Разрешения хост-функций',
permissionGranted: 'Предоставлено',
permissionDenied: 'Отклонено',
permissionsDeniedNote: 'Некоторые объявленные разрешения не действуют.',
moduleSettingsNote:
'Изменения настроек модулей применяются сразу. Отключённый модуль будет скрыт из навигации.',
failedToUpload: 'Не удалось загрузить: {error}',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/locales/zh-CN/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,10 @@ export default {
routeAuth_session: '会话',
routeAuth_admin: '管理员',
routeAuth_none: '公开',
hostFunctionPermissions: '宿主函数权限',
permissionGranted: '已授予',
permissionDenied: '已拒绝',
permissionsDeniedNote: '部分声明的权限未生效。',
moduleSettingsNote: '模块设置的更改会立即生效。禁用模块将从导航中隐藏它。',
failedToUpload: '上传失败:{error}',
failedToReload: '重新加载插件失败:{error}',
Expand Down
28 changes: 27 additions & 1 deletion frontend/src/lib/settings/ModuleSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -550,8 +550,34 @@
</div>
</div>
{/if}

{#if plugin.requested_permissions && plugin.requested_permissions.length > 0}
<div class="mt-3">
<p class="text-xs font-medium mb-1" style="color: var(--ds-text-subtle);">{t('settings.modules.hostFunctionPermissions')}:</p>
<div class="flex flex-wrap gap-1">
{#each plugin.requested_permissions as permission, permissionIndex}
{@const granted = (plugin.effective_permissions || []).includes(permission)}
<span
class="text-xs font-mono rounded px-2 py-1"
data-testid={`plugin-permission-${plugin.name}-${permissionIndex}`}
data-permission={permission}
data-granted={granted}
title={granted ? t('settings.modules.permissionGranted') : t('settings.modules.permissionDenied')}
style={granted
? 'background-color: var(--ds-background-neutral); color: var(--ds-text);'
: 'background-color: var(--ds-accent-red-subtle); color: var(--ds-accent-red);'}
>
{permission}
</span>
{/each}
</div>
{#if (plugin.effective_permissions || []).length < plugin.requested_permissions.length}
<p class="text-xs mt-1" style="color: var(--ds-accent-red);">{t('settings.modules.permissionsDeniedNote')}</p>
{/if}
</div>
{/if}
</div>

{#if !pluginsDisabled}
<div class="flex items-center gap-2 ml-4">
<button
Expand Down
34 changes: 22 additions & 12 deletions internal/handlers/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,15 @@ type PluginInfo struct {
Enabled bool `json:"enabled"`
// Licensed reports whether the plugin's license verified for this install.
// Always true when license enforcement is not configured.
Licensed bool `json:"licensed"`
Routes []map[string]string `json:"routes"`
Extensions []plugins.Extension `json:"extensions,omitempty"`
InstalledAt string `json:"installed_at"`
Licensed bool `json:"licensed"`
Routes []map[string]string `json:"routes"`
// RequestedPermissions is what the plugin's manifest declares;
// EffectivePermissions is what it actually holds after manifest validation
// and license gating. Undeclared host functions are denied.
RequestedPermissions []string `json:"requested_permissions,omitempty"`
EffectivePermissions []string `json:"effective_permissions,omitempty"`
Extensions []plugins.Extension `json:"extensions,omitempty"`
InstalledAt string `json:"installed_at"`
}

// ListPlugins returns all installed plugins
Expand Down Expand Up @@ -98,8 +103,11 @@ func (h *PluginHandler) ListPlugins(w http.ResponseWriter, r *http.Request) {
for i := range pluginList {
dbPlugin := &pluginList[i]
if dbPlugin.Name == loadedPlugin.Manifest.Name {
// The loaded state is the source of truth for licensing.
// The loaded state is the source of truth for licensing and
// effective host-function permissions.
dbPlugin.Licensed = loadedPlugin.Licensed
dbPlugin.RequestedPermissions = loadedPlugin.Manifest.Permissions
dbPlugin.EffectivePermissions = loadedPlugin.Permissions
found = true
break
}
Expand All @@ -118,13 +126,15 @@ func (h *PluginHandler) ListPlugins(w http.ResponseWriter, r *http.Request) {
}

pluginList = append(pluginList, PluginInfo{
Name: loadedPlugin.Manifest.Name,
Version: loadedPlugin.Manifest.Version,
Description: loadedPlugin.Manifest.Description,
Author: loadedPlugin.Manifest.Author,
Enabled: loadedPlugin.Enabled,
Licensed: loadedPlugin.Licensed,
Routes: routes,
Name: loadedPlugin.Manifest.Name,
Version: loadedPlugin.Manifest.Version,
Description: loadedPlugin.Manifest.Description,
Author: loadedPlugin.Manifest.Author,
Enabled: loadedPlugin.Enabled,
Licensed: loadedPlugin.Licensed,
Routes: routes,
RequestedPermissions: loadedPlugin.Manifest.Permissions,
EffectivePermissions: loadedPlugin.Permissions,
})
}
}
Expand Down
121 changes: 121 additions & 0 deletions internal/plugins/capabilities.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//go:build !noplugins

package plugins

import (
"fmt"
"sort"
"strings"

extism "github.com/extism/go-sdk"
)

// Host-function permissions. A plugin may call a host function only when its
// manifest declares the matching permission; everything else is denied by
// default. The names form a closed catalog: an unknown name fails the plugin
// load, so a typo cannot silently leave a host function unavailable.
//
// The `log` host function is always available and needs no permission.
const (
PermissionProcessExec = "process.exec"
PermissionNetworkEgress = "network.egress"
PermissionMailSend = "mail.send"
PermissionStorageKV = "storage.kv"
PermissionCommentsWrite = "comments.write"
PermissionSCMWrite = "scm.write"
)

// hostFunctionPermissions maps each registered host function to the permission
// a plugin must declare to call it. TestHostFunctionPermissionMapping fails when
// a registered host function is missing from this table, so a new host function
// cannot ship unguarded.
var hostFunctionPermissions = map[string]string{
"cli_exec": PermissionProcessExec,
"http_fetch": PermissionNetworkEgress,
"smtp_send": PermissionMailSend,
"kv_get": PermissionStorageKV,
"kv_set": PermissionStorageKV,
"kv_delete": PermissionStorageKV,
"create_comment": PermissionCommentsWrite,
"scm_create_branch": PermissionSCMWrite,
"scm_create_item_link": PermissionSCMWrite,
}

// ungatedHostFunctions are always available and require no permission.
var ungatedHostFunctions = map[string]struct{}{
"log": {},
}

// knownPermissions is the closed catalog of valid manifest permissions.
var knownPermissions = func() map[string]struct{} {
known := make(map[string]struct{}, len(hostFunctionPermissions))
for _, permission := range hostFunctionPermissions {
known[permission] = struct{}{}
}
return known
}()

// normalizePermissions trims, deduplicates, and validates a manifest's declared
// permissions. An unknown name is an error so a typo fails the load instead of
// silently leaving the host function denied.
func normalizePermissions(declared []string) ([]string, error) {
seen := make(map[string]struct{}, len(declared))
for _, raw := range declared {
permission := strings.TrimSpace(raw)
if permission == "" {
continue
}
if _, ok := knownPermissions[permission]; !ok {
return nil, fmt.Errorf("unknown permission %q", permission)
}
seen[permission] = struct{}{}
}
if len(seen) == 0 {
return nil, nil
}
normalized := make([]string, 0, len(seen))
for permission := range seen {
normalized = append(normalized, permission)
}
sort.Strings(normalized)
return normalized, nil
}

// deniedHostFunctions lists the gated host functions a plugin cannot call with
// the given effective permissions, in a stable order. The load path logs this so
// an operator sees exactly which host functions a plugin lost access to.
func deniedHostFunctions(effective []string) []string {
granted := make(map[string]struct{}, len(effective))
for _, permission := range effective {
granted[permission] = struct{}{}
}
var denied []string
for hostFunction, permission := range hostFunctionPermissions {
if _, ok := granted[permission]; !ok {
denied = append(denied, hostFunction)
}
}
sort.Strings(denied)
return denied
}

// permissionDeniedMessage is the response body a host function returns when the
// calling plugin did not declare its permission.
func permissionDeniedMessage(permission string) string {
return fmt.Sprintf("host function denied: plugin does not declare permission %q", permission)
}

// validateHostFunctionCatalog verifies that every registered host function is
// either explicitly ungated or carries a permission mapping. NewManager runs it
// against its host function set so a new host function cannot ship unguarded.
func validateHostFunctionCatalog(hostFunctions []extism.HostFunction) error {
for _, hostFunction := range hostFunctions {
if _, ungated := ungatedHostFunctions[hostFunction.Name]; ungated {
continue
}
if _, mapped := hostFunctionPermissions[hostFunction.Name]; !mapped {
return fmt.Errorf("host function %q has no permission mapping", hostFunction.Name)
}
}
return nil
}
8 changes: 8 additions & 0 deletions internal/plugins/cli_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ const (

// cliExecHostFunction executes a CLI command and returns the result.
func (m *Manager) cliExecHostFunction(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
if !m.requirePermission(ctx, PermissionProcessExec) {
m.writeHostResponse(plugin, stack, CLIExecResponse{
Status: "error",
Error: permissionDeniedMessage(PermissionProcessExec),
})
return
}

enabled, err := m.cliExecEnabled()
if err != nil {
m.logger.Error("cli_exec: failed to read security setting", "error", err)
Expand Down
40 changes: 40 additions & 0 deletions internal/plugins/host_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ func (m *Manager) logHostFunction(ctx context.Context, plugin *extism.CurrentPlu
}

func (m *Manager) smtpHostFunction(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
if !m.requirePermission(ctx, PermissionMailSend) {
m.writeHostResponse(plugin, stack, SMTPSendResponse{Status: "error", Error: permissionDeniedMessage(PermissionMailSend)})
return
}

payload, err := plugin.ReadBytes(stack[0])
if err != nil {
m.logger.Warn("smtp_send host function failed to read payload", "error", err)
Expand Down Expand Up @@ -93,6 +98,11 @@ func (m *Manager) smtpHostFunction(ctx context.Context, plugin *extism.CurrentPl
}

func (m *Manager) httpFetchHostFunction(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
if !m.requirePermission(ctx, PermissionNetworkEgress) {
m.writeHostResponse(plugin, stack, HTTPFetchResponse{Status: http.StatusForbidden, Body: []byte(permissionDeniedMessage(PermissionNetworkEgress))})
return
}

payload, err := plugin.ReadBytes(stack[0])
if err != nil {
m.logger.Warn("http_fetch host function failed to read payload", "error", err)
Expand Down Expand Up @@ -184,6 +194,11 @@ func (m *Manager) writeHostResponse(plugin *extism.CurrentPlugin, stack []uint64
}

func (m *Manager) kvGetHostFunction(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
if !m.requirePermission(ctx, PermissionStorageKV) {
m.writeHostResponse(plugin, stack, KVGetResponse{Status: "error", Error: permissionDeniedMessage(PermissionStorageKV)})
return
}

payload, err := plugin.ReadBytes(stack[0])
if err != nil {
m.logger.Warn("kv_get host function failed to read payload", "error", err)
Expand Down Expand Up @@ -234,6 +249,11 @@ func (m *Manager) kvGetHostFunction(ctx context.Context, plugin *extism.CurrentP
}

func (m *Manager) kvSetHostFunction(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
if !m.requirePermission(ctx, PermissionStorageKV) {
m.writeHostResponse(plugin, stack, KVSetResponse{Status: "error", Error: permissionDeniedMessage(PermissionStorageKV)})
return
}

payload, err := plugin.ReadBytes(stack[0])
if err != nil {
m.logger.Warn("kv_set host function failed to read payload", "error", err)
Expand Down Expand Up @@ -292,6 +312,11 @@ func (m *Manager) kvSetHostFunction(ctx context.Context, plugin *extism.CurrentP
}

func (m *Manager) kvDeleteHostFunction(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
if !m.requirePermission(ctx, PermissionStorageKV) {
m.writeHostResponse(plugin, stack, KVDeleteResponse{Status: "error", Error: permissionDeniedMessage(PermissionStorageKV)})
return
}

payload, err := plugin.ReadBytes(stack[0])
if err != nil {
m.logger.Warn("kv_delete host function failed to read payload", "error", err)
Expand Down Expand Up @@ -336,6 +361,11 @@ func (m *Manager) kvDeleteHostFunction(ctx context.Context, plugin *extism.Curre
}

func (m *Manager) createCommentHostFunction(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
if !m.requirePermission(ctx, PermissionCommentsWrite) {
m.writeHostResponse(plugin, stack, CreateCommentResponse{Status: "error", Error: permissionDeniedMessage(PermissionCommentsWrite)})
return
}

payload, err := plugin.ReadBytes(stack[0])
if err != nil {
m.logger.Warn("create_comment host function failed to read payload", "error", err)
Expand Down Expand Up @@ -393,6 +423,11 @@ func (m *Manager) createCommentHostFunction(ctx context.Context, plugin *extism.
}

func (m *Manager) scmCreateBranchHostFunction(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
if !m.requirePermission(ctx, PermissionSCMWrite) {
m.writeHostResponse(plugin, stack, SCMCreateBranchResponse{Status: "error", Error: permissionDeniedMessage(PermissionSCMWrite)})
return
}

payload, err := plugin.ReadBytes(stack[0])
if err != nil {
m.logger.Warn("scm_create_branch host function failed to read payload", "error", err)
Expand Down Expand Up @@ -439,6 +474,11 @@ func (m *Manager) scmCreateBranchHostFunction(ctx context.Context, plugin *extis
}

func (m *Manager) scmCreateItemLinkHostFunction(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
if !m.requirePermission(ctx, PermissionSCMWrite) {
m.writeHostResponse(plugin, stack, SCMCreateItemLinkResponse{Status: "error", Error: permissionDeniedMessage(PermissionSCMWrite)})
return
}

payload, err := plugin.ReadBytes(stack[0])
if err != nil {
m.logger.Warn("scm_create_item_link host function failed to read payload", "error", err)
Expand Down
Loading
Loading