diff --git a/src/infra/clawhub-client.ts b/src/infra/clawhub-client.ts index adc0c5ef28cc..9e19af53282b 100644 --- a/src/infra/clawhub-client.ts +++ b/src/infra/clawhub-client.ts @@ -81,13 +81,15 @@ export function resolveClawHubImageUrl(value: string | null | undefined, baseUrl try { const registryUrl = new URL(`${normalizeBaseUrl(baseUrl)}/`); const url = new URL(normalized, registryUrl); + const registryPathPrefix = registryUrl.pathname.replace(/\/+$/u, ""); if ( url.origin !== registryUrl.origin || url.username || url.password || url.search || url.hash || - !/^\/api\/v1\/skill-icons\/[a-f\d]{64}$/u.test(url.pathname) + !url.pathname.startsWith(`${registryPathPrefix}/`) || + !/^\/api\/v1\/skill-icons\/[a-f\d]{64}$/u.test(url.pathname.slice(registryPathPrefix.length)) ) { return undefined; } diff --git a/src/infra/clawhub-skills.test.ts b/src/infra/clawhub-skills.test.ts index e644a3427bd2..9994c5c0d527 100644 --- a/src/infra/clawhub-skills.test.ts +++ b/src/infra/clawhub-skills.test.ts @@ -30,11 +30,11 @@ describe("clawhub skills", () => { delete process.env.CLAWDHUB_DISABLE_TELEMETRY; }); - it("resolves hosted skill icons against the configured ClawHub origin", async () => { + it("resolves search icons against a path-mounted ClawHub registry", async () => { await expect( searchClawHubSkills({ query: "playwright", - baseUrl: "https://registry.example", + baseUrl: "https://registry.example/clawhub", fetchImpl: async () => new Response( JSON.stringify({ @@ -46,7 +46,7 @@ describe("clawhub skills", () => { displayName: "Playwright Interactive", source: "clawhub", install: { kind: "clawhub", reference: "acme/playwright-interactive" }, - icon: `/api/v1/skill-icons/${"a".repeat(64)}`, + icon: `/clawhub/api/v1/skill-icons/${"a".repeat(64)}`, }, ], }), @@ -55,7 +55,7 @@ describe("clawhub skills", () => { }), ).resolves.toMatchObject([ { - icon: `https://registry.example/api/v1/skill-icons/${"a".repeat(64)}`, + icon: `https://registry.example/clawhub/api/v1/skill-icons/${"a".repeat(64)}`, }, ]); }); @@ -334,6 +334,7 @@ describe("clawhub skills", () => { fetchClawHubSkillDetail({ slug: "weather", ownerHandle: "demo-owner", + baseUrl: "https://registry.example/clawhub", fetchImpl: async (input) => { requestedUrl = input instanceof Request ? input.url : String(input); return new Response( @@ -341,7 +342,7 @@ describe("clawhub skills", () => { skill: { slug: "weather", displayName: "Weather", - icon: `/api/v1/skill-icons/${"a".repeat(64)}`, + icon: `/clawhub/api/v1/skill-icons/${"a".repeat(64)}`, createdAt: 1, updatedAt: 2, }, @@ -353,12 +354,12 @@ describe("clawhub skills", () => { ).resolves.toMatchObject({ skill: { slug: "weather", - icon: `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`, + icon: `https://registry.example/clawhub/api/v1/skill-icons/${"a".repeat(64)}`, }, }); const url = new URL(requestedUrl); - expect(url.pathname).toBe("/api/v1/skills/weather"); + expect(url.pathname).toBe("/clawhub/api/v1/skills/weather"); expect(url.searchParams.get("ownerHandle")).toBe("demo-owner"); }); diff --git a/src/plugins/management-service-icon.test.ts b/src/plugins/management-service-icon.test.ts new file mode 100644 index 000000000000..fe6641c12c1b --- /dev/null +++ b/src/plugins/management-service-icon.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveManagedSetupCatalogIconUrl } from "./management-service.js"; + +vi.mock("./provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoices: () => [], +})); + +vi.mock("./recommended-tool-installs.js", () => ({ + listRecommendedToolInstalls: () => [], +})); + +describe("managed ClawHub skill icon URLs", () => { + const digest = "a".repeat(64); + const clawHub = "https://clawhub.ai"; + const registry = "https://registry.example.test"; + const pathRegistry = `${registry}/clawhub`; + const icon = (base: string, value = digest) => `${base}/api/v1/skill-icons/${value}`; + const env = (base: string): NodeJS.ProcessEnv => ({ OPENCLAW_CLAWHUB_URL: base }); + + it.each<[string, NodeJS.ProcessEnv, string]>([ + ["the default registry", {}, icon(clawHub)], + ["a configured registry", env(registry), icon(registry)], + ["a path-mounted registry", env(`${pathRegistry}/`), icon(pathRegistry)], + ["the fallback registry setting", { CLAWHUB_URL: registry }, icon(registry)], + ])("allows skill icons from %s", (_label, registryEnv, iconUrl) => { + expect(resolveManagedSetupCatalogIconUrl({ config: {}, env: registryEnv, iconUrl })).toBe( + iconUrl, + ); + }); + + it.each<[string, NodeJS.ProcessEnv, string]>([ + ["outside the configured path", env(pathRegistry), icon(registry)], + ["on another origin", env(registry), icon("https://attacker.example.test")], + ["with a query", {}, `${icon(clawHub)}?redirect=1`], + ["with a fragment", {}, `${icon(clawHub)}#redirect`], + ["with uppercase digest", {}, icon(clawHub, digest.toUpperCase())], + ["with short digest", {}, icon(clawHub, "a".repeat(63))], + ["on a non-icon path", {}, `${clawHub}/api/v1/packages/${digest}`], + ["when registry has credentials", env("https://user:secret@clawhub.ai"), icon(clawHub)], + ["when icon has credentials", {}, icon("https://user:secret@clawhub.ai")], + [ + "when registry is insecure", + env("http://registry.example.test"), + icon("http://registry.example.test"), + ], + ])("rejects skill icons %s", (_label, registryEnv, iconUrl) => { + expect( + resolveManagedSetupCatalogIconUrl({ config: {}, env: registryEnv, iconUrl }), + ).toBeUndefined(); + }); +}); diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index 67633cb3a044..da4458ce1658 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -14,6 +14,7 @@ import { resolveIsNixMode } from "../config/paths.js"; import { ensurePluginAllowlisted } from "../config/plugins-allowlist.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { resolveClawHubImageUrl } from "../infra/clawhub-client.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import { formatErrorMessage } from "../infra/errors.js"; import { buildNpmResolutionFields, type NpmSpecResolution } from "../infra/install-source-utils.js"; @@ -716,6 +717,24 @@ export function resolveManagedSetupCatalogIconUrl(params: { return undefined; } const env = params.env ?? process.env; + const registryBaseUrl = + normalizeOptionalString(env.OPENCLAW_CLAWHUB_URL) ?? + normalizeOptionalString(env.CLAWHUB_URL) ?? + "https://clawhub.ai"; + const registryUrl = URL.parse(registryBaseUrl); + // Skill icons are registry-owned, not caller-owned; pin the target before + // handing it to the authenticated proxy so it cannot become an SSRF relay. + if ( + registryUrl && + registryUrl.protocol === "https:" && + !registryUrl.username && + !registryUrl.password && + !registryUrl.search && + !registryUrl.hash && + resolveClawHubImageUrl(requested, registryBaseUrl) === requested + ) { + return requested; + } const allowedUrls = [ ...resolveManifestProviderAuthChoices({ config: params.config, diff --git a/ui/src/e2e/clawhub-skill-icons.e2e.test.ts b/ui/src/e2e/clawhub-skill-icons.e2e.test.ts new file mode 100644 index 000000000000..11cce292ad92 --- /dev/null +++ b/ui/src/e2e/clawhub-skill-icons.e2e.test.ts @@ -0,0 +1,169 @@ +// Control UI proof keeps ClawHub skill artwork inside the production Gateway CSP. +import { expect, it } from "vitest"; +import { + buildControlUiCspHeader, + computeInlineScriptHashes, +} from "../../../src/gateway/control-ui-csp.ts"; +import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI ClawHub skill icons", + startServerBeforeBrowser: true, +}); +const skillIconUrl = `https://registry.example.test/clawhub/api/v1/skill-icons/${"a".repeat(64)}`; +const detailIconUrl = `https://registry.example.test/clawhub/api/v1/skill-icons/${"b".repeat(64)}`; +const skillIconPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4n8/wHwAGTQJu5DkvqwAAAABJRU5ErkJggg==", + "base64", +); + +suite.define(() => { + it("loads search and detail artwork through the authenticated proxy under production CSP", async () => { + await suite.withPage( + { + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }, + async ({ page }) => { + await page.addInitScript(() => { + const violations: Array<{ blockedUri: string; effectiveDirective: string }> = []; + Object.assign(globalThis, { __openclawClawHubIconCspViolations: violations }); + document.addEventListener("securitypolicyviolation", (event) => { + violations.push({ + blockedUri: event.blockedURI, + effectiveDirective: event.effectiveDirective, + }); + }); + }); + + await page.addInitScript( + ({ gatewayUrl }) => { + window["__OPENCLAW_NATIVE_CONTROL_AUTH__"] = { gatewayUrl }; + }, + { gatewayUrl: suite.server.baseUrl.replace(/^http/u, "ws") }, + ); + const directImageRequests: string[] = []; + const proxiedImageRequests: Array<{ authorization: string; sourceUrl: string }> = []; + page.on("request", (request) => { + if (request.url().startsWith(`${new URL(skillIconUrl).origin}/`)) { + directImageRequests.push(request.url()); + } + }); + + const gateway = await installMockGateway(page, { + featureMethods: ["skills.status", "skills.search", "skills.detail"], + methodResponses: { + "skills.status": { + workspaceDir: "/tmp/openclaw-e2e/workspace", + managedSkillsDir: "/tmp/openclaw-e2e/skills", + skills: [], + }, + "skills.search": { + results: [ + { + score: 1, + slug: "github", + displayName: "GitHub", + summary: "GitHub integration for OpenClaw", + icon: skillIconUrl, + version: "1.2.3", + }, + ], + }, + "skills.detail": { + skill: { + slug: "github", + displayName: "GitHub", + summary: "GitHub integration for OpenClaw", + icon: detailIconUrl, + createdAt: 1_700_000_000, + updatedAt: 1_700_000_100, + }, + }, + }, + }); + + await page.route("**/__openclaw__/catalog-icon/**", async (route) => { + const request = route.request(); + const encodedSourceUrl = new URL(request.url()).pathname.split("/").at(-1) ?? ""; + proxiedImageRequests.push({ + authorization: request.headers().authorization ?? "", + sourceUrl: decodeURIComponent(encodedSourceUrl), + }); + await route.fulfill({ + body: skillIconPng, + contentType: "image/png", + status: 200, + }); + }); + + const skillsUrl = `${suite.server.baseUrl}skills`; + await page.route(skillsUrl, async (route) => { + const response = await route.fetch(); + const body = await response.text(); + await route.fulfill({ + body, + headers: { + ...response.headers(), + "content-security-policy": buildControlUiCspHeader({ + inlineScriptHashes: computeInlineScriptHashes(body), + }), + }, + response, + }); + }); + + const response = await page.goto(skillsUrl); + expect(response?.status()).toBe(200); + expect(response?.headers()["content-security-policy"]).toContain( + "img-src 'self' data: blob: https://gravatar.com", + ); + await gateway.waitForRequest("skills.status"); + await page.getByPlaceholder("Search ClawHub skills…").fill("github"); + await gateway.waitForRequest("skills.search"); + + const searchIcon = page.locator(".plugins-item .clawhub-skill-icon"); + await expect.poll(async () => await searchIcon.count()).toBe(1); + await expect + .poll( + async () => await searchIcon.evaluate((image: HTMLImageElement) => image.naturalWidth), + ) + .toBeGreaterThan(0); + expect(await searchIcon.getAttribute("src")).toMatch(/^blob:/u); + + await page.getByRole("button", { name: "Open GitHub details" }).click(); + await gateway.waitForRequest("skills.detail"); + const detailIcon = page.locator(".clawhub-skill-icon--detail"); + await expect.poll(async () => await detailIcon.count()).toBe(1); + await expect + .poll( + async () => await detailIcon.evaluate((image: HTMLImageElement) => image.naturalWidth), + ) + .toBeGreaterThan(0); + expect(await detailIcon.getAttribute("src")).toMatch(/^blob:/u); + + expect(proxiedImageRequests).toEqual([ + { authorization: "Bearer e2e-device-token", sourceUrl: skillIconUrl }, + { authorization: "Bearer e2e-device-token", sourceUrl: detailIconUrl }, + ]); + expect(directImageRequests).toEqual([]); + expect(await page.locator('img[src^="https:"]').count()).toBe(0); + expect( + await page.evaluate(() => { + const runtime = globalThis as typeof globalThis & { + __openclawClawHubIconCspViolations?: Array<{ + blockedUri: string; + effectiveDirective: string; + }>; + }; + return (runtime["__openclawClawHubIconCspViolations"] ?? []).filter((violation) => + violation.effectiveDirective.startsWith("img-src"), + ); + }), + ).toEqual([]); + }, + ); + }); +}); diff --git a/ui/src/pages/skills/skills-page.ts b/ui/src/pages/skills/skills-page.ts index 123660e1dff1..f8263df7ff3f 100644 --- a/ui/src/pages/skills/skills-page.ts +++ b/ui/src/pages/skills/skills-page.ts @@ -36,6 +36,7 @@ import { GatewayPageController } from "../../lit/gateway-page-controller.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import { renderPluginsHubHeader } from "../plugins/plugins-hub-header.ts"; +import { ModelSetupIconLoader as CatalogIconLoader } from "../model-setup/model-setup-icon-loader.ts"; import { PLUGINS_HUB_PANEL_ID, type PluginsHubTab } from "../plugins/plugins-hub.ts"; import { renderSkills, type SkillDetailTab, type SkillsStatusFilter } from "./view.ts"; @@ -68,6 +69,7 @@ class SkillsPage extends OpenClawLightDomElement { @state() skillsDetailKey: string | null = null; @state() skillsDetailTab: SkillDetailTab = "overview"; @state() clawhubSearchQuery = ""; + @state() clawhubIconUrls: Record = {}; @state() clawhubDetail: ClawHubSkillDetail | null = null; @state() clawhubDetailRef: string | null = null; @state() clawhubDetailLoading = false; @@ -103,6 +105,11 @@ class SkillsPage extends OpenClawLightDomElement { private routeDataInitialized = false; private routeDataEnabled = true; private debouncedClawHubSearchQuery = ""; + private readonly clawhubIconLoader = new CatalogIconLoader( + () => this.context, + (iconUrl) => this.connected && this.currentClawHubIconUrls().has(iconUrl), + (urls) => (this.clawhubIconUrls = urls), + ); private readonly gateway = new GatewayPageController(this, { getGateway: () => this.context?.gateway, invalidateRequests: () => this.resetLoadedSkillState(), @@ -139,12 +146,17 @@ class SkillsPage extends OpenClawLightDomElement { } } + override updated() { + this.clawhubIconLoader.reconcile(this.currentClawHubIconUrls()); + } + override disconnectedCallback() { this.subscriptions.clear(); if (this.clawhubSearchTimer) { clearTimeout(this.clawhubSearchTimer); this.clawhubSearchTimer = null; } + this.clawhubIconLoader.reset(); super.disconnectedCallback(); } @@ -162,6 +174,7 @@ class SkillsPage extends OpenClawLightDomElement { private resetLoadedSkillState() { void this.clawhubSearchTask.run([null, ""]); + this.clawhubIconLoader.reset(); if (this.clawhubSearchTimer) { clearTimeout(this.clawhubSearchTimer); this.clawhubSearchTimer = null; @@ -194,6 +207,18 @@ class SkillsPage extends OpenClawLightDomElement { this.skillCardErrors = {}; } + private currentClawHubIconUrls(): Set { + if (!this.connected) { + return new Set(); + } + return new Set( + [ + ...(this.clawhubSearchResults ?? []).map((result) => result.icon), + this.clawhubDetail?.skill?.icon, + ].flatMap((icon) => (icon ? [icon] : [])), + ); + } + private applyRouteData() { const data = this.routeData; if (!data) { @@ -395,6 +420,7 @@ class SkillsPage extends OpenClawLightDomElement { skillCardErrors: this.skillCardErrors, clawhubQuery: this.clawhubSearchQuery, clawhubResults: this.clawhubSearchResults, + clawhubIconUrls: this.clawhubIconUrls, clawhubSearchLoading: this.clawhubSearchLoading, clawhubSearchError: this.clawhubSearchError, clawhubDetail: this.clawhubDetail, diff --git a/ui/src/pages/skills/view.clawhub.test.ts b/ui/src/pages/skills/view.clawhub.test.ts index 5f2fccd272ad..68f040509502 100644 --- a/ui/src/pages/skills/view.clawhub.test.ts +++ b/ui/src/pages/skills/view.clawhub.test.ts @@ -26,7 +26,7 @@ describe("renderSkills ClawHub", () => { await i18n.setLocale("en"); }); - it("opens detail dialogs and routes ClawHub actions", async () => { + it("opens detail dialogs, avoids owner images, and routes ClawHub actions", async () => { const container = document.createElement("div"); document.body.append(container); dialogRestores.push(() => container.remove()); @@ -79,6 +79,9 @@ describe("renderSkills ClawHub", () => { version: "1.2.3", }, ], + clawhubIconUrls: { + [`https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`]: "blob:clawhub-search-icon", + }, onClawHubDetailOpen, onClawHubInstall, }), @@ -101,7 +104,7 @@ describe("renderSkills ClawHub", () => { ); expect(resultItem?.querySelector(".settings-row__value")?.textContent?.trim()).toBe("v1.2.3"); expect(resultItem?.querySelector(".clawhub-skill-icon")?.src).toBe( - `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`, + "blob:clawhub-search-icon", ); expect(installButton?.textContent?.trim()).toBe("Install"); detailButton!.click(); @@ -121,6 +124,9 @@ describe("renderSkills ClawHub", () => { clawhubSearchError: "rate limited", clawhubInstallMessage: { kind: "success", text: "Installed github" }, clawhubDetailRef: "github", + clawhubIconUrls: { + [`https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`]: "blob:clawhub-detail-icon", + }, clawhubDetail: { skill: { slug: "github", @@ -158,7 +164,7 @@ describe("renderSkills ClawHub", () => { "GitHub integration for OpenClaw By OpenClaw (@openclaw) Latest: v1.2.3 Added search support Platforms: macos, linux Install GitHub", ); expect(container.querySelector(".clawhub-skill-icon--detail")?.src).toBe( - `https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`, + "blob:clawhub-detail-icon", ); expect(container.querySelector(".clawhub-skill-icon--profile")).toBeNull(); @@ -170,6 +176,22 @@ describe("renderSkills ClawHub", () => { expect(onClawHubInstall).toHaveBeenCalledTimes(1); expect(onClawHubInstall).toHaveBeenCalledWith("github"); + + render( + renderSkills( + createProps({ + clawhubDetailRef: "github", + clawhubDetail: { + skill: { slug: "github", displayName: "GitHub", createdAt: 1, updatedAt: 1 }, + owner: { image: "https://attacker.example/profile.png" }, + }, + }), + ), + container, + ); + await Promise.resolve(); + + expect(container.querySelector('img[src^="https:"]')).toBeNull(); }); it("routes each same-slug search result to its own publisher", async () => { diff --git a/ui/src/pages/skills/view.test-support.ts b/ui/src/pages/skills/view.test-support.ts index cf50ca18d524..e9c3750f7bb6 100644 --- a/ui/src/pages/skills/view.test-support.ts +++ b/ui/src/pages/skills/view.test-support.ts @@ -84,6 +84,7 @@ export function createProps(overrides: Partial = {}): SkillsProps { skillCardErrors: {}, clawhubQuery: "", clawhubResults: null, + clawhubIconUrls: {}, clawhubSearchLoading: false, clawhubSearchError: null, clawhubDetail: null, diff --git a/ui/src/pages/skills/view.ts b/ui/src/pages/skills/view.ts index 773112683093..815962ee20fb 100644 --- a/ui/src/pages/skills/view.ts +++ b/ui/src/pages/skills/view.ts @@ -80,6 +80,7 @@ type SkillsProps = { skillCardErrors: Record; clawhubQuery: string; clawhubResults: ClawHubSearchResult[] | null; + clawhubIconUrls: Record; clawhubSearchLoading: boolean; clawhubSearchError: string | null; clawhubDetail: ClawHubSkillDetail | null; @@ -451,7 +452,7 @@ function renderClawHubResults(props: SkillsProps) { } return html` ${results.map((r) => { - const iconUrl = safeExternalHref(r.icon ?? undefined); + const iconUrl = r.icon ? props.clawhubIconUrls[r.icon] : undefined; // Same slug can appear once per publisher, so the reference is the only thing that tells // otherwise identical rows apart — and it is what install sends back. const ref = clawHubSkillRef(r); @@ -509,9 +510,8 @@ function renderClawHubResults(props: SkillsProps) { function renderClawHubDetailDialog(props: SkillsProps) { const detail = props.clawhubDetail; - const skillIconUrl = safeExternalHref(detail?.skill?.icon ?? undefined); - const profileImageUrl = skillIconUrl ? null : safeExternalHref(detail?.owner?.image ?? undefined); - const detailImageUrl = skillIconUrl ?? profileImageUrl; + const detailIcon = detail?.skill?.icon; + const detailImageUrl = detailIcon ? props.clawhubIconUrls[detailIcon] : undefined; return html` ${detailImageUrl ? html``