fix(line): prevent unbounded provider response buffering (#119099)

* fix(line): bound provider response bodies

* fix(line): preserve provider status on body errors

* test(line): keep response bounds coverage under lint budget

* test(line): cover bounded retry conflict responses

* chore(line): shrink assertion safety baseline
This commit is contained in:
xingzhou
2026-08-25 16:13:58 +08:00
committed by GitHub
parent f08443ea39
commit 736a03f9ad
3 changed files with 192 additions and 6 deletions
+1 -1
View File
@@ -630,7 +630,7 @@ extensions/line/src/monitor-durable.ts 1
extensions/line/src/monitor.ts 1
extensions/line/src/outbound.ts 6
extensions/line/src/rich-menu.ts 1
extensions/line/src/send.ts 2
extensions/line/src/send.ts 1
extensions/line/src/setup-core.ts 1
extensions/line/src/webhook-spool.ts 4
extensions/line/src/webhook-utils.ts 1
@@ -0,0 +1,175 @@
import { HTTPFetchError } from "@line/bot-sdk";
import { isChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const {
lineFetchMock,
requireRuntimeConfigMock,
resolveLineAccountMock,
resolveLineChannelAccessTokenMock,
recordChannelActivityMock,
} = vi.hoisted(() => ({
lineFetchMock: vi.fn<typeof fetch>(),
requireRuntimeConfigMock: vi.fn((cfg: unknown) => cfg ?? {}),
resolveLineAccountMock: vi.fn(() => ({ accountId: "default" })),
resolveLineChannelAccessTokenMock: vi.fn(() => "line-token"),
recordChannelActivityMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", () => ({
requireRuntimeConfig: requireRuntimeConfigMock,
}));
vi.mock("./accounts.js", () => ({ resolveLineAccount: resolveLineAccountMock }));
vi.mock("./channel-access-token.js", () => ({
resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock,
}));
vi.mock("openclaw/plugin-sdk/channel-activity-runtime", () => ({
recordChannelActivity: recordChannelActivityMock,
}));
let sendModule: typeof import("./send.js");
const LINE_TEST_CFG = { channels: { line: { accounts: { default: {} } } } };
function createTrackedResponse(body: string, init: ResponseInit) {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(body));
},
cancel() {
canceled = true;
},
});
return { response: new Response(stream, init), wasCanceled: () => canceled };
}
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
try {
await run();
} catch (error) {
return error;
}
throw new Error("expected LINE send to fail");
}
describe("LINE bounded provider responses", () => {
beforeAll(async () => {
sendModule = await import("./send.js");
});
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime");
vi.doUnmock("./accounts.js");
vi.doUnmock("./channel-access-token.js");
vi.doUnmock("openclaw/plugin-sdk/channel-activity-runtime");
vi.resetModules();
});
beforeEach(() => {
lineFetchMock.mockReset();
requireRuntimeConfigMock.mockClear().mockImplementation((cfg: unknown) => cfg ?? LINE_TEST_CFG);
resolveLineAccountMock.mockReset().mockReturnValue({ accountId: "default" });
resolveLineChannelAccessTokenMock.mockReset().mockReturnValue("line-token");
recordChannelActivityMock.mockReset();
vi.stubGlobal("fetch", lineFetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("preserves partial delivery when an accepted LINE response is oversized", async () => {
const tracked = createTrackedResponse("x".repeat(16 * 1024 + 1), {
status: 200,
headers: { "content-type": "application/json" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
lineFetchMock.mockResolvedValueOnce(tracked.response);
const caught = await captureError(() =>
sendModule.pushMessageLine("U123", "Hello", { cfg: LINE_TEST_CFG }),
);
expect(isChannelPartialDeliveryError(caught)).toBe(true);
if (!isChannelPartialDeliveryError(caught)) {
throw new Error("expected an accepted LINE delivery without a readable receipt");
}
expect(caught.deliveryResult).toEqual({ messageIds: [], visibleReplySent: true });
expect(textSpy).not.toHaveBeenCalled();
expect(tracked.wasCanceled()).toBe(true);
});
it("bounds an accepted retry-key conflict without reclassifying it as rejected", async () => {
const tracked = createTrackedResponse("x".repeat(16 * 1024 + 1), {
status: 409,
statusText: "Conflict",
headers: { "content-type": "application/json" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
lineFetchMock.mockResolvedValueOnce(tracked.response);
const caught = await captureError(() =>
sendModule.pushMessageLine("U123", "Hello", { cfg: LINE_TEST_CFG }),
);
expect(isChannelPartialDeliveryError(caught)).toBe(true);
expect(caught).not.toBeInstanceOf(HTTPFetchError);
expect(lineFetchMock).toHaveBeenCalledOnce();
const requestInit = lineFetchMock.mock.calls[0]?.[1];
expect(new Headers(requestInit?.headers).get("X-Line-Retry-Key")).toBeTruthy();
expect(textSpy).not.toHaveBeenCalled();
expect(tracked.wasCanceled()).toBe(true);
});
it("bounds oversized rejected LINE response bodies", async () => {
const tracked = createTrackedResponse(`${"line upstream unavailable ".repeat(1024)}tail`, {
status: 400,
statusText: "Bad Request",
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
lineFetchMock.mockResolvedValueOnce(tracked.response);
const caught = await captureError(() =>
sendModule.pushMessageLine("U123", "Hello", { cfg: LINE_TEST_CFG }),
);
expect(caught).toBeInstanceOf(HTTPFetchError);
expect(isChannelPartialDeliveryError(caught)).toBe(false);
expect(caught).toMatchObject({ status: 400, statusText: "Bad Request" });
expect((caught as HTTPFetchError).body).toContain("line upstream unavailable");
expect((caught as HTTPFetchError).body).not.toContain("tail");
expect(textSpy).not.toHaveBeenCalled();
expect(tracked.wasCanceled()).toBe(true);
});
it("preserves reply rejection status when the LINE error body cannot be read", async () => {
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.error(new Error("provider response body failed"));
},
}),
{ status: 503, statusText: "Service Unavailable", headers: { "content-type": "text/plain" } },
);
const textSpy = vi.spyOn(response, "text").mockRejectedValue(new Error("unbounded"));
lineFetchMock.mockResolvedValueOnce(response);
const caught = await captureError(() =>
sendModule.sendMessageLine("U123", "Hello", {
cfg: LINE_TEST_CFG,
replyToken: "reply-token",
}),
);
expect(caught).toBeInstanceOf(HTTPFetchError);
expect(isChannelPartialDeliveryError(caught)).toBe(false);
expect(caught).toMatchObject({
status: 503,
statusText: "Service Unavailable",
body: "",
});
expect(lineFetchMock).toHaveBeenCalledOnce();
expect(textSpy).not.toHaveBeenCalled();
});
});
+16 -5
View File
@@ -7,6 +7,10 @@ import { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-i
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithRuntimeDispatcherOrMockedGlobal } from "openclaw/plugin-sdk/runtime-fetch";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
@@ -38,6 +42,9 @@ const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROFILE_CACHE_MAX_ENTRIES = 1000;
const LINE_FLEX_ALT_TEXT_LIMIT = 1500;
const LINE_LOCATION_LABEL_LIMIT = 100;
// This cap bounds receipts and diagnostics: overflow after acceptance becomes no-retry partial
// delivery, while rejected responses keep their status with prefix-only diagnostics.
const LINE_PROVIDER_RESPONSE_MAX_BYTES = 16 * 1024;
function cacheUserProfile(
userId: string,
@@ -197,19 +204,23 @@ async function sendLineProviderMessages(
const acceptedRetryConflict = retryKey !== undefined && response.status === 409;
if (!response.ok && !acceptedRetryConflict) {
const body = await readResponseTextLimited(response, LINE_PROVIDER_RESPONSE_MAX_BYTES).catch(
() => "",
);
throw new HTTPFetchError(`${response.status} - ${response.statusText}`, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
body: await response.text(),
body,
});
}
try {
const text = await response.text();
return (text ? JSON.parse(text) : null) as
| messagingApi.PushMessageResponse
| messagingApi.ReplyMessageResponse;
return await readProviderJsonResponse<
messagingApi.PushMessageResponse | messagingApi.ReplyMessageResponse
>(response, `LINE ${operation} response`, {
maxBytes: LINE_PROVIDER_RESPONSE_MAX_BYTES,
});
} catch (error) {
// LINE accepted this exact request before its receipt became unreadable; retrying duplicates it.
throw createChannelPartialDeliveryError(error, { messageIds: [], visibleReplySent: true });