fix(device-pair): validate public setup urls (#74538)

* fix(device-pair): validate public setup urls

* test(cli): cover invalid qr override urls

---------

Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Simone
2026-04-29 20:47:35 +02:00
committed by GitHub
parent c728d604b2
commit dabf76b3de
6 changed files with 154 additions and 8 deletions

View File

@@ -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 <provider> 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.

View File

@@ -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", () => {

View File

@@ -129,22 +129,34 @@ const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
},
};
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")) {

View File

@@ -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: {

View File

@@ -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<ResolveSetupConfig["gateway"]>["auth"];
env?: ResolveSetupEnv;

View File

@@ -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(