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

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