0
0
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:
Lunny Xiao 2025-12-06 15:13:35 -08:00
parent d22069de0b
commit 63fc35ea22
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
15 changed files with 558 additions and 20 deletions

View File

@ -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:

View File

@ -7,5 +7,6 @@
"entry": "render.js",
"filePatterns": [
"*.wasmnote"
]
],
"permissions": []
}

View File

@ -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:

View File

@ -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": []
}

View File

@ -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"`

View File

@ -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"`

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}

View File

@ -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() {

View File

@ -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

View 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" .}}

View File

@ -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>

View File

@ -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();