fix(cli): --insecure also skips TLS certificate verification (#38531)

This commit is contained in:
Xiyuan Chen 2026-07-08 00:58:31 -07:00 committed by GitHub
parent 5d61318860
commit ae1e180b54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 190 additions and 34 deletions

View File

@ -113,6 +113,21 @@ describe('Registry (pure)', () => {
expect(active?.ctx.account.email).toBe('a@x')
})
it('resolveActive returns the active context with insecureTls', () => {
const reg = baseReg()
reg.upsert('h1', 'a@x', ctx('a@x'))
reg.setInsecureTls('h1', true)
reg.setHost('h1')
reg.setAccount('a@x')
expect(reg.resolveActive()?.insecureTls).toBe(true)
})
it('setInsecureTls is a no-op for an unknown host', () => {
const reg = baseReg()
reg.setInsecureTls('missing', true)
expect(reg.hosts.missing).toBeUndefined()
})
it('resolveActive returns undefined for each missing pointer', () => {
const reg = baseReg()
expect(reg.resolveActive()).toBeUndefined()

View File

@ -41,6 +41,7 @@ export type AccountContext = z.infer<typeof AccountContextSchema>
export const HostEntrySchema = z.object({
scheme: z.string().optional(),
insecure_tls: z.boolean().optional(),
current_account: z.string().optional(),
accounts: z.record(z.string(), AccountContextSchema).default({}),
})
@ -58,6 +59,7 @@ export type ActiveContext = {
readonly email: string
readonly ctx: AccountContext
readonly scheme?: string
readonly insecureTls?: boolean
}
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
@ -104,7 +106,7 @@ export class Registry {
const ctx = entry.accounts[email]
if (ctx === undefined)
return undefined
return { host, email, ctx, scheme: entry.scheme }
return { host, email, ctx, scheme: entry.scheme, insecureTls: entry.insecure_tls }
}
requireActive(hint?: string): ActiveContext {
@ -157,6 +159,12 @@ export class Registry {
entry.scheme = scheme
}
setInsecureTls(host: string, insecure: boolean): void {
const entry = this.data.hosts[host]
if (entry !== undefined)
entry.insecure_tls = insecure
}
activate(host: string, email: string, ctx: AccountContext): void {
this.upsert(host, email, ctx)
this.setHost(host)

View File

@ -13,7 +13,7 @@ import { formatErrorForCli } from '@/errors/format'
import { createHttpClient } from '@/http/client'
import { getTokenStore } from '@/store/manager'
import { realStreams } from '@/sys/io/streams'
import { hostWithScheme, openAPIBase } from '@/util/host'
import { activeHostInfo, openAPIBase } from '@/util/host'
import { enforceDifyVersion } from '@/version/enforce'
import { versionInfo } from '@/version/info'
import { maybeNudgeCompat } from '@/version/nudge'
@ -50,17 +50,17 @@ export async function buildAuthedContext(
if (bearer === '')
fail(cmd, opts, io)
const host = hostWithScheme(active.host, active.scheme)
const { host, insecure } = activeHostInfo(active)
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts })
const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts, insecure })
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
// Hard gate: refuse a server too old for this difyctl (throws → exit 6).
// Cached per host (1h) so most commands don't re-probe. Then the soft nudge
// handles the "server too new" direction.
await enforceDifyVersion(host)
await runCompatNudge({ host, io })
await enforceDifyVersion(host, { insecure })
await runCompatNudge({ host, insecure, io })
return { reg, active, store, http, host, io, cache }
}
@ -74,6 +74,7 @@ function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStr
// command flows through it without per-command wiring.
async function runCompatNudge(opts: {
readonly host: string
readonly insecure: boolean
readonly io: IOStreams
}): Promise<void> {
try {
@ -81,7 +82,7 @@ async function runCompatNudge(opts: {
await maybeNudgeCompat(opts.host, {
store,
probe: async (host) => {
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure: opts.insecure })
return new MetaClient(http).serverVersion()
},
emit: line => opts.io.err.write(line),

View File

@ -27,7 +27,7 @@ export default class Login extends DifyCommand {
default: false,
}),
'insecure': Flags.boolean({
description: 'allow http:// hosts (local-dev only)',
description: 'allow http:// hosts and skip TLS certificate verification (local-dev only)',
default: false,
}),
}
@ -40,7 +40,7 @@ export default class Login extends DifyCommand {
noBrowser: flags['no-browser'],
insecure: flags.insecure,
verifyServer: async (host) => {
await enforceDifyVersion(host, { forceFresh: true })
await enforceDifyVersion(host, { forceFresh: true, insecure: flags.insecure })
},
})
}

View File

@ -44,7 +44,7 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
const host = await resolveLoginHost(opts, insecure)
const label = opts.deviceLabel ?? defaultDeviceLabel()
const api = opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host) }))
const api = opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host), insecure }))
const code = await api.requestCode({ device_label: label })
renderCodePrompt(opts.io.err, cs, code)
@ -88,6 +88,7 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
reg.token_storage = storeBundle.mode
reg.activate(display, email, ctx)
applyScheme(reg, display, host)
reg.setInsecureTls(display, insecure)
await reg.save()
renderLoggedIn(opts.io.out, cs, host, success)

