From 63fc35ea2295655105c9d1eadaf780eb5fc1ecef Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 6 Dec 2025 15:13:35 -0800 Subject: [PATCH] Add permissions for render plugin --- contrib/render-plugins/example-wasm/README.md | 5 + .../render-plugins/example-wasm/manifest.json | 3 +- contrib/render-plugins/example/README.md | 5 + contrib/render-plugins/example/manifest.json | 3 +- models/migrations/v1_26/v324.go | 1 + models/render/plugin.go | 1 + modules/renderplugin/manifest.go | 27 +++ modules/renderplugin/manifest_test.go | 17 ++ options/locale/locale_en-US.ini | 14 ++ routers/web/admin/render_plugins.go | 214 +++++++++++++++++- routers/web/web.go | 3 + services/renderplugin/service.go | 12 + templates/admin/render/plugin_confirm.tmpl | 89 ++++++++ templates/admin/render/plugin_detail.tmpl | 28 ++- web_src/js/render/plugins/dynamic-plugin.ts | 156 ++++++++++++- 15 files changed, 558 insertions(+), 20 deletions(-) create mode 100644 templates/admin/render/plugin_confirm.tmpl 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.confirm.description"}} +
+
+
+

{{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}} +
+
+
+ {{.CsrfTokenHtml}} + + {{if .RedirectTo}} + + {{end}} + +
+
+ {{.CsrfTokenHtml}} + + {{if .RedirectTo}} + + {{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();