diff --git a/contrib/render-plugins/example-wasm/README.md b/contrib/render-plugins/example-wasm/README.md index f60954f4fc..5a01c3dea6 100644 --- a/contrib/render-plugins/example-wasm/README.md +++ b/contrib/render-plugins/example-wasm/README.md @@ -16,6 +16,11 @@ then renders the result inside the file viewer. - `build.sh` — helper script that builds `plugin.wasm` and produces a zip archive ready for upload +As with other plugins, declare any Gitea endpoints or external hosts the WASM +module needs to call inside the `permissions` array in `manifest.json`. Without +an explicit entry, the plugin may only download the file that is currently being +rendered. + ## Build & Install 1. Build the WASM binary and zip archive: diff --git a/contrib/render-plugins/example-wasm/manifest.json b/contrib/render-plugins/example-wasm/manifest.json index c03d6f665c..b592e226aa 100644 --- a/contrib/render-plugins/example-wasm/manifest.json +++ b/contrib/render-plugins/example-wasm/manifest.json @@ -7,5 +7,6 @@ "entry": "render.js", "filePatterns": [ "*.wasmnote" - ] + ], + "permissions": [] } diff --git a/contrib/render-plugins/example/README.md b/contrib/render-plugins/example/README.md index b2273a8dbf..9dda2f1d01 100644 --- a/contrib/render-plugins/example/README.md +++ b/contrib/render-plugins/example/README.md @@ -10,6 +10,11 @@ as a quick way to validate the dynamic plugin system locally. - `render.js` — an ES module that exports a `render(container, fileUrl)` function; it downloads the source file and renders it in a styled `
`
+By default plugins may only fetch the file that is currently being rendered.
+If your plugin needs to contact Gitea APIs or any external services, list their
+domains under the `permissions` array in `manifest.json`. Requests to hosts that
+are not declared there will be blocked by the runtime.
+
## Build & Install
1. Create a zip archive that contains both files:
diff --git a/contrib/render-plugins/example/manifest.json b/contrib/render-plugins/example/manifest.json
index 05addce610..ba2825a1d9 100644
--- a/contrib/render-plugins/example/manifest.json
+++ b/contrib/render-plugins/example/manifest.json
@@ -5,5 +5,6 @@
"version": "1.0.0",
"description": "Simple sample plugin that renders .txt files with a custom color scheme.",
"entry": "render.js",
- "filePatterns": ["*.txt"]
+ "filePatterns": ["*.txt"],
+ "permissions": []
}
diff --git a/models/migrations/v1_26/v324.go b/models/migrations/v1_26/v324.go
index e1c4ccb4d9..1c54fa9517 100644
--- a/models/migrations/v1_26/v324.go
+++ b/models/migrations/v1_26/v324.go
@@ -18,6 +18,7 @@ func AddRenderPluginTable(x *xorm.Engine) error {
Version string `xorm:"NOT NULL"`
Description string `xorm:"TEXT"`
Source string `xorm:"TEXT"`
+ Permissions []string `xorm:"JSON"`
Entry string `xorm:"NOT NULL"`
FilePatterns []string `xorm:"JSON"`
FormatVersion int `xorm:"NOT NULL DEFAULT 1"`
diff --git a/models/render/plugin.go b/models/render/plugin.go
index 1414b2d71f..11d1dcad42 100644
--- a/models/render/plugin.go
+++ b/models/render/plugin.go
@@ -20,6 +20,7 @@ type Plugin struct {
Source string `xorm:"TEXT"`
Entry string `xorm:"NOT NULL"`
FilePatterns []string `xorm:"JSON"`
+ Permissions []string `xorm:"JSON"`
FormatVersion int `xorm:"NOT NULL DEFAULT 1"`
Enabled bool `xorm:"NOT NULL DEFAULT false"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
diff --git a/modules/renderplugin/manifest.go b/modules/renderplugin/manifest.go
index 7866fda070..7fa9796fd8 100644
--- a/modules/renderplugin/manifest.go
+++ b/modules/renderplugin/manifest.go
@@ -29,6 +29,7 @@ type Manifest struct {
Description string `json:"description"`
Entry string `json:"entry"`
FilePatterns []string `json:"filePatterns"`
+ Permissions []string `json:"permissions"`
}
// Normalize validates mandatory fields and normalizes values.
@@ -71,9 +72,34 @@ func (m *Manifest) Normalize() error {
}
sort.Strings(cleanPatterns)
m.FilePatterns = cleanPatterns
+
+ cleanPerms := make([]string, 0, len(m.Permissions))
+ seenPerm := make(map[string]struct{}, len(m.Permissions))
+ for _, perm := range m.Permissions {
+ perm = strings.TrimSpace(strings.ToLower(perm))
+ if perm == "" {
+ continue
+ }
+ if !isValidPermissionHost(perm) {
+ return fmt.Errorf("manifest permission %q is invalid; only plain domains optionally including a port are allowed", perm)
+ }
+ if _, ok := seenPerm[perm]; ok {
+ continue
+ }
+ seenPerm[perm] = struct{}{}
+ cleanPerms = append(cleanPerms, perm)
+ }
+ sort.Strings(cleanPerms)
+ m.Permissions = cleanPerms
return nil
}
+var permissionHostRegexp = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?::[0-9]{1,5})?$`)
+
+func isValidPermissionHost(value string) bool {
+ return permissionHostRegexp.MatchString(value)
+}
+
// LoadManifest reads and validates the manifest.json file located under dir.
func LoadManifest(dir string) (*Manifest, error) {
manifestPath := filepath.Join(dir, "manifest.json")
@@ -103,4 +129,5 @@ type Metadata struct {
AssetsBase string `json:"assetsBaseUrl"`
FilePatterns []string `json:"filePatterns"`
SchemaVersion int `json:"schemaVersion"`
+ Permissions []string `json:"permissions"`
}
diff --git a/modules/renderplugin/manifest_test.go b/modules/renderplugin/manifest_test.go
index 7f1fa82ec1..11ffd2bb7f 100644
--- a/modules/renderplugin/manifest_test.go
+++ b/modules/renderplugin/manifest_test.go
@@ -27,6 +27,7 @@ func TestManifestNormalizeDefaults(t *testing.T) {
assert.Equal(t, "example.plugin", manifest.ID)
assert.Equal(t, "render.js", manifest.Entry)
assert.Equal(t, []string{"*.TXT", "README.md"}, manifest.FilePatterns)
+ assert.Empty(t, manifest.Permissions)
}
func TestManifestNormalizeErrors(t *testing.T) {
@@ -50,6 +51,7 @@ func TestManifestNormalizeErrors(t *testing.T) {
{"missing name", func(m *Manifest) { m.Name = "" }, "name is required"},
{"missing version", func(m *Manifest) { m.Version = "" }, "version is required"},
{"no patterns", func(m *Manifest) { m.FilePatterns = nil }, "at least one file pattern"},
+ {"invalid permission", func(m *Manifest) { m.Permissions = []string{"http://bad"} }, "manifest permission"},
}
for _, tt := range tests {
@@ -82,3 +84,18 @@ func TestLoadManifest(t *testing.T) {
assert.Equal(t, "example", manifest.ID)
assert.Equal(t, []string{"*.md", "*.txt"}, manifest.FilePatterns)
}
+
+func TestManifestNormalizePermissions(t *testing.T) {
+ manifest := Manifest{
+ SchemaVersion: SupportedManifestVersion,
+ ID: "perm",
+ Name: "perm",
+ Version: "1.0.0",
+ Entry: "render.js",
+ FilePatterns: []string{"*.md"},
+ Permissions: []string{" Example.com ", "api.example.com:8080", "example.com", ""},
+ }
+
+ require.NoError(t, manifest.Normalize())
+ assert.Equal(t, []string{"api.example.com:8080", "example.com"}, manifest.Permissions)
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 10665780c5..95c21d28c8 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -3030,6 +3030,20 @@ render_plugins.detail.none = Not provided
render_plugins.detail.file_patterns_empty = No file patterns declared.
render_plugins.detail.actions = Plugin actions
render_plugins.detail.upgrade = Upgrade plugin
+render_plugins.detail.permissions = Permissions
+render_plugins.confirm_install = Review permissions before installing "%s"
+render_plugins.confirm_upgrade = Review permissions before upgrading "%s"
+render_plugins.confirm.description = Gitea will only allow this plugin to contact the domains listed below (plus the file being rendered). Continue only if you trust these endpoints.
+render_plugins.confirm.permissions = Requested domains
+render_plugins.confirm.permission_hint = If the list is empty the plugin will only fetch the file currently being rendered.
+render_plugins.confirm.permission_none = None
+render_plugins.confirm.archive = Archive
+render_plugins.confirm.actions.install = Install Plugin
+render_plugins.confirm.actions.upgrade = Upgrade Plugin
+render_plugins.confirm.actions.cancel = Cancel Upload
+render_plugins.upload_token_invalid = Plugin upload session expired. Please upload the archive again.
+render_plugins.upload_discarded = Plugin upload discarded.
+render_plugins.identifier_mismatch = Uploaded plugin identifier "%s" does not match "%s".
hooks = Webhooks
integrations = Integrations
authentication = Authentication Sources
diff --git a/routers/web/admin/render_plugins.go b/routers/web/admin/render_plugins.go
index 38d209e1ae..62a31475dc 100644
--- a/routers/web/admin/render_plugins.go
+++ b/routers/web/admin/render_plugins.go
@@ -5,21 +5,77 @@ package admin
import (
"fmt"
+ "io"
+ "mime/multipart"
"net/http"
+ "os"
"strings"
+ "sync"
render_model "code.gitea.io/gitea/models/render"
+ "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
plugin_service "code.gitea.io/gitea/services/renderplugin"
)
const (
- tplRenderPlugins templates.TplName = "admin/render/plugins"
- tplRenderPluginDetail templates.TplName = "admin/render/plugin_detail"
+ tplRenderPlugins templates.TplName = "admin/render/plugins"
+ tplRenderPluginDetail templates.TplName = "admin/render/plugin_detail"
+ tplRenderPluginConfirm templates.TplName = "admin/render/plugin_confirm"
)
+type pendingRenderPluginUpload struct {
+ Path string
+ Filename string
+ ExpectedIdentifier string
+ PluginID int64
+}
+
+var (
+ pendingUploadsMu sync.Mutex
+ pendingUploads = make(map[string]*pendingRenderPluginUpload)
+)
+
+func rememberPendingUpload(info *pendingRenderPluginUpload) (string, error) {
+ for {
+ token, err := util.CryptoRandomString(32)
+ if err != nil {
+ return "", err
+ }
+ pendingUploadsMu.Lock()
+ if _, ok := pendingUploads[token]; ok {
+ pendingUploadsMu.Unlock()
+ continue
+ }
+ pendingUploads[token] = info
+ pendingUploadsMu.Unlock()
+ return token, nil
+ }
+}
+
+func takePendingUpload(token string) *pendingRenderPluginUpload {
+ if token == "" {
+ return nil
+ }
+ pendingUploadsMu.Lock()
+ defer pendingUploadsMu.Unlock()
+ info := pendingUploads[token]
+ delete(pendingUploads, token)
+ return info
+}
+
+func discardPendingUpload(info *pendingRenderPluginUpload) {
+ if info == nil {
+ return
+ }
+ if err := os.Remove(info.Path); err != nil && !os.IsNotExist(err) {
+ log.Warn("Failed to remove pending render plugin upload %s: %v", info.Path, err)
+ }
+}
+
// RenderPlugins shows the plugin management page.
func RenderPlugins(ctx *context.Context) {
plugs, err := render_model.ListPlugins(ctx)
@@ -59,12 +115,39 @@ func RenderPluginsUpload(ctx *context.Context) {
redirectRenderPlugins(ctx)
return
}
- if _, err := plugin_service.InstallFromArchive(ctx, file, header.Filename, ""); err != nil {
+ previewPath, err := saveRenderPluginUpload(file)
+ if err != nil {
ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
- } else {
- ctx.Flash.Success(ctx.Tr("admin.render_plugins.upload_success", header.Filename))
+ redirectRenderPlugins(ctx)
+ return
}
- redirectRenderPlugins(ctx)
+ manifest, err := plugin_service.LoadManifestFromArchive(previewPath)
+ if err != nil {
+ _ = os.Remove(previewPath)
+ ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
+ redirectRenderPlugins(ctx)
+ return
+ }
+ token, err := rememberPendingUpload(&pendingRenderPluginUpload{
+ Path: previewPath,
+ Filename: header.Filename,
+ ExpectedIdentifier: "",
+ PluginID: 0,
+ })
+ if err != nil {
+ _ = os.Remove(previewPath)
+ ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
+ redirectRenderPlugins(ctx)
+ return
+ }
+ ctx.Data["Title"] = ctx.Tr("admin.render_plugins.confirm_install", manifest.Name)
+ ctx.Data["PageIsAdminRenderPlugins"] = true
+ ctx.Data["PluginManifest"] = manifest
+ ctx.Data["UploadFilename"] = header.Filename
+ ctx.Data["PendingUploadToken"] = token
+ ctx.Data["IsUpgradePreview"] = false
+ ctx.Data["RedirectTo"] = ctx.FormString("redirect_to")
+ ctx.HTML(http.StatusOK, tplRenderPluginConfirm)
}
// RenderPluginsEnable toggles plugin state to enabled.
@@ -127,7 +210,84 @@ func RenderPluginsUpgrade(ctx *context.Context) {
redirectRenderPlugins(ctx)
return
}
- updated, err := plugin_service.InstallFromArchive(ctx, file, header.Filename, plug.Identifier)
+ previewPath, err := saveRenderPluginUpload(file)
+ if err != nil {
+ ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
+ redirectRenderPlugins(ctx)
+ return
+ }
+ manifest, err := plugin_service.LoadManifestFromArchive(previewPath)
+ if err != nil {
+ _ = os.Remove(previewPath)
+ ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
+ redirectRenderPlugins(ctx)
+ return
+ }
+ if manifest.ID != plug.Identifier {
+ _ = os.Remove(previewPath)
+ ctx.Flash.Error(ctx.Tr("admin.render_plugins.identifier_mismatch", manifest.ID, plug.Identifier))
+ redirectRenderPlugins(ctx)
+ return
+ }
+ token, err := rememberPendingUpload(&pendingRenderPluginUpload{
+ Path: previewPath,
+ Filename: header.Filename,
+ ExpectedIdentifier: plug.Identifier,
+ PluginID: plug.ID,
+ })
+ if err != nil {
+ _ = os.Remove(previewPath)
+ ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
+ redirectRenderPlugins(ctx)
+ return
+ }
+ ctx.Data["Title"] = ctx.Tr("admin.render_plugins.confirm_upgrade", plug.Name)
+ ctx.Data["PageIsAdminRenderPlugins"] = true
+ ctx.Data["PluginManifest"] = manifest
+ ctx.Data["UploadFilename"] = header.Filename
+ ctx.Data["PendingUploadToken"] = token
+ ctx.Data["IsUpgradePreview"] = true
+ ctx.Data["CurrentPlugin"] = plug
+ ctx.Data["RedirectTo"] = ctx.FormString("redirect_to")
+ ctx.HTML(http.StatusOK, tplRenderPluginConfirm)
+}
+
+// RenderPluginsUploadConfirm finalizes a pending plugin installation.
+func RenderPluginsUploadConfirm(ctx *context.Context) {
+ info := takePendingUpload(ctx.FormString("token"))
+ if info == nil || info.PluginID != 0 {
+ ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_token_invalid"))
+ if info != nil {
+ discardPendingUpload(info)
+ }
+ redirectRenderPlugins(ctx)
+ return
+ }
+ _, err := installPendingUpload(ctx, info)
+ if err != nil {
+ ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
+ } else {
+ ctx.Flash.Success(ctx.Tr("admin.render_plugins.upload_success", info.Filename))
+ }
+ redirectRenderPlugins(ctx)
+}
+
+// RenderPluginsUpgradeConfirm finalizes a pending plugin upgrade.
+func RenderPluginsUpgradeConfirm(ctx *context.Context) {
+ plug := mustGetRenderPlugin(ctx)
+ if plug == nil {
+ return
+ }
+ info := takePendingUpload(ctx.FormString("token"))
+ if info == nil || info.PluginID != plug.ID || info.ExpectedIdentifier != plug.Identifier {
+ ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_token_invalid"))
+ if info != nil {
+ discardPendingUpload(info)
+ }
+ redirectRenderPlugins(ctx)
+ return
+ }
+ updated, err := installPendingUpload(ctx, info)
if err != nil {
ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
} else {
@@ -136,6 +296,16 @@ func RenderPluginsUpgrade(ctx *context.Context) {
redirectRenderPlugins(ctx)
}
+// RenderPluginsUploadDiscard removes a pending upload archive without installing it.
+func RenderPluginsUploadDiscard(ctx *context.Context) {
+ info := takePendingUpload(ctx.FormString("token"))
+ if info != nil {
+ discardPendingUpload(info)
+ }
+ ctx.Flash.Success(ctx.Tr("admin.render_plugins.upload_discarded"))
+ redirectRenderPlugins(ctx)
+}
+
func mustGetRenderPlugin(ctx *context.Context) *render_model.Plugin {
id := ctx.PathParamInt64("id")
if id <= 0 {
@@ -163,3 +333,33 @@ func redirectRenderPlugins(ctx *context.Context) {
}
ctx.Redirect(setting.AppSubURL + "/-/admin/render-plugins")
}
+
+func saveRenderPluginUpload(file multipart.File) (_ string, err error) {
+ tmpFile, cleanup, err := setting.AppDataTempDir("render-plugins").CreateTempFileRandom("pending", "*.zip")
+ if err != nil {
+ return "", err
+ }
+ defer func() {
+ if err != nil {
+ cleanup()
+ }
+ }()
+ if _, err = io.Copy(tmpFile, file); err != nil {
+ return "", err
+ }
+ if err = tmpFile.Close(); err != nil {
+ return "", err
+ }
+ return tmpFile.Name(), nil
+}
+
+func installPendingUpload(ctx *context.Context, info *pendingRenderPluginUpload) (*render_model.Plugin, error) {
+ file, err := os.Open(info.Path)
+ if err != nil {
+ discardPendingUpload(info)
+ return nil, err
+ }
+ defer file.Close()
+ defer discardPendingUpload(info)
+ return plugin_service.InstallFromArchive(ctx, file, info.Filename, info.ExpectedIdentifier)
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 0b44e45e19..e6f6252923 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -778,10 +778,13 @@ func registerWebRoutes(m *web.Router) {
m.Get("", admin.RenderPlugins)
m.Get("/{id}", admin.RenderPluginDetail)
m.Post("/upload", admin.RenderPluginsUpload)
+ m.Post("/upload/confirm", admin.RenderPluginsUploadConfirm)
+ m.Post("/upload/discard", admin.RenderPluginsUploadDiscard)
m.Post("/{id}/enable", admin.RenderPluginsEnable)
m.Post("/{id}/disable", admin.RenderPluginsDisable)
m.Post("/{id}/delete", admin.RenderPluginsDelete)
m.Post("/{id}/upgrade", admin.RenderPluginsUpgrade)
+ m.Post("/{id}/upgrade/confirm", admin.RenderPluginsUpgradeConfirm)
})
m.Group("/hooks", func() {
diff --git a/services/renderplugin/service.go b/services/renderplugin/service.go
index 692fae4e47..b82f70a70d 100644
--- a/services/renderplugin/service.go
+++ b/services/renderplugin/service.go
@@ -64,6 +64,7 @@ func InstallFromArchive(ctx context.Context, upload io.Reader, filename, expecte
Source: strings.TrimSpace(filename),
Entry: manifest.Entry,
FilePatterns: manifest.FilePatterns,
+ Permissions: manifest.Permissions,
FormatVersion: manifest.SchemaVersion,
}
if err := render_model.UpsertPlugin(ctx, plug); err != nil {
@@ -72,6 +73,16 @@ func InstallFromArchive(ctx context.Context, upload io.Reader, filename, expecte
return plug, nil
}
+// LoadManifestFromArchive extracts and validates only the manifest from a plugin archive.
+func LoadManifestFromArchive(zipPath string) (*renderplugin.Manifest, error) {
+ _, manifest, cleanup, err := extractArchive(zipPath)
+ if err != nil {
+ return nil, err
+ }
+ defer cleanup()
+ return manifest, nil
+}
+
// Delete removes a plugin from disk and database.
func Delete(ctx context.Context, plug *render_model.Plugin) error {
if err := deletePluginFiles(plug.Identifier); err != nil {
@@ -118,6 +129,7 @@ func BuildMetadata(ctx context.Context) ([]renderplugin.Metadata, error) {
AssetsBase: assetsBase,
FilePatterns: append([]string(nil), plug.FilePatterns...),
SchemaVersion: plug.FormatVersion,
+ Permissions: append([]string(nil), plug.Permissions...),
})
}
return metas, nil
diff --git a/templates/admin/render/plugin_confirm.tmpl b/templates/admin/render/plugin_confirm.tmpl
new file mode 100644
index 0000000000..358aaac0a0
--- /dev/null
+++ b/templates/admin/render/plugin_confirm.tmpl
@@ -0,0 +1,89 @@
+{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugins")}}
+
+
+ {{if .IsUpgradePreview}}
+ {{ctx.Locale.Tr "admin.render_plugins.confirm_upgrade" .CurrentPlugin.Name}}
+ {{else}}
+ {{ctx.Locale.Tr "admin.render_plugins.confirm_install" .PluginManifest.Name}}
+ {{end}}
+
+
+
+
+ {{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}
+
+
+
+ {{ctx.Locale.Tr "admin.render_plugins.table.name"}}
+ {{.PluginManifest.Name}}
+
+
+ {{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}
+ {{.PluginManifest.ID}}
+
+
+ {{ctx.Locale.Tr "admin.render_plugins.table.version"}}
+ {{.PluginManifest.Version}}
+
+
+ {{ctx.Locale.Tr "admin.render_plugins.confirm.archive"}}
+ {{.UploadFilename}}
+
+
+
+
+
+ {{ctx.Locale.Tr "admin.render_plugins.confirm.permissions"}}
+ {{ctx.Locale.Tr "admin.render_plugins.confirm.permission_hint"}}
+ {{if .PluginManifest.Permissions}}
+
+ {{range .PluginManifest.Permissions}}
+ {{.}}
+ {{end}}
+
+ {{else}}
+ {{ctx.Locale.Tr "admin.render_plugins.confirm.permission_none"}}
+ {{end}}
+
+ {{if .PluginManifest.Description}}
+
+ {{ctx.Locale.Tr "admin.render_plugins.detail.description"}}
+ {{.PluginManifest.Description}}
+
+ {{end}}
+ {{if .IsUpgradePreview}}
+
+ {{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}
+ {{ctx.Locale.Tr "admin.render_plugins.detail.entry"}}: {{.PluginManifest.Entry}}
+
+ {{end}}
+
+
+
+
+
+
+
+{{template "admin/layout_footer" .}}
diff --git a/templates/admin/render/plugin_detail.tmpl b/templates/admin/render/plugin_detail.tmpl
index a328a42732..7b02836a9e 100644
--- a/templates/admin/render/plugin_detail.tmpl
+++ b/templates/admin/render/plugin_detail.tmpl
@@ -57,15 +57,25 @@
- {{ctx.Locale.Tr "admin.render_plugins.table.patterns"}}
-
- {{if .Plugin.FilePatterns}}
- {{range $i, $pattern := .Plugin.FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}}
- {{else}}
- {{ctx.Locale.Tr "admin.render_plugins.detail.file_patterns_empty"}}
- {{end}}
-
-
+ {{ctx.Locale.Tr "admin.render_plugins.table.patterns"}}
+
+ {{if .Plugin.FilePatterns}}
+ {{range $i, $pattern := .Plugin.FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}}
+ {{else}}
+ {{ctx.Locale.Tr "admin.render_plugins.detail.file_patterns_empty"}}
+ {{end}}
+
+
+
+ {{ctx.Locale.Tr "admin.render_plugins.detail.permissions"}}
+
+ {{if .Plugin.Permissions}}
+ {{range $i, $perm := .Plugin.Permissions}}{{if $i}}, {{end}}{{$perm}}{{end}}
+ {{else}}
+ {{ctx.Locale.Tr "admin.render_plugins.detail.none"}}
+ {{end}}
+
+
diff --git a/web_src/js/render/plugins/dynamic-plugin.ts b/web_src/js/render/plugins/dynamic-plugin.ts
index 72a1933e23..f206a30a3e 100644
--- a/web_src/js/render/plugins/dynamic-plugin.ts
+++ b/web_src/js/render/plugins/dynamic-plugin.ts
@@ -10,6 +10,7 @@ type RemotePluginMeta = {
entryUrl: string;
assetsBaseUrl: string;
filePatterns: string[];
+ permissions?: string[];
};
type RemotePluginModule = {
@@ -73,12 +74,163 @@ function wrapRemotePlugin(meta: RemotePluginMeta): FileRenderPlugin {
return matcher(filename);
},
async render(container, fileUrl, options) {
- const remote = await loadRemoteModule(meta);
- await remote.render(container, fileUrl, options);
+ const allowedHosts = collectAllowedHosts(meta, fileUrl);
+ await withNetworkRestrictions(allowedHosts, async () => {
+ const remote = await loadRemoteModule(meta);
+ await remote.render(container, fileUrl, options);
+ });
},
};
}
+type RestoreFn = () => void;
+
+function collectAllowedHosts(meta: RemotePluginMeta, fileUrl: string): Set {
+ const hosts = new Set();
+ const addHost = (value?: string | null) => {
+ if (!value) return;
+ hosts.add(value.toLowerCase());
+ };
+
+ addHost(parseHost(fileUrl));
+ for (const perm of meta.permissions ?? []) {
+ addHost(normalizeHost(perm));
+ }
+ return hosts;
+}
+
+function normalizeHost(host: string | null | undefined): string | null {
+ if (!host) return null;
+ return host.trim().toLowerCase();
+}
+
+function parseHost(value: string | URL | null | undefined): string | null {
+ if (!value) return null;
+ try {
+ const url = value instanceof URL ? value : new URL(value, window.location.href);
+ return normalizeHost(url.host);
+ } catch {
+ return null;
+ }
+}
+
+function ensureAllowedHost(kind: string, url: URL, allowedHosts: Set): void {
+ const host = normalizeHost(url.host);
+ if (!host || allowedHosts.has(host)) {
+ return;
+ }
+ throw new Error(`Render plugin network request for ${kind} blocked: ${host} is not in the declared permissions`);
+}
+
+function resolveRequestURL(input: RequestInfo | URL): URL {
+ if (typeof Request !== 'undefined' && input instanceof Request) {
+ return new URL(input.url, window.location.href);
+ }
+ if (input instanceof URL) {
+ return new URL(input.toString(), window.location.href);
+ }
+ return new URL(input as string, window.location.href);
+}
+
+async function withNetworkRestrictions(allowedHosts: Set, fn: () => Promise): Promise {
+ const restoreFns: RestoreFn[] = [];
+ const register = (restorer: RestoreFn | null | undefined) => {
+ if (restorer) {
+ restoreFns.push(restorer);
+ }
+ };
+
+ register(patchFetch(allowedHosts));
+ register(patchXHR(allowedHosts));
+ register(patchSendBeacon(allowedHosts));
+ register(patchWebSocket(allowedHosts));
+ register(patchEventSource(allowedHosts));
+
+ try {
+ await fn();
+ } finally {
+ while (restoreFns.length > 0) {
+ const restore = restoreFns.pop();
+ restore?.();
+ }
+ }
+}
+
+function patchFetch(allowedHosts: Set): RestoreFn {
+ const originalFetch = window.fetch;
+ const guarded = (input: RequestInfo | URL, init?: RequestInit) => {
+ const target = resolveRequestURL(input);
+ ensureAllowedHost('fetch', target, allowedHosts);
+ return originalFetch.call(window, input as any, init);
+ };
+ window.fetch = guarded as typeof window.fetch;
+ return () => {
+ window.fetch = originalFetch;
+ };
+}
+
+function patchXHR(allowedHosts: Set): RestoreFn {
+ const originalOpen = XMLHttpRequest.prototype.open;
+ function guardedOpen(this: XMLHttpRequest, method: string, url: string | URL, async?: boolean, user?: string | null, password?: string | null) {
+ const target = url instanceof URL ? url : new URL(url, window.location.href);
+ ensureAllowedHost('XMLHttpRequest', target, allowedHosts);
+ return originalOpen.call(this, method, url as any, async ?? true, user ?? undefined, password ?? undefined);
+ }
+ XMLHttpRequest.prototype.open = guardedOpen;
+ return () => {
+ XMLHttpRequest.prototype.open = originalOpen;
+ };
+}
+
+function patchSendBeacon(allowedHosts: Set): RestoreFn | null {
+ if (typeof navigator.sendBeacon !== 'function') {
+ return null;
+ }
+ const original = navigator.sendBeacon;
+ const bound = original.bind(navigator);
+ navigator.sendBeacon = ((url: string | URL, data?: BodyInit | null) => {
+ const target = url instanceof URL ? url : new URL(url, window.location.href);
+ ensureAllowedHost('sendBeacon', target, allowedHosts);
+ return bound(url as any, data);
+ }) as typeof navigator.sendBeacon;
+ return () => {
+ navigator.sendBeacon = original;
+ };
+}
+
+function patchWebSocket(allowedHosts: Set): RestoreFn {
+ const OriginalWebSocket = window.WebSocket;
+ const GuardedWebSocket = function(url: string | URL, protocols?: string | string[]) {
+ const target = url instanceof URL ? url : new URL(url, window.location.href);
+ ensureAllowedHost('WebSocket', target, allowedHosts);
+ return new OriginalWebSocket(url as any, protocols);
+ } as unknown as typeof WebSocket;
+ GuardedWebSocket.prototype = OriginalWebSocket.prototype;
+ Object.setPrototypeOf(GuardedWebSocket, OriginalWebSocket);
+ window.WebSocket = GuardedWebSocket;
+ return () => {
+ window.WebSocket = OriginalWebSocket;
+ };
+}
+
+function patchEventSource(allowedHosts: Set): RestoreFn | null {
+ if (typeof window.EventSource !== 'function') {
+ return null;
+ }
+ const OriginalEventSource = window.EventSource;
+ const GuardedEventSource = function(url: string | URL, eventSourceInitDict?: EventSourceInit) {
+ const target = url instanceof URL ? url : new URL(url, window.location.href);
+ ensureAllowedHost('EventSource', target, allowedHosts);
+ return new OriginalEventSource(url as any, eventSourceInitDict);
+ } as unknown as typeof EventSource;
+ GuardedEventSource.prototype = OriginalEventSource.prototype;
+ Object.setPrototypeOf(GuardedEventSource, OriginalEventSource);
+ window.EventSource = GuardedEventSource;
+ return () => {
+ window.EventSource = OriginalEventSource;
+ };
+}
+
export async function loadDynamicRenderPlugins(): Promise {
try {
const metadata = await fetchRemoteMetadata();