mirror of
https://github.com/go-gitea/gitea.git
synced 2025-12-09 00:21:31 +01:00
Add permissions for render plugin
This commit is contained in:
parent
d22069de0b
commit
63fc35ea22
@ -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:
|
||||
|
||||
@ -7,5 +7,6 @@
|
||||
"entry": "render.js",
|
||||
"filePatterns": [
|
||||
"*.wasmnote"
|
||||
]
|
||||
],
|
||||
"permissions": []
|
||||
}
|
||||
|
||||
@ -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 `<pre>`
|
||||
|
||||
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:
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -5,12 +5,18 @@ 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"
|
||||
)
|
||||
@ -18,8 +24,58 @@ import (
|
||||
const (
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
89
templates/admin/render/plugin_confirm.tmpl
Normal file
89
templates/admin/render/plugin_confirm.tmpl
Normal file
@ -0,0 +1,89 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugins")}}
|
||||
<div class="admin-render-plugin-confirm tw-max-w-4xl tw-mx-auto">
|
||||
<h2 class="tw-mb-4">
|
||||
{{if .IsUpgradePreview}}
|
||||
{{ctx.Locale.Tr "admin.render_plugins.confirm_upgrade" .CurrentPlugin.Name}}
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "admin.render_plugins.confirm_install" .PluginManifest.Name}}
|
||||
{{end}}
|
||||
</h2>
|
||||
<div class="ui message">
|
||||
{{ctx.Locale.Tr "admin.render_plugins.confirm.description"}}
|
||||
</div>
|
||||
<div class="ui segments tw-mb-4">
|
||||
<div class="ui segment">
|
||||
<h3>{{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}</h3>
|
||||
<table class="ui very basic table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="tw-w-48">{{ctx.Locale.Tr "admin.render_plugins.table.name"}}</th>
|
||||
<td>{{.PluginManifest.Name}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}</th>
|
||||
<td>{{.PluginManifest.ID}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.version"}}</th>
|
||||
<td>{{.PluginManifest.Version}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.confirm.archive"}}</th>
|
||||
<td>{{.UploadFilename}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ui segment">
|
||||
<h3>{{ctx.Locale.Tr "admin.render_plugins.confirm.permissions"}}</h3>
|
||||
<p class="tw-text-sm tw-text-gray-500">{{ctx.Locale.Tr "admin.render_plugins.confirm.permission_hint"}}</p>
|
||||
{{if .PluginManifest.Permissions}}
|
||||
<ul class="tw-list-disc tw-ml-6">
|
||||
{{range .PluginManifest.Permissions}}
|
||||
<li><code>{{.}}</code></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p>{{ctx.Locale.Tr "admin.render_plugins.confirm.permission_none"}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .PluginManifest.Description}}
|
||||
<div class="ui segment">
|
||||
<h3>{{ctx.Locale.Tr "admin.render_plugins.detail.description"}}</h3>
|
||||
<p>{{.PluginManifest.Description}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .IsUpgradePreview}}
|
||||
<div class="ui segment">
|
||||
<h3>{{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}</h3>
|
||||
<p>{{ctx.Locale.Tr "admin.render_plugins.detail.entry"}}: {{.PluginManifest.Entry}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-2">
|
||||
<form class="ui form" method="post" action="{{if .IsUpgradePreview}}{{AppSubUrl}}/-/admin/render-plugins/{{.CurrentPlugin.ID}}/upgrade/confirm{{else}}{{AppSubUrl}}/-/admin/render-plugins/upload/confirm{{end}}">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="token" value="{{.PendingUploadToken}}">
|
||||
{{if .RedirectTo}}
|
||||
<input type="hidden" name="redirect_to" value="{{.RedirectTo}}">
|
||||
{{end}}
|
||||
<button class="ui primary button" type="submit">
|
||||
{{if .IsUpgradePreview}}
|
||||
{{ctx.Locale.Tr "admin.render_plugins.confirm.actions.upgrade"}}
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "admin.render_plugins.confirm.actions.install"}}
|
||||
{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<form class="ui form" method="post" action="{{AppSubUrl}}/-/admin/render-plugins/upload/discard">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="token" value="{{.PendingUploadToken}}">
|
||||
{{if .RedirectTo}}
|
||||
<input type="hidden" name="redirect_to" value="{{.RedirectTo}}">
|
||||
{{end}}
|
||||
<button class="ui button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.confirm.actions.cancel"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
||||
@ -66,6 +66,16 @@
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ctx.Locale.Tr "admin.render_plugins.detail.permissions"}}</td>
|
||||
<td>
|
||||
{{if .Plugin.Permissions}}
|
||||
{{range $i, $perm := .Plugin.Permissions}}{{if $i}}, {{end}}<code>{{$perm}}</code>{{end}}
|
||||
{{else}}
|
||||
<span class="text light">{{ctx.Locale.Tr "admin.render_plugins.detail.none"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -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 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<string> {
|
||||
const hosts = new Set<string>();
|
||||
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<string>): 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<string>, fn: () => Promise<void>): Promise<void> {
|
||||
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<string>): 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<string>): 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<string>): 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<string>): 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<string>): 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<FileRenderPlugin[]> {
|
||||
try {
|
||||
const metadata = await fetchRemoteMetadata();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user