{"record":{"id":"e506a6d2a376506e","repo":"mihomo-party-org/clash-party","slug":"plugin-url-must-use-a-public-host","errorCode":null,"errorMessage":"Plugin URL must use a public host","messagePattern":"Plugin URL must use a public host","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/main/resolve/plugin/remote.ts","lineNumber":16,"sourceCode":"import { getAppConfig } from '../../config/app'\nimport { MAX_PLUGIN_FILE_BYTES } from './constants'\nimport { requestOnce } from './http-client'\nimport { createGuardedLookup, isForbiddenHost } from './net-guard'\n\nfunction parseDownloadUrl(url: string): URL {\n  let parsed: URL\n  try {\n    parsed = new URL(url)\n  } catch {\n    throw new Error('Invalid plugin URL')\n  }\n  if (parsed.protocol !== 'https:') throw new Error('Plugin URL must use https')\n  if (parsed.username || parsed.password) throw new Error('Plugin URL must not contain userinfo')\n  if (parsed.hash) throw new Error('Plugin URL must not contain a fragment')\n  if (isForbiddenHost(parsed.hostname)) throw new Error('Plugin URL must use a public host')\n  return parsed\n}\n\nexport async function fetchRemotePlugin(url: string): Promise<string> {\n  const parsed = parseDownloadUrl(url)\n  const { subscriptionTimeout = 30000, pluginUseProxy } = await getAppConfig()\n  let proxy: { host: string; port: number } | undefined\n  if (pluginUseProxy) {\n    const { getControledMihomoConfig } = await import('../../config/controledMihomo')\n    const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()\n    proxy = { host: '127.0.0.1', port }\n  }\n\n  const response = await requestOnce(parsed.toString(), {\n    method: 'GET',\n    headers: { Accept: 'application/json, application/octet-stream' },\n    timeout: subscriptionTimeout,\n    maxBytes: MAX_PLUGIN_FILE_BYTES,","sourceCodeStart":1,"sourceCodeEnd":34,"githubUrl":"https://github.com/mihomo-party-org/clash-party/blob/911e090537acdf7c50bee1c3aebecc2ef119a8b5/src/main/resolve/plugin/remote.ts#L1-L34","documentation":"parseDownloadUrl applies isForbiddenHost to the hostname and rejects non-public hosts with 'Plugin URL must use a public host'. This is an SSRF guard: downloads must not target localhost, private/loopback/link-local ranges, or other internal addresses reachable from the host machine.","triggerScenarios":"Passing a URL whose hostname resolves into a forbidden class — localhost/127.0.0.1, ::1, 10.x/172.16-31.x/192.168.x, 169.254.x link-local, 0.0.0.0, internal DNS names — e.g. 'https://localhost:8080/plugin.yaml' or 'https://192.168.1.10/p.yaml'.","commonSituations":"Testing the plugin downloader against a local dev server; an internal mirror configured with a private IP; an SSRF attempt via a crafted URL; a hostname that resolves to a private address on the current network.","solutions":["Host the plugin file on a genuinely public https endpoint and use that URL.","For development, expose the local server via a public https tunnel (e.g. a tunneling service) instead of the raw private address.","Audit the URL host against the same forbidden-host rules (loopback, RFC1918, link-local) before calling.","If an internal mirror is legitimate, front it with a public https gateway."],"exampleFix":"// before\nawait fetchRemotePlugin('https://192.168.1.10:8080/plugin.yaml') // SSRF-guarded: rejected\n// after\nawait fetchRemotePlugin('https://plugins.example.com/plugin.yaml')","handlingStrategy":"validation","validationCode":"const u = new URL(input)\nconst host = u.hostname\nconst blocked =\n  host === 'localhost' || host === '0.0.0.0' || host === '::1' ||\n  /^127\\./.test(host) || /^10\\./.test(host) || /^192\\.168\\./.test(host) ||\n  /^169\\.254\\./.test(host) || /^172\\.(1[6-9]|2\\d|3[01])\\./.test(host)\nif (blocked) throw new Error('host must be a public address')","typeGuard":"const isPublicHost = (s: string): boolean => {\n  try {\n    const h = new URL(s).hostname\n    return !(h === 'localhost' || h === '::1' || /^127\\.|^10\\.|^192\\.168\\.|^169\\.254\\.|^172\\.(1[6-9]|2\\d|3[01])\\./.test(h))\n  } catch { return false }\n}","tryCatchPattern":"try {\n  await fetchRemotePlugin(input)\n} catch (e) {\n  if (e.message === 'Plugin URL must use a public host') {\n    showUrlInputError('Private/loopback hosts are blocked (SSRF guard); use a public https endpoint')\n  } else throw e\n}","preventionTips":["Never point downloads at localhost or RFC1918 addresses, even in dev — use a public tunnel instead","Validate hostnames against private/loopback/link-local ranges at input time","Treat SSRF rejection as a security signal, not a bug to bypass","Keep internal mirrors behind a public https gateway if external access is needed"],"tags":["ssrf","security","url","validation","network"],"backgroundTag":"ssrf-blocked-host","analyzedSha":"911e090537acdf7c50bee1c3aebecc2ef119a8b5","analyzedAt":"2026-08-30T13:00:49.174Z","schemaVersion":2},"datasetVersion":"2026-08-30T13:17:10.514Z"}