ci: fix additional guard failures

This commit is contained in:
Peter Steinberger
2026-04-10 19:20:45 +01:00
parent e7db987ce6
commit 925a499d84
6 changed files with 207 additions and 42 deletions

View File

@@ -23,7 +23,7 @@ describe("fetchCdpChecked", () => {
);
vi.stubGlobal("fetch", fetchSpy);
await expect(fetchCdpChecked("https://browser.example/json/version", 50)).rejects.toThrow(
await expect(fetchCdpChecked("https://example.com/json/version", 50)).rejects.toThrow(
"CDP endpoint redirects are not allowed",
);

View File

@@ -1,3 +1,4 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import WebSocket from "ws";
import { isLoopbackHost } from "../gateway/net.js";
@@ -244,14 +245,25 @@ export async function fetchCdpChecked(
): Promise<Response> {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
let release: (() => Promise<void>) | undefined;
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
// Block redirects on all CDP HTTP paths (not just probes) because a
// redirect to an internal host is an SSRF vector regardless of whether
// the call is /json/version, /json/list, /json/activate, or /json/close.
const res = await withNoProxyForCdpUrl(url, () =>
fetch(url, { ...init, headers, redirect: "manual", signal: ctrl.signal }),
);
const currentFetch = globalThis.fetch;
const guarded = await fetchWithSsrFGuard({
url,
fetchImpl: async (input, guardedInit) =>
await withNoProxyForCdpUrl(url, () => currentFetch(input, guardedInit)),
init: { ...init, headers },
maxRedirects: 0,
policy: { allowPrivateNetwork: true },
signal: ctrl.signal,
auditContext: "browser-cdp",
});
release = guarded.release;
const res = guarded.response;
if (res.status >= 300 && res.status < 400) {
throw new Error("CDP endpoint redirects are not allowed");
}
@@ -262,9 +274,20 @@ export async function fetchCdpChecked(
}
throw new Error(`HTTP ${res.status}`);
}
return res;
const body = await res.arrayBuffer();
return new Response(body, {
headers: res.headers,
status: res.status,
statusText: res.statusText,
});
} catch (error) {
if (error instanceof Error && error.message.startsWith("Too many redirects")) {
throw new Error("CDP endpoint redirects are not allowed", { cause: error });
}
throw error;
} finally {
clearTimeout(t);
await release?.();
}
}

View File

