diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6e0413cea..0f916c447ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Ollama: normalize provider-prefixed tool-call names at the native stream boundary so Kimi/Ollama calls such as `functions.exec` dispatch as `exec` instead of missing configured tools. Fixes #74487. Thanks @afurm and @carreipeia. - Security/audit: resolve configured model aliases before model-tier and small-parameter checks, so alias-based GPT-5/Codex configs no longer report false weak-model warnings. Fixes #74455. Thanks @blaspat. - CLI/agent: isolate Gateway-timeout embedded fallback runs under explicit `gateway-fallback-*` sessions so accepted Gateway runs cannot race transcript locks or replace the routed conversation session. Fixes #62981. Thanks @HemantSudarshan. +- CLI/QR/device-pair: reject malformed public setup URLs before issuing mobile pairing bootstrap tokens, while keeping valid bare host:port setup URLs supported. Thanks @Lucenx9. - Models/UI: hide unauthenticated providers from the default Web chat, `/models`, and model setup pickers while keeping explicit full-catalog browse paths through `view: "all"`, `/models all`, and `models list --all`. Fixes #74423. Thanks @guarismo and @SymbolStar. - Slack/prompts: rely on Slack `interactiveReplies` guidance instead of generic `inlineButtons` config hints so enabled Slack button directives are not contradicted. Fixes #46647. Thanks @jeremykoerber. - Slack/reactions: treat duplicate `already_reacted` responses as idempotent success so repeated agent reaction adds no longer surface as tool failures. Fixes #69005. Thanks @shipitsteven and @martingarramon. diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index db3078185da..5be5df03669 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -609,6 +609,72 @@ describe("device-pair /pair default setup code", () => { text: "⚠️ This command requires operator.pairing for internal gateway callers.", }); }); + + it("normalizes bare publicUrl host ports before issuing setup codes", async () => { + const command = registerPairCommand({ + pluginConfig: { + publicUrl: "gateway.example.test:18789/setup", + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + const text = requireText(result); + + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); + expect(text).toContain("Gateway: ws://gateway.example.test:18789"); + }); + + it("rejects invalid bare publicUrl host ports", async () => { + const command = registerPairCommand({ + pluginConfig: { + publicUrl: "localhost:notaport", + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); + expect(result).toEqual({ text: "Error: Configured publicUrl is invalid." }); + }); + + it.each([ + "http://localhost:notaport", + "http:gateway.example.test", + "ws:gateway.example.test", + "http:/localhost:notaport", + "ftp:/gateway.example.test", + "mailto:foo@example.com", + "ws://user:pass@gateway.example.test:18789", + ])("rejects invalid publicUrl %s before issuing setup codes", async (publicUrl) => { + const command = registerPairCommand({ + pluginConfig: { + publicUrl, + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); + expect(result).toEqual({ text: "Error: Configured publicUrl is invalid." }); + }); }); describe("device-pair notify pending formatting", () => { diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 6dfeec22e70..93804e77bc0 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -129,22 +129,34 @@ const QR_CHANNEL_SENDERS: Record = { }, }; +const GATEWAY_SCHEME_WITHOUT_AUTHORITY_RE = /^(?:https?|wss?):(?!\/\/)/i; +const SCHEME_LIKE_PATH_RE = /^[A-Za-z][A-Za-z0-9+.-]*:\//; + function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const candidate = normalizeOptionalString(raw); if (!candidate) { return null; } + if (GATEWAY_SCHEME_WITHOUT_AUTHORITY_RE.test(candidate)) { + return null; + } const parsedUrl = parseNormalizedGatewayUrl(candidate); if (parsedUrl) { return parsedUrl; } + if (candidate.includes("://") || SCHEME_LIKE_PATH_RE.test(candidate)) { + return null; + } const hostPort = normalizeOptionalString(candidate.split("/", 1)[0]) ?? ""; - return hostPort ? `${schemeFallback}://${hostPort}` : null; + return hostPort ? parseNormalizedGatewayUrl(`${schemeFallback}://${hostPort}`) : null; } function parseNormalizedGatewayUrl(raw: string): string | null { try { const parsed = new URL(raw); + if (parsed.username || parsed.password) { + return null; + } const scheme = parsed.protocol.slice(0, -1); const normalizedScheme = scheme === "http" ? "ws" : scheme === "https" ? "wss" : scheme; if (!(normalizedScheme === "ws" || normalizedScheme === "wss")) { diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index c0c5b2a4183..8999a2a4e9f 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -260,6 +260,22 @@ describe("registerQrCli", () => { expectLoggedSetupCode("ws://10.0.2.2:18789"); }); + it("rejects invalid override urls before printing setup codes", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "127.0.0.1", + auth: { mode: "token", token: "tok" }, + }, + }); + + await expectQrExit(["--setup-code-only", "--url", "http://localhost:notaport"]); + + const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); + expect(output).toContain("Configured publicUrl is invalid."); + expect(runtime.log).not.toHaveBeenCalled(); + }); + it("accepts --token override when config has no auth", async () => { loadConfig.mockReturnValue({ gateway: { diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 13cbb7a86d4..98503b8da7c 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -207,6 +207,42 @@ describe("pairing setup code", () => { expect(encodePairingSetupCode(payload)).toBe(expected); }); + it("normalizes bare publicUrl host ports for setup code payloads", async () => { + await expectResolvedSetupSuccessCase({ + config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }), + options: { + forceSecure: true, + publicUrl: "gateway.example.test:18789/setup", + }, + expected: { + authLabel: "token", + url: "wss://gateway.example.test:18789", + urlSource: "plugins.entries.device-pair.config.publicUrl", + }, + }); + }); + + it.each([ + "localhost:notaport", + "http://localhost:notaport", + "http:gateway.example.test", + "ws:gateway.example.test", + "http:/localhost:notaport", + "ftp:/gateway.example.test", + "mailto:foo@example.com", + "ws://user:pass@gateway.example.test:18789", + ])("rejects invalid publicUrl %s before issuing setup code payloads", async (publicUrl) => { + await expectResolvedSetupFailureCase({ + config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }), + options: { + forceSecure: true, + publicUrl, + }, + expectedError: "Configured publicUrl is invalid.", + }); + expect(issueDeviceBootstrapTokenMock).not.toHaveBeenCalled(); + }); + async function resolveCustomGatewaySetup(params: { auth: NonNullable["auth"]; env?: ResolveSetupEnv; diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index 52c09b8278c..ebf6a4ed4f4 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -128,13 +128,34 @@ type ResolveAuthLabelResult = { error?: string; }; +const GATEWAY_SCHEME_WITHOUT_AUTHORITY_RE = /^(?:https?|wss?):(?!\/\/)/i; +const SCHEME_LIKE_PATH_RE = /^[A-Za-z][A-Za-z0-9+.-]*:\//; + function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const trimmed = raw.trim(); if (!trimmed) { return null; } + if (GATEWAY_SCHEME_WITHOUT_AUTHORITY_RE.test(trimmed)) { + return null; + } + const parsedUrl = parseNormalizedGatewayUrl(trimmed); + if (parsedUrl) { + return parsedUrl; + } + if (trimmed.includes("://") || SCHEME_LIKE_PATH_RE.test(trimmed)) { + return null; + } + const withoutPath = normalizeOptionalString(trimmed.split("/", 1)[0]) ?? ""; + return withoutPath ? parseNormalizedGatewayUrl(`${schemeFallback}://${withoutPath}`) : null; +} + +function parseNormalizedGatewayUrl(raw: string): string | null { try { - const parsed = new URL(trimmed); + const parsed = new URL(raw); + if (parsed.username || parsed.password) { + return null; + } const scheme = parsed.protocol.replace(":", ""); if (!scheme) { return null; @@ -150,14 +171,8 @@ function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null const port = parsed.port ? `:${parsed.port}` : ""; return `${resolvedScheme}://${host}${port}`; } catch { - // Fall through to host:port parsing. - } - - const withoutPath = trimmed.split("/")[0] ?? ""; - if (!withoutPath) { return null; } - return `${schemeFallback}://${withoutPath}`; } function resolveScheme(