mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
ci: fix additional guard failures
This commit is contained in:
@@ -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",
|
||||
);
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user