mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user