@@ -132,7 +132,7 @@ describe("createMSTeamsAdapter", () => {
await adapter.continueConversation(
creds.appId,
{
serviceUrl: "https://service.example.com/",
serviceUrl: "https://example.com/",
conversation: { id: "19:conversation@thread.tacv2" },
channelId: "msteams",
},
@@ -142,7 +142,7 @@ describe("createMSTeamsAdapter", () => {
);
expect(fetchMock).toHaveBeenCalledWith(
"https://service.example.com/v3/conversations/19%3Aconversation%40thread.tacv2/activities/activity-123",
"https://example.com/v3/conversations/19%3Aconversation%40thread.tacv2/activities/activity-123",
expect.objectContaining({
method: "DELETE",
headers: expect.objectContaining({

View File

@@ -1,6 +1,7 @@
// IHttpServerAdapter is re-exported via the public barrel (`export * from './http'`)
// but tsgo cannot resolve the chain. Use the dist subpath directly (type-only import).
import type { IHttpServerAdapter } from "@microsoft/teams.apps/dist/http/index.js";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { formatUnknownError } from "./errors.js";
import type { MSTeamsAdapter } from "./messenger.js";
import type { MSTeamsCredentials } from "./token.js";
@@ -326,24 +327,34 @@ async function updateActivityViaRest(params: {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(url, {
method: "PUT",
headers,
body: JSON.stringify({
type: "message",
...activity,
id: activityId,
}),
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
init: {
method: "PUT",
headers,
body: JSON.stringify({
type: "message",
...activity,
id: activityId,
}),
},
auditContext: "msteams-update-activity",
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw Object.assign(new Error(`updateActivity failed: HTTP ${response.status} ${body}`), {
statusCode: response.status,
});
}
try {
if (!response.ok) {
const body = await response.text().catch(() => "");
throw Object.assign(new Error(`updateActivity failed: HTTP ${response.status} ${body}`), {
statusCode: response.status,
});
}
return await response.json().catch(() => ({ id: activityId }));
return await response.json().catch(() => ({ id: activityId }));
} finally {
await release();
}
}
/**
@@ -367,16 +378,26 @@ async function deleteActivityViaRest(params: {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(url, {
method: "DELETE",
headers,
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
init: {
method: "DELETE",
headers,
},
auditContext: "msteams-delete-activity",
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw Object.assign(new Error(`deleteActivity failed: HTTP ${response.status} ${body}`), {
statusCode: response.status,
});
try {
if (!response.ok) {
const body = await response.text().catch(() => "");
throw Object.assign(new Error(`deleteActivity failed: HTTP ${response.status} ${body}`), {
statusCode: response.status,
});
}
} finally {
await release();
}
}

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { startQaGatewayChild } from "./gateway-child.js";
import {
defaultQaModelForMode,
@@ -292,21 +293,30 @@ async function callTelegramApi<T>(
body?: Record<string, unknown>,
timeoutMs = 15_000,
): Promise<T> {
const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
method: "POST",
headers: {
"content-type": "application/json",
const { response, release } = await fetchWithSsrFGuard({
url: `https://api.telegram.org/bot${token}/${method}`,
init: {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body ?? {}),
},
body: JSON.stringify(body ?? {}),
signal: AbortSignal.timeout(timeoutMs),
policy: { hostnameAllowlist: ["api.telegram.org"] },
auditContext: "qa-lab-telegram-live",
});
const payload = (await response.json()) as TelegramApiEnvelope<T>;
if (!response.ok || !payload.ok || payload.result === undefined) {
throw new Error(
payload.description?.trim() || `${method} failed with status ${response.status}`,
);
try {
const payload = (await response.json()) as TelegramApiEnvelope<T>;
if (!response.ok || !payload.ok || payload.result === undefined) {
throw new Error(
payload.description?.trim() || `${method} failed with status ${response.status}`,
);
}
return payload.result;
} finally {
await release();
}
return payload.result;
}
async function getBotIdentity(token: string) {

View File

@@ -12,6 +12,8 @@ import { resolveBuildRequirement } from "./run-node.mjs";
const DEFAULTS = {
outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"),
windowMs: 10_000,
readyTimeoutMs: 20_000,
readySettleMs: 500,
sigkillGraceMs: 10_000,
cpuWarnMs: 1_000,
cpuFailMs: 8_000,
@@ -51,6 +53,12 @@ function parseArgs(argv) {
case "--window-ms":
options.windowMs = Number(readValue());
break;
case "--ready-timeout-ms":
options.readyTimeoutMs = Number(readValue());
break;
case "--ready-settle-ms":
options.readySettleMs = Number(readValue());
break;
case "--sigkill-grace-ms":
options.sigkillGraceMs = Number(readValue());
break;
@@ -229,6 +237,90 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parsePsCpuTimeMs(timeText) {
const [maybeDays, clockText] = timeText.includes("-") ? timeText.split("-", 2) : ["0", timeText];
const days = Number(maybeDays);
const parts = clockText.split(":");
if (!Number.isFinite(days) || parts.length < 2 || parts.length > 3) {
return null;
}
const seconds = Number(parts.at(-1));
const minutes = Number(parts.at(-2));
const hours = parts.length === 3 ? Number(parts[0]) : 0;
if (![seconds, minutes, hours].every(Number.isFinite)) {
return null;
}
return Math.round(((days * 24 + hours) * 60 * 60 + minutes * 60 + seconds) * 1000);
}
function readProcessTreeCpuMs(rootPid) {
if (!Number.isInteger(rootPid) || rootPid <= 0) {
return null;
}
const result = spawnSync("ps", ["-eo", "pid=,ppid=,time="], {
cwd: process.cwd(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status !== 0) {
return null;
}
const rows = [];
for (const line of result.stdout.split("\n")) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)$/);
if (!match) {
continue;
}
const pid = Number(match[1]);
const ppid = Number(match[2]);
const cpuMs = parsePsCpuTimeMs(match[3]);
if (!Number.isInteger(pid) || !Number.isInteger(ppid) || cpuMs == null) {
continue;
}
rows.push({ pid, ppid, cpuMs });
}
const childrenByParent = new Map();
const cpuByPid = new Map();
for (const row of rows) {
cpuByPid.set(row.pid, row.cpuMs);
const children = childrenByParent.get(row.ppid) ?? [];
children.push(row.pid);
childrenByParent.set(row.ppid, children);
}
if (!cpuByPid.has(rootPid)) {
return null;
}
let totalCpuMs = 0;
const seen = new Set();
const stack = [rootPid];
while (stack.length > 0) {
const pid = stack.pop();
if (!pid || seen.has(pid)) {
continue;
}
seen.add(pid);
totalCpuMs += cpuByPid.get(pid) ?? 0;
for (const childPid of childrenByParent.get(pid) ?? []) {
stack.push(childPid);
}
}
return totalCpuMs;
}
async function waitForGatewayReady(readText, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (/\[gateway\] ready \(/.test(readText())) {
return true;
}
await sleep(100);
}
return false;
}
async function allocateLoopbackPort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
@@ -355,7 +447,16 @@ async function runTimedWatch(options, outputDir) {
await sleep(100);
}
const readyBeforeWindow = await waitForGatewayReady(
() => `${stdout}\n${stderr}`,
options.readyTimeoutMs,
);
if (readyBeforeWindow && options.readySettleMs > 0) {
await sleep(options.readySettleMs);
}
const idleCpuStartMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
await sleep(options.windowMs);
const idleCpuEndMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
if (watchPid) {
try {
@@ -390,6 +491,11 @@ async function runTimedWatch(options, outputDir) {
return {
exit,
timing,
readyBeforeWindow,
idleCpuMs:
idleCpuStartMs == null || idleCpuEndMs == null
? null
: Math.max(0, idleCpuEndMs - idleCpuStartMs),
stdoutPath,
stderrPath,
timeFilePath,
@@ -503,7 +609,10 @@ async function main() {
const distRuntimeAddedPaths = diff.added.filter((entry) =>
entry.startsWith("dist-runtime/"),
).length;
const cpuMs = Math.round((watchResult.timing.userSeconds + watchResult.timing.sysSeconds) * 1000);
const totalCpuMs = Math.round(
(watchResult.timing.userSeconds + watchResult.timing.sysSeconds) * 1000,
);
const cpuMs = watchResult.idleCpuMs ?? totalCpuMs;
const watchTriggeredBuild =
fs
.readFileSync(watchResult.stderrPath, "utf8")
@@ -519,6 +628,8 @@ async function main() {
watchTriggeredBuild,
watchBuildReason,
cpuMs,
totalCpuMs,
readyBeforeWindow: watchResult.readyBeforeWindow,
cpuWarnMs: options.cpuWarnMs,
cpuFailMs: options.cpuFailMs,
distRuntimeFileGrowth,