diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml
index 72fca76b91..847687dc8d 100644
--- a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml
+++ b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml
@@ -11,6 +11,7 @@ display_name: "Kandev E2E Fixture Plugin"
description: "SDK-based plugin backend used by backend integration tests and Playwright e2e tests"
author: "kandev"
categories: ["tools"]
+repo_url: "https://github.com/kdlbs/kandev-plugin-template"
runtime:
type: binary
diff --git a/apps/backend/cmd/plugin-fixture/fixture_package_test.go b/apps/backend/cmd/plugin-fixture/fixture_package_test.go
index 3681c2a495..5e7c501745 100644
--- a/apps/backend/cmd/plugin-fixture/fixture_package_test.go
+++ b/apps/backend/cmd/plugin-fixture/fixture_package_test.go
@@ -24,6 +24,7 @@ func TestFixtureManifest_ParsesAndValidates(t *testing.T) {
require.Equal(t, 1, m.APIVersion)
require.Equal(t, "1.0.0", m.Version)
require.True(t, m.IsManaged())
+ require.Equal(t, "https://github.com/kdlbs/kandev-plugin-template", m.RepoURL)
require.Equal(t, "/ui/bundle.js", m.UI.Bundle)
require.True(t, m.HasEvent("task.created"))
require.True(t, m.Capabilities.State)
diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go
index a53da07d09..391223ebdd 100644
--- a/apps/backend/internal/plugins/manifest/manifest.go
+++ b/apps/backend/internal/plugins/manifest/manifest.go
@@ -22,6 +22,16 @@ type Manifest struct {
// for an installed plugin it is served from the extracted package.
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"`
+ // RepoURL is an optional URL to the plugin's source repository (e.g.
+ // "https://github.com/owner/plugin"). kandev renders it as a "Repo" link
+ // in the installed-plugin list and detail. Unlike the marketplace
+ // catalog's repo_url — which the registry index-build derives from
+ // plugins.yaml — this is author-declared in the manifest, so sideloaded
+ // and directly-installed plugins carry the link too. When set it must be
+ // an http(s) URL (see Validate); the frontend href guard is enforced here
+ // as well so a bad scheme fails registration rather than reaching the UI.
+ RepoURL string `yaml:"repo_url,omitempty" json:"repo_url,omitempty"`
+
BaseURL string `yaml:"base_url" json:"base_url"`
Endpoints Endpoints `yaml:"endpoints" json:"endpoints"`
diff --git a/apps/backend/internal/plugins/manifest/manifest_test.go b/apps/backend/internal/plugins/manifest/manifest_test.go
index b49930ac15..2c860d9dbb 100644
--- a/apps/backend/internal/plugins/manifest/manifest_test.go
+++ b/apps/backend/internal/plugins/manifest/manifest_test.go
@@ -78,6 +78,36 @@ func validManifest(t *testing.T) *Manifest {
return m
}
+func TestParse_DecodesRepoURL(t *testing.T) {
+ const withRepo = validManifestYAML + "\nrepo_url: \"https://github.com/kdlbs/kandev-plugin-slack\"\n"
+ m, err := Parse([]byte(withRepo))
+ if err != nil {
+ t.Fatalf("Parse() unexpected error: %v", err)
+ }
+ if m.RepoURL != "https://github.com/kdlbs/kandev-plugin-slack" {
+ t.Fatalf("m.RepoURL = %q, want the manifest's repo_url", m.RepoURL)
+ }
+}
+
+func TestValidate_AcceptsHTTPRepoURL(t *testing.T) {
+ m := validManifest(t)
+ m.RepoURL = "https://github.com/kdlbs/kandev-plugin-slack"
+ if err := m.Validate(); err != nil {
+ t.Fatalf("Validate() unexpected error for a valid http(s) repo_url: %v", err)
+ }
+}
+
+func TestValidate_TrimsRepoURLInPlace(t *testing.T) {
+ m := validManifest(t)
+ m.RepoURL = " https://github.com/kdlbs/kandev-plugin-slack "
+ if err := m.Validate(); err != nil {
+ t.Fatalf("Validate() unexpected error: %v", err)
+ }
+ if m.RepoURL != "https://github.com/kdlbs/kandev-plugin-slack" {
+ t.Fatalf("m.RepoURL = %q, want the surrounding whitespace trimmed", m.RepoURL)
+ }
+}
+
func TestValidate_RejectsBadIDPattern(t *testing.T) {
m := validManifest(t)
m.ID = "Kandev_Plugin!"
@@ -180,6 +210,16 @@ func TestValidate_RejectsInvalidManifests(t *testing.T) {
mutate: func(m *Manifest) { m.Version = "1.0.0 " },
wantErr: "version",
},
+ {
+ name: "repo_url with a javascript: scheme",
+ mutate: func(m *Manifest) { m.RepoURL = "javascript:alert(document.cookie)" },
+ wantErr: "repo_url",
+ },
+ {
+ name: "repo_url without an http(s) scheme",
+ mutate: func(m *Manifest) { m.RepoURL = "ftp://example.com/repo" },
+ wantErr: "repo_url",
+ },
}
for _, tt := range tests {
diff --git a/apps/backend/internal/plugins/manifest/validate.go b/apps/backend/internal/plugins/manifest/validate.go
index 754a0d1df1..5f0f2c1c7f 100644
--- a/apps/backend/internal/plugins/manifest/validate.go
+++ b/apps/backend/internal/plugins/manifest/validate.go
@@ -44,6 +44,7 @@ func (m *Manifest) Validate() error {
errs = append(errs, m.validateEndpoints()...)
}
errs = append(errs, m.validateCategories()...)
+ errs = append(errs, m.validateRepoURL()...)
errs = append(errs, m.validateUIPages()...)
errs = append(errs, m.validateUIBundle()...)
errs = append(errs, m.validateWebhooks()...)
@@ -174,6 +175,25 @@ func (m *Manifest) validateCategories() []error {
return errs
}
+// validateRepoURL checks that repo_url, when set, is an http(s) URL. It is
+// surfaced as a clickable "Repo" link in the plugin UI, so a non-http(s)
+// scheme (e.g. "javascript:") is rejected at registration rather than relying
+// solely on the frontend href guard. An empty repo_url is valid (optional).
+func (m *Manifest) validateRepoURL() []error {
+ // Normalise in place so the stored/serialised value matches what was
+ // validated (a manifest with surrounding whitespace would otherwise pass
+ // the check but keep the spaces in the href).
+ m.RepoURL = strings.TrimSpace(m.RepoURL)
+ if m.RepoURL == "" {
+ return nil
+ }
+ u := strings.ToLower(m.RepoURL)
+ if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
+ return []error{fmt.Errorf("repo_url %q must be an http(s) URL", m.RepoURL)}
+ }
+ return nil
+}
+
// validateUIPages checks each declared UI page's surface against the known
// enum (settings | task-panel | main-nav).
func (m *Manifest) validateUIPages() []error {
diff --git a/apps/web/components/settings/plugins/marketplace-entry-row.tsx b/apps/web/components/settings/plugins/marketplace-entry-row.tsx
index 86bc2863d8..681a25c903 100644
--- a/apps/web/components/settings/plugins/marketplace-entry-row.tsx
+++ b/apps/web/components/settings/plugins/marketplace-entry-row.tsx
@@ -1,25 +1,16 @@
"use client";
import { useState } from "react";
-import { IconArrowUpCircle, IconCheck, IconExternalLink, IconStar } from "@tabler/icons-react";
+import { IconArrowUpCircle, IconCheck, IconStar } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import type { MarketplaceEntry } from "@/lib/types/plugins";
+import { PluginRepoLink } from "./plugin-repo-link";
// Id of the built-in official source (marketplace.officialSourceID). Entries
// from any other source get a source badge; the official one does not.
const OFFICIAL_SOURCE_ID = "official";
-/**
- * Guard for rendering a catalog-supplied URL as an . repo_url comes
- * from an untrusted index.json (an operator-added or compromised source), so
- * only http(s) is allowed — a `javascript:` (or other) scheme would execute in
- * the operator's session on click. rel/target do NOT block `javascript:`.
- */
-function isHttpUrl(url: string): boolean {
- return /^https?:\/\//i.test(url.trim());
-}
-
type MarketplaceEntryRowProps = {
entry: MarketplaceEntry;
busy: boolean;
@@ -65,17 +56,7 @@ export function MarketplaceEntryRow({ entry, busy, onInstall }: MarketplaceEntry
{cat}
))}
- {isHttpUrl(entry.repo_url) && (
-
-
{plugin.description}
diff --git a/apps/web/components/settings/plugins/plugin-repo-link.test.tsx b/apps/web/components/settings/plugins/plugin-repo-link.test.tsx new file mode 100644 index 0000000000..912fa4e40b --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-repo-link.test.tsx @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { PluginRepoLink, isHttpUrl } from "./plugin-repo-link"; + +afterEach(() => cleanup()); + +describe("isHttpUrl", () => { + it("accepts http and https, rejects other schemes and blanks", () => { + expect(isHttpUrl("https://github.com/kdlbs/kandev-plugin-acme")).toBe(true); + expect(isHttpUrl("http://example.com")).toBe(true); + expect(isHttpUrl(" https://example.com ")).toBe(true); + expect(isHttpUrl("javascript:alert(1)")).toBe(false); + expect(isHttpUrl("ftp://example.com")).toBe(false); + expect(isHttpUrl("")).toBe(false); + expect(isHttpUrl(undefined)).toBe(false); + expect(isHttpUrl(null)).toBe(false); + }); +}); + +describe("PluginRepoLink", () => { + it("renders an external link for an http(s) URL", () => { + render(