View File

@ -6,7 +6,7 @@ import { createHttpClient } from '@/http/client'
import { getTokenStore } from '@/store/manager'
import { runWithSpinner } from '@/sys/io/spinner'
import { realStreams } from '@/sys/io/streams'
import { hostWithScheme, openAPIBase } from '@/util/host'
import { activeHostInfo, openAPIBase } from '@/util/host'
import { runLogout } from './logout.js'
export default class Logout extends DifyCommand {
@ -32,7 +32,8 @@ export default class Logout extends DifyCommand {
}
catch { /* keyring locked — skip remote revocation, local cleanup still runs */ }
if (bearer !== '') {
http = createHttpClient({ baseURL: openAPIBase(hostWithScheme(active.host, active.scheme)), bearer, retryAttempts: 0 })
const { host, insecure } = activeHostInfo(active)
http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts: 0, insecure })
}
}

View File

@ -0,0 +1,66 @@
import type { Buffer } from 'node:buffer'
import type { AddressInfo } from 'node:net'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import * as https from 'node:https'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createHttpClient } from './client.js'
function generateSelfSignedCert(dir: string): { key: Buffer, cert: Buffer } {
const keyPath = join(dir, 'key.pem')
const certPath = join(dir, 'cert.pem')
execFileSync('openssl', [
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-keyout',
keyPath,
'-out',
certPath,
'-days',
'1',
'-subj',
'/CN=localhost',
], { stdio: ['ignore', 'ignore', 'pipe'] })
return { key: readFileSync(keyPath), cert: readFileSync(certPath) }
}
// A real server, not a fetch mock, so this also covers Bun's native `tls`
// fetch option (ignored by Node, which only reads undici's `dispatcher`).
describe('createHttpClient against a real self-signed TLS server', () => {
let server: https.Server
let baseURL: string
let certDir: string
beforeAll(async () => {
certDir = mkdtempSync(join(tmpdir(), 'difyctl-tls-test-'))
const { key, cert } = generateSelfSignedCert(certDir)
server = https.createServer({ key, cert }, (_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
await new Promise<void>(resolve => server.listen(0, resolve))
const port = (server.address() as AddressInfo).port
baseURL = `https://localhost:${port}/`
})
afterAll(async () => {
await new Promise<void>(resolve => server.close(() => resolve()))
rmSync(certDir, { recursive: true, force: true })
})
it('rejects the self-signed cert by default', async () => {
const http = createHttpClient({ baseURL, retryAttempts: 0 })
await expect(http.get('')).rejects.toBeDefined()
})
it('accepts the self-signed cert when insecure: true', async () => {
const http = createHttpClient({ baseURL, retryAttempts: 0, insecure: true })
const res = await http.get<{ ok: boolean }>('')
expect(res.ok).toBe(true)
})
})

View File

@ -38,6 +38,7 @@ type ClientState = {
readonly logger: HttpLogger | undefined
readonly originalOptions: ClientOptions
readonly dispatcher: ReturnType<typeof proxyDispatcher>
readonly insecure: boolean
}
function toArray<T>(value: T | T[] | undefined): T[] {
@ -74,7 +75,8 @@ function compileState(opts: ClientOptions): ClientState {
hooks: { onRequest, onResponse, onRequestError, onResponseError },
logger: opts.logger,
originalOptions: opts,
dispatcher: proxyDispatcher(),
dispatcher: proxyDispatcher({ insecure: opts.insecure }),
insecure: opts.insecure ?? false,
}
}
@ -171,9 +173,16 @@ async function execute(
await runHooks(state.hooks.onRequest, ctx)
const init: RequestInit & { dispatcher?: unknown, verbose?: boolean } = { signal }
// Two runtimes, two options: Node's fetch reads undici's `dispatcher` (used
// below for TLS-skip + proxy routing); Bun's native fetch — what the compiled
// difyctl binary actually runs on — ignores `dispatcher` entirely and instead
// needs its own `tls` option. Set both; each runtime ignores the one it
// doesn't understand.
const init: RequestInit & { dispatcher?: unknown, tls?: { rejectUnauthorized: boolean }, verbose?: boolean } = { signal }
if (state.dispatcher !== undefined)
init.dispatcher = state.dispatcher
if (state.insecure)
init.tls = { rejectUnauthorized: false }
if (isVerbose())
init.verbose = true

View File

@ -52,4 +52,28 @@ describe('proxyDispatcher', () => {
expect(proxyDispatcher()).toBe(first)
await first?.close()
})
it('builds a plain Agent with TLS verification disabled when insecure is set and no proxy env', async () => {
const { proxyDispatcher } = await import('./proxy.js')
const d = proxyDispatcher({ insecure: true })
expect(d?.constructor.name).toBe('Agent')
await d?.close()
})
it('builds an EnvHttpProxyAgent with TLS verification disabled when insecure and a proxy are both set', async () => {
process.env.HTTP_PROXY = 'http://127.0.0.1:8888'
const { proxyDispatcher } = await import('./proxy.js')
const d = proxyDispatcher({ insecure: true })
expect(d?.constructor.name).toBe('EnvHttpProxyAgent')
await d?.close()
})
it('re-resolves when the insecure flag changes for the same call site', async () => {
const { proxyDispatcher } = await import('./proxy.js')
const secure = proxyDispatcher({ insecure: false })
expect(secure).toBeUndefined()
const insecure = proxyDispatcher({ insecure: true })
expect(insecure?.constructor.name).toBe('Agent')
await insecure?.close()
})
})

View File

@ -1,4 +1,5 @@
import { EnvHttpProxyAgent } from 'undici'
import type { Dispatcher } from 'undici'
import { Agent, EnvHttpProxyAgent } from 'undici'
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy'] as const
@ -6,18 +7,30 @@ export function hasProxyEnv(): boolean {
return PROXY_ENV_KEYS.some(k => (process.env[k] ?? '') !== '')
}
let resolved = false
let agent: EnvHttpProxyAgent | undefined
export type ProxyDispatcherOptions = {
// --insecure on a self-signed https:// host: skip certificate verification
// (local-dev only, same flag that allows plain http:// hosts).
readonly insecure?: boolean
}
let resolvedKey: string | undefined
let agent: Dispatcher | undefined
// Node's global fetch ignores HTTP_PROXY / HTTPS_PROXY / NO_PROXY. When a proxy
// var is set, route requests through an EnvHttpProxyAgent (it also reads the
// lowercase variants and honours NO_PROXY); when none is set, return undefined so
// fetch keeps Node's default global dispatcher untouched. Resolved once per
// process — proxy env vars are fixed for a single CLI invocation.
export function proxyDispatcher(): EnvHttpProxyAgent | undefined {
if (!resolved) {
agent = hasProxyEnv() ? new EnvHttpProxyAgent() : undefined
resolved = true
// lowercase variants and honours NO_PROXY); when none is set and TLS verification
// isn't being skipped, return undefined so fetch keeps Node's default global
// dispatcher untouched. Resolved once per (proxy env, insecure) combination —
// both are fixed for a single CLI invocation.
export function proxyDispatcher(opts: ProxyDispatcherOptions = {}): Dispatcher | undefined {
const insecure = opts.insecure ?? false
const key = `${hasProxyEnv()}:${insecure}`
if (resolvedKey !== key) {
const tls = insecure ? { rejectUnauthorized: false } : undefined
agent = hasProxyEnv()
? new EnvHttpProxyAgent(tls !== undefined ? { connect: tls, requestTls: tls, proxyTls: tls } : undefined)
: (tls !== undefined ? new Agent({ connect: tls }) : undefined)
resolvedKey = key
}
return agent
}

View File

@ -75,6 +75,8 @@ export type ClientOptions = {
readonly retryAttempts?: number
readonly logger?: HttpLogger
readonly hooks?: Hooks
// Skip TLS certificate verification (local-dev only, self-signed hosts).
readonly insecure?: boolean
}
export type HttpClient = {

View File

@ -1,3 +1,4 @@
import type { ActiveContext } from '@/auth/hosts'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
@ -44,6 +45,16 @@ export function hostWithScheme(host: string, scheme: string | undefined): string
return `${proto}://${host}`
}
// Every call site that builds an HTTP client for the active host needs both its
// scheme-qualified host and whether TLS verification is disabled for it — derive
// them together so neither is forgotten independently.
export function activeHostInfo(active: Pick<ActiveContext, 'host' | 'scheme' | 'insecureTls'>): { host: string, insecure: boolean } {
return {
host: hostWithScheme(active.host, active.scheme),
insecure: active.insecureTls === true,
}
}
export function bareHost(raw: string): string {
try {
const u = new URL(raw)

View File

@ -16,15 +16,18 @@ const UPGRADE_HINT
+ '(https://docs.dify.ai/en/getting-started/install-self-hosted)'
// /_version is unauthenticated; same timeout/no-retry budget as the auto-nudge probe.
const defaultProbe: ServerVersionProbe = async (host) => {
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
return new MetaClient(http).serverVersion()
function buildDefaultProbe(insecure: boolean): ServerVersionProbe {
return async (host) => {
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure })
return new MetaClient(http).serverVersion()
}
}
export type EnforceOptions = {
readonly probe?: ServerVersionProbe
readonly store?: CompatStore
readonly forceFresh?: boolean
readonly insecure?: boolean
}
/**
@ -45,7 +48,7 @@ export async function enforceDifyVersion(
if (opts.forceFresh !== true && store.isFreshCompatible(host))
return undefined
const probe = opts.probe ?? defaultProbe
const probe = opts.probe ?? buildDefaultProbe(opts.insecure === true)
let server: ServerVersionResponse
try {
server = await probe(host)

View File

@ -6,7 +6,7 @@ import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
import { Registry } from '@/auth/hosts'
import { createHttpClient } from '@/http/client'
import { arch, platform } from '@/sys/index'
import { hostWithScheme, openAPIBase } from '@/util/host'
import { activeHostInfo, openAPIBase } from '@/util/host'
import { difyCompat, evaluateCompat } from './compat.js'
import { versionInfo } from './info.js'
@ -51,9 +51,11 @@ const defaultLoadActive = async (): Promise<ActiveContext | undefined> => {
return (await Registry.load()).resolveActive()
}
const defaultProbe: MetaProbe = async (endpoint) => {
const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
return new MetaClient(http).serverVersion()
function buildDefaultProbe(insecure: boolean): MetaProbe {
return async (endpoint) => {
const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure })
return new MetaClient(http).serverVersion()
}
}
function buildClientBlock(): ClientBlock {
@ -92,7 +94,6 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
}
const loadActive = opts.loadActive ?? defaultLoadActive
const probe = opts.probe ?? defaultProbe
let active: ActiveContext | undefined
let loadFailed = false
@ -112,7 +113,8 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
}
}
const endpoint = hostWithScheme(active.host, active.scheme)
const { host: endpoint, insecure } = activeHostInfo(active)
const probe = opts.probe ?? buildDefaultProbe(insecure)
let serverInfo: ServerVersionResponse | undefined
try {