mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(nextcloud-talk): redact reflected credentials in error diagnostics (#119976)
Share bounded, complete error-body redaction across message sends, reactions, and bot preflight. Preserve status fallbacks, request deadlines, UTF-16 display bounds, and accepted-send receipts. Consolidate duplicate receipt and feature-mask fixtures while adding real HTTP reflection, truncation, and idle-deadline regressions. Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- **Android settings:** keep form fields and actions reachable above the keyboard, and respect bottom system insets without duplicating navigation padding.
|
||||
- **Nextcloud Talk diagnostics:** redact reflected credentials before displaying send, reaction, and bot-preflight errors, and suppress incomplete error bodies. (#119976) Thanks @xialonglee.
|
||||
- Codex image attachments: decode mixed-case `file://` URLs as local image paths while preserving existing file URL validation and platform behavior. (#121611) Thanks @sunlit-deng.
|
||||
- **Android gateway discovery:** resolve nearby gateways one at a time on Android 12 and 13 so simultaneously advertised gateways are not silently omitted.
|
||||
|
||||
|
||||
@@ -90,31 +90,21 @@ describe("probeNextcloudTalkBotResponseFeature", () => {
|
||||
hoisted.fetchWithSsrFGuard.mockReset();
|
||||
});
|
||||
|
||||
it("passes when the matching bot has the response feature bit", async () => {
|
||||
mockBotAdmin(1 | 2 | 8);
|
||||
it.each([1 | 2 | 8, "+011"])(
|
||||
"accepts numeric or signed decimal response features: %j",
|
||||
async (features) => {
|
||||
mockBotAdmin(features);
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: true,
|
||||
code: "ok",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
features: 11,
|
||||
message: 'Nextcloud Talk bot "OpenClaw" has the response feature.',
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes signed decimal bot feature strings through the shared parser", async () => {
|
||||
mockBotAdmin("+011");
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: true,
|
||||
code: "ok",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
features: 11,
|
||||
message: 'Nextcloud Talk bot "OpenClaw" has the response feature.',
|
||||
});
|
||||
});
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: true,
|
||||
code: "ok",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
features: 11,
|
||||
message: 'Nextcloud Talk bot "OpenClaw" has the response feature.',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports missing response feature for the matching webhook bot", async () => {
|
||||
mockBotAdmin(1 | 8);
|
||||
@@ -130,31 +120,21 @@ describe("probeNextcloudTalkBotResponseFeature", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not coerce partial bot feature strings", async () => {
|
||||
mockBotAdmin("2response");
|
||||
it.each(["2response", -1])(
|
||||
"rejects malformed or negative response features: %j",
|
||||
async (features) => {
|
||||
mockBotAdmin(features);
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "missing_response_feature",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
message:
|
||||
'Nextcloud Talk bot "OpenClaw" (7) is missing the response feature; outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction 7 1 or reinstall the bot with --feature response.',
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat negative feature masks as having every feature", async () => {
|
||||
mockBotAdmin(-1);
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "missing_response_feature",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
message:
|
||||
'Nextcloud Talk bot "OpenClaw" (7) is missing the response feature; outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction 7 1 or reinstall the bot with --feature response.',
|
||||
});
|
||||
});
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "missing_response_feature",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
message:
|
||||
'Nextcloud Talk bot "OpenClaw" (7) is missing the response feature; outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction 7 1 or reinstall the bot with --feature response.',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports malformed bot admin JSON with a stable channel error", async () => {
|
||||
hoisted.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
@@ -190,9 +170,7 @@ describe("probeNextcloudTalkBotResponseFeature", () => {
|
||||
ok: false,
|
||||
code: "api_error",
|
||||
status: 503,
|
||||
message: expect.stringContaining(
|
||||
"Nextcloud Talk bot response feature probe failed (503): nextcloud bot admin failure",
|
||||
),
|
||||
message: "Nextcloud Talk bot response feature probe failed (503)",
|
||||
});
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
// Nextcloud Talk plugin module implements bot preflight behavior.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
import { resolveNextcloudTalkApiCredentials } from "./api-credentials.js";
|
||||
import { releaseNextcloudTalkGuardedResponse } from "./guarded-response.js";
|
||||
import {
|
||||
readNextcloudTalkErrorBody,
|
||||
releaseNextcloudTalkGuardedResponse,
|
||||
} from "./guarded-response.js";
|
||||
import { ssrfPolicyFromPrivateNetworkOptIn } from "./send.runtime.js";
|
||||
|
||||
const BOT_FEATURE_RESPONSE = 2;
|
||||
const BOT_PREFLIGHT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
type NextcloudTalkBotAdminEntry = {
|
||||
id?: number | string;
|
||||
@@ -130,10 +129,7 @@ export async function probeNextcloudTalkBotResponseFeature(params: {
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const body = await readResponseTextLimited(
|
||||
response,
|
||||
BOT_PREFLIGHT_ERROR_BODY_LIMIT_BYTES,
|
||||
).catch(() => "");
|
||||
const body = await readNextcloudTalkErrorBody(response, auth, credentials.apiPassword);
|
||||
return {
|
||||
ok: false,
|
||||
code: "api_error",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { redactToolPayloadText } from "openclaw/plugin-sdk/logging-core";
|
||||
import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
|
||||
// Nextcloud Talk guarded fetches own their dispatcher until the response body
|
||||
// settles. Cancel unread bodies before release so streaming responses cannot
|
||||
// keep the dispatcher alive after an early return.
|
||||
@@ -10,3 +13,21 @@ export async function releaseNextcloudTalkGuardedResponse(params: {
|
||||
}
|
||||
await params.release();
|
||||
}
|
||||
export async function readNextcloudTalkErrorBody(
|
||||
response: Response,
|
||||
...credentials: string[]
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Never expose a truncated credential: redact only complete, bounded bodies.
|
||||
let body = await readProviderTextResponse(response, "Nextcloud Talk error", {
|
||||
maxBytes: 8 * 1024,
|
||||
chunkTimeoutMs: 10_000,
|
||||
});
|
||||
for (const credential of credentials) {
|
||||
body = body.replaceAll(credential, "***");
|
||||
}
|
||||
return redactToolPayloadText(body);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,52 +140,60 @@ describe("nextcloud-talk send cfg threading", () => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => {
|
||||
const cfg = { source: "provided" } as const;
|
||||
mockNextcloudMessageResponse(12345, 1_706_000_000);
|
||||
it.each([true, false])(
|
||||
"preserves cfg and receipts with runtime initialized=%s",
|
||||
async (initialized) => {
|
||||
const cfg = { source: "provided" } as const;
|
||||
if (!initialized) {
|
||||
hoisted.record.mockImplementation(() => {
|
||||
throw new Error("Nextcloud Talk runtime not initialized");
|
||||
});
|
||||
}
|
||||
mockNextcloudMessageResponse(12345, 1_706_000_000);
|
||||
|
||||
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expectProvidedMessageCfgThreading(cfg);
|
||||
expect(hoisted.record).toHaveBeenCalledWith({
|
||||
channel: "nextcloud-talk",
|
||||
accountId: "default",
|
||||
direction: "outbound",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
messageId: "12345",
|
||||
receipt: {
|
||||
platformMessageIds: ["12345"],
|
||||
primaryPlatformMessageId: "12345",
|
||||
parts: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "text",
|
||||
platformMessageId: "12345",
|
||||
raw: {
|
||||
expectProvidedMessageCfgThreading(cfg);
|
||||
expect(hoisted.record).toHaveBeenCalledWith({
|
||||
channel: "nextcloud-talk",
|
||||
accountId: "default",
|
||||
direction: "outbound",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
messageId: "12345",
|
||||
receipt: {
|
||||
platformMessageIds: ["12345"],
|
||||
primaryPlatformMessageId: "12345",
|
||||
parts: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "text",
|
||||
platformMessageId: "12345",
|
||||
raw: {
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12345",
|
||||
},
|
||||
},
|
||||
],
|
||||
raw: [
|
||||
{
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12345",
|
||||
},
|
||||
},
|
||||
],
|
||||
raw: [
|
||||
{
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12345",
|
||||
},
|
||||
],
|
||||
sentAt: fixedSentAt,
|
||||
},
|
||||
roomToken: "abc123",
|
||||
timestamp: 1_706_000_000,
|
||||
});
|
||||
});
|
||||
],
|
||||
sentAt: fixedSentAt,
|
||||
},
|
||||
roomToken: "abc123",
|
||||
timestamp: 1_706_000_000,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("strips mixed-case provider and room prefixes before sending", async () => {
|
||||
const cfg = { source: "provided" } as const;
|
||||
@@ -229,50 +237,6 @@ describe("nextcloud-talk send cfg threading", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends with provided cfg even when the runtime store is not initialized", async () => {
|
||||
const cfg = { source: "provided" } as const;
|
||||
hoisted.record.mockImplementation(() => {
|
||||
throw new Error("Nextcloud Talk runtime not initialized");
|
||||
});
|
||||
mockNextcloudMessageResponse(12346, 1_706_000_001);
|
||||
|
||||
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expectProvidedMessageCfgThreading(cfg);
|
||||
expect(result).toEqual({
|
||||
messageId: "12346",
|
||||
receipt: {
|
||||
platformMessageIds: ["12346"],
|
||||
primaryPlatformMessageId: "12346",
|
||||
parts: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "text",
|
||||
platformMessageId: "12346",
|
||||
raw: {
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12346",
|
||||
},
|
||||
},
|
||||
],
|
||||
raw: [
|
||||
{
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12346",
|
||||
},
|
||||
],
|
||||
sentAt: fixedSentAt,
|
||||
},
|
||||
roomToken: "abc123",
|
||||
timestamp: 1_706_000_001,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves reply ids in receipts", async () => {
|
||||
const cfg = { source: "provided" } as const;
|
||||
mockNextcloudMessageResponse(12347, 1_706_000_002);
|
||||
@@ -531,7 +495,7 @@ describe("nextcloud-talk send bounded response reads", () => {
|
||||
expect(result.timestamp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("bounds an oversized error body into a short send-failure snippet", async () => {
|
||||
it("omits an oversized error body from the send failure", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
streamingResponse({
|
||||
status: 400,
|
||||
@@ -543,10 +507,10 @@ describe("nextcloud-talk send bounded response reads", () => {
|
||||
|
||||
await expect(
|
||||
sendMessageNextcloudTalk("room:abc", "hello", { cfg: { source: "provided" } }),
|
||||
).rejects.toThrow(/Nextcloud Talk: bad request/);
|
||||
).rejects.toThrow(new Error("Nextcloud Talk: bad request - invalid message format"));
|
||||
});
|
||||
|
||||
it("bounds an oversized reaction error body into a short snippet", async () => {
|
||||
it("omits an oversized error body from the reaction failure", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
streamingResponse({
|
||||
status: 500,
|
||||
@@ -556,31 +520,8 @@ describe("nextcloud-talk send bounded response reads", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await sendReactionNextcloudTalk("room:abc", "m-1", "👍", { cfg: { source: "provided" } });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
// The collapsed snippet caps the message far below the streamed 17 MiB body.
|
||||
expect((caught as Error).message.length).toBeLessThan(4_000);
|
||||
});
|
||||
|
||||
it("still parses a normal small success body", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ocs: { data: { id: 99, timestamp: 1_700_000_000 } } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await sendMessageNextcloudTalk("room:abc", "hello", {
|
||||
cfg: { source: "provided" },
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("99");
|
||||
expect(result.timestamp).toBe(1_700_000_000);
|
||||
await expect(
|
||||
sendReactionNextcloudTalk("room:abc", "m-1", "👍", { cfg: { source: "provided" } }),
|
||||
).rejects.toThrow(new Error("Nextcloud Talk reaction failed: 500"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { withServer } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import { probeNextcloudTalkBotResponseFeature } from "./bot-preflight.js";
|
||||
import { sendMessageNextcloudTalk, sendReactionNextcloudTalk } from "./send.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
@@ -11,6 +13,9 @@ function createTalkConfig(baseUrl: string): CoreConfig {
|
||||
"nextcloud-talk": {
|
||||
baseUrl,
|
||||
botSecret: "test-secret",
|
||||
apiUser: "test-admin",
|
||||
apiPassword: "test-password",
|
||||
webhookPublicUrl: "https://bot.example.test/hook",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
},
|
||||
},
|
||||
@@ -47,6 +52,69 @@ async function expectHangingTalkRequestTimesOut(params: {
|
||||
}
|
||||
|
||||
describe("nextcloud-talk send error responses", () => {
|
||||
it.each(["message", "reaction", "preflight"])(
|
||||
"redacts reflected credentials and drops incomplete %s error bodies",
|
||||
async (operation) => {
|
||||
for (const mode of ["complete", "display-boundary", "oversized", "stalled"]) {
|
||||
let credential = "";
|
||||
await withServer(
|
||||
(request, response) => {
|
||||
request.resume();
|
||||
credential = String(
|
||||
request.headers["x-nextcloud-talk-bot-signature"] ??
|
||||
request.headers.authorization?.replace(/^Basic /, ""),
|
||||
);
|
||||
expect(credential).not.toBe("undefined");
|
||||
response.writeHead(500, { "content-type": "text/plain" });
|
||||
// Put a secret across the read cap: exposing a prefix defeats exact redaction.
|
||||
const body =
|
||||
mode === "oversized"
|
||||
? `${"x".repeat(8192 - 16)}${credential}`
|
||||
: `upstream rejected ${mode === "display-boundary" ? "x".repeat(160) : ""}${credential}; password=fixture-private-value${
|
||||
request.headers.authorization ? "; decoded test-password" : ""
|
||||
}`;
|
||||
if (mode === "stalled") {
|
||||
response.write(body);
|
||||
} else {
|
||||
response.end(body);
|
||||
}
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const cfg = createTalkConfig(baseUrl);
|
||||
const result =
|
||||
operation === "preflight"
|
||||
? await probeNextcloudTalkBotResponseFeature({
|
||||
account: resolveNextcloudTalkAccount({ cfg }),
|
||||
})
|
||||
: await (
|
||||
operation === "message"
|
||||
? sendMessageNextcloudTalk("room:abc123", "hello", { cfg })
|
||||
: sendReactionNextcloudTalk("room:abc123", "m-1", "ok", { cfg })
|
||||
).catch((error: unknown) => error);
|
||||
const message =
|
||||
typeof result === "object" && result !== null && "message" in result
|
||||
? result.message
|
||||
: undefined;
|
||||
expect(message).toBeTypeOf("string");
|
||||
expect(message).not.toContain(credential);
|
||||
expect(message).not.toContain(credential.slice(0, 16));
|
||||
expect(message).not.toContain("fixture-private-value");
|
||||
expect(message).not.toContain("test-password");
|
||||
if (mode === "oversized" || mode === "stalled") {
|
||||
expect(message).not.toContain("xxxxxxxx");
|
||||
expect(message).not.toContain("upstream rejected");
|
||||
expect(message).toContain("500");
|
||||
} else {
|
||||
expect(message).toContain("upstream rejected");
|
||||
expect(message).toContain("***");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
it("keeps send error body snippets UTF-16 safe", async () => {
|
||||
const prefix = "e".repeat(199);
|
||||
const errorBody = `${prefix}\u{1F600}tail`;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Nextcloud Talk plugin module implements send behavior.
|
||||
import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
FormatCapabilityProfile,
|
||||
renderMarkdownWithMarkers,
|
||||
} from "openclaw/plugin-sdk/text-chunking";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { releaseNextcloudTalkGuardedResponse } from "./guarded-response.js";
|
||||
import {
|
||||
readNextcloudTalkErrorBody,
|
||||
releaseNextcloudTalkGuardedResponse,
|
||||
} from "./guarded-response.js";
|
||||
import { stripNextcloudTalkTargetPrefix } from "./normalize.js";
|
||||
import {
|
||||
convertMarkdownTables,
|
||||
@@ -23,11 +23,6 @@ import {
|
||||
} from "./send.runtime.js";
|
||||
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
|
||||
|
||||
// Nextcloud Talk runs against self-hosted servers whose responses are not
|
||||
// trusted to be small. Cap error bodies so a hostile or misbehaving endpoint
|
||||
// cannot stream an unbounded body into memory. (Success JSON is bounded by the
|
||||
// shared readProviderJsonResponse helper.)
|
||||
const NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_BYTES = 8 * 1024;
|
||||
const NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_CHARS = 200;
|
||||
const NEXTCLOUD_TALK_SEND_TIMEOUT_MS = 30_000;
|
||||
|
||||
@@ -44,7 +39,7 @@ function renderNextcloudTalkMarkdown(markdown: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapses whitespace and caps an error-body prefix to a short, log-safe snippet. */
|
||||
/** Collapses and caps an already-redacted error body for display. */
|
||||
function collapseErrorSnippet(text: string): string {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
if (collapsed.length > NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_CHARS) {
|
||||
@@ -53,20 +48,6 @@ function collapseErrorSnippet(text: string): string {
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
/** Reads a bounded, collapsed error-body snippet without buffering hostile responses. */
|
||||
async function readNextcloudTalkErrorSnippet(response: Response): Promise<string> {
|
||||
try {
|
||||
// readResponseTextLimited caps the read at the byte budget and cancels the
|
||||
// upstream stream once full, so a hostile endpoint cannot stream an
|
||||
// unbounded body into memory. Collapse the bounded prefix locally to keep a
|
||||
// short, log-safe error snippet (no new plugin SDK surface required).
|
||||
const text = await readResponseTextLimited(response, NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_BYTES);
|
||||
return collapseErrorSnippet(text);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
type NextcloudTalkSendOpts = {
|
||||
cfg: CoreConfig;
|
||||
baseUrl?: string;
|
||||
@@ -217,7 +198,7 @@ export async function sendMessageNextcloudTalk(
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const errorBody = await readNextcloudTalkErrorSnippet(response);
|
||||
const errorBody = collapseErrorSnippet(await readNextcloudTalkErrorBody(response, signature));
|
||||
const status = response.status;
|
||||
let errorMsg = `Nextcloud Talk send failed (${status})`;
|
||||
|
||||
@@ -317,7 +298,7 @@ export async function sendReactionNextcloudTalk(
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const errorBody = await readNextcloudTalkErrorSnippet(response);
|
||||
const errorBody = collapseErrorSnippet(await readNextcloudTalkErrorBody(response, signature));
|
||||
throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user