fix(telegram): harden mini app URL and page serving

This commit is contained in:
Ayaan Zaidi
2026-07-09 17:20:07 +05:30
parent b2ee68780c
commit 00aad83f43
9 changed files with 49 additions and 31 deletions

View File

@@ -56,7 +56,7 @@ describe("createTelegramMiniAppDashboardCommand", () => {
resolveTelegramMiniAppUrls.mockResolvedValue({
pageUrl: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/",
controlUiUrl: "https://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net",
});
const command = createTelegramMiniAppDashboardCommand(
createTestPluginApi({

View File

@@ -59,6 +59,8 @@ function currentConfig(api: OpenClawPluginApi): OpenClawConfig {
}
function isTelegramDirectCommand(ctx: PluginCommandContext): boolean {
// Parses OpenClaw's canonical telegram:<id> / telegram:group:<id> from/sessionKey encoding.
// DM-only because Telegram permits web_app inline buttons only in private chats.
const from = ctx.from?.trim() ?? "";
const sessionKey = ctx.sessionKey?.trim() ?? "";
if (from.startsWith("telegram:group:") || sessionKey.includes(":telegram:group:")) {

View File

@@ -15,6 +15,8 @@ export async function isTelegramMiniAppOwner(params: {
}
const account = mergeTelegramAccountConfig(params.cfg, params.accountId);
const allowFrom = [...(account.allowFrom ?? []), ...(params.cfg.commands?.ownerAllowFrom ?? [])];
// Dashboard access is stricter than core senderIsOwner: wildcard and username
// allowFrom entries never grant the numeric-id match that mints an operator credential.
const expanded = await expandTelegramAllowFromWithAccessGroups({
cfg: params.cfg,
accountId: params.accountId,

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { renderTelegramMiniAppPage } from "./page.js";
describe("renderTelegramMiniAppPage", () => {
it("builds the dashboard redirect from the authenticated payload", () => {
const html = renderTelegramMiniAppPage({ accountId: "ops", scriptNonce: "nonce" });
expect(html).toContain('const accountId = "ops";');
expect(html).toContain("new URL(payload.controlUiUrl)");
expect(html).not.toContain("const controlUiUrl =");
});
});

View File

@@ -4,11 +4,9 @@ export const TELEGRAM_MINIAPP_EXPIRED_MESSAGE =
export function renderTelegramMiniAppPage(params: {
accountId: string;
controlUiUrl: string;
scriptNonce: string;
}): string {
const accountId = JSON.stringify(params.accountId);
const controlUiUrl = JSON.stringify(params.controlUiUrl);
const nonce = escapeHtml(params.scriptNonce);
return `<!doctype html>
<html lang="en">
@@ -33,7 +31,6 @@ export function renderTelegramMiniAppPage(params: {
</main>
<script nonce="${nonce}">
const accountId = ${accountId};
const controlUiUrl = ${controlUiUrl};
const status = document.getElementById("status");
const showExpired = () => {
status.textContent = ${JSON.stringify(TELEGRAM_MINIAPP_EXPIRED_MESSAGE)};
@@ -55,7 +52,7 @@ export function renderTelegramMiniAppPage(params: {
}
return await response.json();
}).then((payload) => {
const next = new URL(controlUiUrl);
const next = new URL(payload.controlUiUrl);
next.hash = "gatewayUrl=" + encodeURIComponent(payload.gatewayUrl) +
"&bootstrapToken=" + encodeURIComponent(payload.bootstrapToken);
location.replace(next.toString());

View File

@@ -2,7 +2,7 @@ import crypto from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../../../src/plugin-sdk/plugin-test-api.js";
import type { OpenClawPluginHttpRouteParams } from "../../../../src/plugins/types.js";
@@ -13,7 +13,7 @@ const resolveTelegramMiniAppUrls = vi.hoisted(() =>
vi.fn(async () => ({
pageUrl: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/",
controlUiUrl: "https://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net",
})),
);
@@ -108,6 +108,25 @@ function signedInitData(userId: string, nonce: string): string {
}
describe("registerTelegramMiniAppRoutes", () => {
beforeEach(() => {
issueDeviceBootstrapToken.mockClear();
resolveTelegramMiniAppUrls.mockClear();
});
it("serves the page without resolving published URLs", async () => {
const route = createRoute({});
const res = await callRoute({
route,
method: "GET",
url: "/__openclaw_tg_miniapp/?accountId=ops",
});
expect(res.statusCode).toBe(200);
expect(res.body).toContain('const accountId = "ops";');
expect(res.body).toContain("new URL(payload.controlUiUrl)");
expect(resolveTelegramMiniAppUrls).not.toHaveBeenCalled();
});
it("mints a control-ui bootstrap token for a valid owner request", async () => {
const route = createRoute(config());
const res = await callRoute({
@@ -124,7 +143,8 @@ describe("registerTelegramMiniAppRoutes", () => {
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({
bootstrapToken: "issued",
gatewayUrl: "wss://host.tailnet.ts.net/openclaw",
controlUiUrl: "https://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net",
});
expect(issueDeviceBootstrapToken).toHaveBeenCalledWith({
profile: {
@@ -136,7 +156,6 @@ describe("registerTelegramMiniAppRoutes", () => {
});
it("rejects replayed init-data without minting again", async () => {
issueDeviceBootstrapToken.mockClear();
const route = createRoute(config());
const initData = signedInitData("123456", "replay");
await callRoute({
@@ -162,7 +181,6 @@ describe("registerTelegramMiniAppRoutes", () => {
});
it("reserves validated init-data before minting", async () => {
issueDeviceBootstrapToken.mockClear();
const route = createRoute(config());
const initData = signedInitData("123456", "concurrent");
@@ -185,7 +203,7 @@ describe("registerTelegramMiniAppRoutes", () => {
}),
]);
expect(responses.map((res) => res.statusCode).toSorted()).toEqual([200, 401]);
expect(responses.map((res) => res.statusCode).toSorted((a, b) => a - b)).toEqual([200, 401]);
expect(issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
});

View File

@@ -34,7 +34,7 @@ export function registerTelegramMiniAppRoutes(api: OpenClawPluginApi): void {
handler: async (req, res) => {
const url = new URL(req.url ?? "", "http://openclaw.local");
if (url.pathname === TELEGRAM_MINIAPP_PATH_PREFIX) {
await handlePage(api, req, res, url);
await handlePage(req, res, url);
return true;
}
if (url.pathname === AUTH_PATH) {
@@ -47,24 +47,11 @@ export function registerTelegramMiniAppRoutes(api: OpenClawPluginApi): void {
});
}
async function handlePage(
api: OpenClawPluginApi,
req: IncomingMessage,
res: ServerResponse,
url: URL,
): Promise<void> {
async function handlePage(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {
if (req.method !== "GET") {
sendText(res, 405, "Method not allowed");
return;
}
const cfg = currentConfig(api);
let urls;
try {
urls = await resolveTelegramMiniAppUrls({ cfg });
} catch {
sendText(res, 503, TELEGRAM_MINIAPP_URL_ERROR);
return;
}
const accountId = normalizeAccountId(url.searchParams.get("accountId") ?? DEFAULT_ACCOUNT_ID);
const nonce = crypto.randomBytes(16).toString("base64url");
sendHtml(
@@ -72,7 +59,6 @@ async function handlePage(
200,
renderTelegramMiniAppPage({
accountId,
controlUiUrl: urls.controlUiUrl,
scriptNonce: nonce,
}),
nonce,
@@ -88,7 +74,7 @@ async function handleAuth(
sendText(res, 405, "Method not allowed");
return;
}
const contentType = String(req.headers["content-type"] ?? "").toLowerCase();
const contentType = (req.headers["content-type"] ?? "").toLowerCase();
if (contentType.split(";")[0]?.trim() !== "application/json") {
sendText(res, 415, "Unsupported media type");
return;
@@ -144,6 +130,7 @@ async function handleAuth(
});
sendJson(res, 200, {
bootstrapToken: issued.token,
controlUiUrl: urls.controlUiUrl,
gatewayUrl: urls.gatewayUrl,
});
}

View File

@@ -18,7 +18,7 @@ describe("resolveTelegramMiniAppUrls", () => {
await expect(resolveTelegramMiniAppUrls({ cfg, runCommand })).resolves.toEqual({
pageUrl: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/",
controlUiUrl: "https://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net",
});
expect(runCommand).toHaveBeenCalledWith(["tailscale", "status", "--json"], {
timeoutMs: 5000,

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Telegram Mini App published URL resolution.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
resolveTailnetHostWithRunner,
resolveTailscalePublishedHost,
@@ -43,7 +43,7 @@ export async function resolveTelegramMiniAppUrls(params: {
return {
pageUrl: `https://${publishedHost}${TELEGRAM_MINIAPP_PATH_PREFIX}`,
controlUiUrl,
gatewayUrl: `wss://${publishedHost}${controlUiPath}`,
gatewayUrl: `wss://${publishedHost}`,
};
}