Spaces:
Runtime error
Runtime error
File size: 6,277 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | import { getSettings } from "../db/settings";
import dns from "node:dns/promises";
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
/**
* Plugin Marketplace β browse, search, install plugins from a registry.
*
* Phase 1: Local registry with seed data.
* Phase 2: Remote registry with ratings/downloads.
*
* @module plugins/marketplace
*/
/** Resolve a hostname to every address it maps to (A + AAAA). Injectable for tests. */
export type MarketplaceLookupFn = (hostname: string) => Promise<Array<{ address: string }>>;
const defaultLookup: MarketplaceLookupFn = (hostname) =>
dns.lookup(hostname, { all: true, verbatim: true });
/**
* SSRF guard for a custom marketplace registry URL. Must be http(s) and must not
* target a private/loopback/link-local/ULA address. Unlike a literal-only or
* IPv4-only check, this resolves BOTH IPv4 (A) and IPv6 (AAAA) records and rejects
* if ANY resolved address is private β closing the public-hostname β private-IP
* bypass (IPv6 included: `::1`, `fc00::/7`, `fe80::/10`, IPv4-mapped) via the
* canonical `isPrivateHost`. DNS failure rejects (fail-closed). The fetch itself
* additionally runs through `safeOutboundFetch({ guard: "public-only" })`, which
* re-applies the guard and blocks redirects (no public β private 30x pivot).
*/
export async function isSafeMarketplaceUrl(
urlStr: string,
lookupFn: MarketplaceLookupFn = defaultLookup
): Promise<boolean> {
let parsed: URL;
try {
parsed = new URL(urlStr);
} catch {
return false;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return false;
}
// Literal IP hostnames (IPv4 + IPv6, incl. IPv4-mapped) are classified directly.
if (isPrivateHost(parsed.hostname)) {
return false;
}
// Resolve A + AAAA and reject if the hostname maps to any private address.
try {
const records = await lookupFn(parsed.hostname);
if (!records.length) return false;
for (const { address } of records) {
if (isPrivateHost(address)) return false;
}
} catch {
// DNS resolution failure β reject to be safe.
return false;
}
return true;
}
// Marketplace β local seed registry. Remote registry in Phase 2.
// ββ Types ββ
export interface MarketplaceEntry {
name: string;
version: string;
description: string;
author: string;
license: string;
downloadUrl: string;
repository?: string;
tags: string[];
downloads: number;
rating: number; // 0-5
verified: boolean;
lastUpdated: string;
}
// ββ Seed Data ββ
const SEED_REGISTRY: MarketplaceEntry[] = [
{
name: "request-logger",
version: "1.0.0",
description: "Logs all requests and responses with timing",
author: "omniroute",
license: "MIT",
downloadUrl: "",
tags: ["logging", "debugging"],
downloads: 0,
rating: 5,
verified: true,
lastUpdated: "2026-05-29",
},
{
name: "rate-limiter",
version: "1.0.0",
description: "Per-model rate limiting with sliding window",
author: "omniroute",
license: "MIT",
downloadUrl: "",
tags: ["rate-limit", "security"],
downloads: 0,
rating: 5,
verified: true,
lastUpdated: "2026-05-29",
},
{
name: "cost-tracker",
version: "1.0.0",
description: "Track token costs per request and per model",
author: "omniroute",
license: "MIT",
downloadUrl: "",
tags: ["analytics", "cost"],
downloads: 0,
rating: 4,
verified: true,
lastUpdated: "2026-05-29",
},
{
name: "theme-manager",
version: "1.0.0",
description: "Dynamic UI theme management via CSS variable injection",
author: "omniroute",
license: "MIT",
downloadUrl: "",
tags: ["theme", "ui", "css", "customization"],
downloads: 0,
rating: 5,
verified: true,
lastUpdated: "2026-06-09",
},
];
// ββ API ββ
/**
* List all available plugins in the marketplace.
*/
export async function listMarketplacePlugins(): Promise<MarketplaceEntry[]> {
try {
const settings = await getSettings();
const url = typeof settings.pluginMarketplaceUrl === "string" ? settings.pluginMarketplaceUrl : null;
if (url) {
if (!(await isSafeMarketplaceUrl(url))) {
console.warn("Custom marketplace URL rejected (SSRF guard):", url);
return [...SEED_REGISTRY];
}
const res = await safeOutboundFetch(url, { guard: "public-only", timeoutMs: 5000 });
if (!res.ok) {
console.warn("Custom marketplace returned non-OK status:", res.status);
return [...SEED_REGISTRY];
}
const data = await res.json();
if (Array.isArray(data)) {
return data.filter((entry: unknown) =>
entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).name === "string"
) as MarketplaceEntry[];
}
if (data && typeof data === "object" && Array.isArray((data as Record<string, unknown>).plugins)) {
return ((data as Record<string, unknown>).plugins as unknown[]).filter((entry: unknown) =>
entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).name === "string"
) as MarketplaceEntry[];
}
console.warn("Custom marketplace returned unrecognized format");
}
} catch (err) {
console.error("Failed to fetch from custom plugin marketplace:", err);
}
return [...SEED_REGISTRY];
}
/**
* Search marketplace plugins by query.
*/
export async function searchMarketplace(query: string): Promise<MarketplaceEntry[]> {
const plugins = await listMarketplacePlugins();
const q = query.toLowerCase();
return plugins.filter(
(p) =>
p.name.includes(q) ||
p.description.toLowerCase().includes(q) ||
p.tags.some((t) => t.includes(q))
);
}
/**
* Get a specific marketplace entry.
*/
export async function getMarketplaceEntry(name: string): Promise<MarketplaceEntry | undefined> {
const plugins = await listMarketplacePlugins();
return plugins.find((p) => p.name === name);
}
/**
* Check if marketplace is available.
*/
export function isMarketplaceAvailable(): boolean {
return true; // Always available (falls back to seed)
}
|