mihomo-party-org/clash-party · error

Plugin URL must use a public host

Error message

Plugin URL must use a public host

What it means

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.

Source

Thrown at src/main/resolve/plugin/remote.ts:16

import { getAppConfig } from '../../config/app'
import { MAX_PLUGIN_FILE_BYTES } from './constants'
import { requestOnce } from './http-client'
import { createGuardedLookup, isForbiddenHost } from './net-guard'

function parseDownloadUrl(url: string): URL {
  let parsed: URL
  try {
    parsed = new URL(url)
  } catch {
    throw new Error('Invalid plugin URL')
  }
  if (parsed.protocol !== 'https:') throw new Error('Plugin URL must use https')
  if (parsed.username || parsed.password) throw new Error('Plugin URL must not contain userinfo')
  if (parsed.hash) throw new Error('Plugin URL must not contain a fragment')
  if (isForbiddenHost(parsed.hostname)) throw new Error('Plugin URL must use a public host')
  return parsed
}

export async function fetchRemotePlugin(url: string): Promise<string> {
  const parsed = parseDownloadUrl(url)
  const { subscriptionTimeout = 30000, pluginUseProxy } = await getAppConfig()
  let proxy: { host: string; port: number } | undefined
  if (pluginUseProxy) {
    const { getControledMihomoConfig } = await import('../../config/controledMihomo')
    const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
    proxy = { host: '127.0.0.1', port }
  }

  const response = await requestOnce(parsed.toString(), {
    method: 'GET',
    headers: { Accept: 'application/json, application/octet-stream' },
    timeout: subscriptionTimeout,
    maxBytes: MAX_PLUGIN_FILE_BYTES,

View on GitHub (pinned to 911e090537)

Solutions

  1. Host the plugin file on a genuinely public https endpoint and use that URL.
  2. For development, expose the local server via a public https tunnel (e.g. a tunneling service) instead of the raw private address.
  3. Audit the URL host against the same forbidden-host rules (loopback, RFC1918, link-local) before calling.
  4. If an internal mirror is legitimate, front it with a public https gateway.

Example fix

// before
await fetchRemotePlugin('https://192.168.1.10:8080/plugin.yaml') // SSRF-guarded: rejected
// after
await fetchRemotePlugin('https://plugins.example.com/plugin.yaml')
Defensive patterns

Strategy: validation

Validate before calling

const u = new URL(input)
const host = u.hostname
const blocked =
  host === 'localhost' || host === '0.0.0.0' || host === '::1' ||
  /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) ||
  /^169\.254\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host)
if (blocked) throw new Error('host must be a public address')

Type guard

const isPublicHost = (s: string): boolean => {
  try {
    const h = new URL(s).hostname
    return !(h === 'localhost' || h === '::1' || /^127\.|^10\.|^192\.168\.|^169\.254\.|^172\.(1[6-9]|2\d|3[01])\./.test(h))
  } catch { return false }
}

Try / catch

try {
  await fetchRemotePlugin(input)
} catch (e) {
  if (e.message === 'Plugin URL must use a public host') {
    showUrlInputError('Private/loopback hosts are blocked (SSRF guard); use a public https endpoint')
  } else throw e
}

Prevention

When it happens

Trigger: 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'.

Common situations: 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.

Related errors


AI-assisted analysis of mihomo-party-org/clash-party@911e090537 (2026-08-30). Data as JSON: /api/errors/e506a6d2a376506e. Report an issue: GitHub.