From 4582bdbee54a1452a5def6d86be497b3737008d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 24 Apr 2026 03:42:00 -0400 Subject: [PATCH] fix(api): enforce utf8 request bodies --- src/main/api-server/server-core.ts | 48 ++++++++++++------- .../work-product-url-policy.spec.ts | 40 ++++++++++++++++ tests/unit/api-body-encoding.test.ts | 20 ++++++++ 3 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 tests/unit/api-body-encoding.test.ts diff --git a/src/main/api-server/server-core.ts b/src/main/api-server/server-core.ts index 7bf32146..c966b568 100644 --- a/src/main/api-server/server-core.ts +++ b/src/main/api-server/server-core.ts @@ -5,27 +5,36 @@ import type { AppDatabase } from "../database"; import type { AgentMessageRecord, DesktopEvent, SectionId } from "@shared/types"; import type { DocumentLifecycleInput, DocumentLifecycleOutcome } from "../document-lifecycle"; -/** - * Decode an HTTP request body to a UTF-8 string, handling the common Windows - * encoding issue where Chinese/CJK characters arrive in GBK (CP936) instead - * of UTF-8. On Chinese Windows the system ANSI code page is GBK; tools like - * curl encode command-line arguments using that code page when stdout is piped - * (chcp 65001 does not help because there is no real console). We try strict - * UTF-8 first and, only if the bytes are invalid UTF-8, fall back to GBK. - */ export function decodeBodyToUtf8(raw: Buffer): string { if (raw.length === 0) return ""; - try { - return new TextDecoder("utf-8", { fatal: true }).decode(raw); - } catch { - // Bytes are not valid UTF-8 — most likely GBK from Chinese Windows. - try { - return new TextDecoder("gbk").decode(raw); - } catch { - // Last resort: lossy UTF-8 (replaces invalid bytes with U+FFFD). - return raw.toString("utf8"); + return new TextDecoder("utf-8", { fatal: true }).decode(raw); +} + +function parseContentType(value: string | string[] | undefined): { mediaType: string; charsets: string[] } | null { + const raw = Array.isArray(value) ? value[0] : value; + if (!raw) return null; + const [mediaTypeRaw, ...params] = raw.split(";"); + const mediaType = mediaTypeRaw?.trim().toLowerCase(); + if (!mediaType) return null; + const charsets: string[] = []; + for (const param of params) { + const [keyRaw, valueRaw] = param.split("="); + if (keyRaw?.trim().toLowerCase() === "charset") { + charsets.push(valueRaw?.trim().replace(/^"|"$/g, "").toLowerCase() ?? ""); } } + return { mediaType, charsets }; +} + +function getJsonContentTypeError(req: IncomingMessage): string | null { + const parsed = parseContentType(req.headers["content-type"]); + if (!parsed) return null; + if (parsed.mediaType !== "application/json") return "Content-Type must be application/json."; + if (parsed.charsets.length > 1) return "JSON request bodies must declare one charset."; + if (parsed.charsets.some((charset) => charset !== "utf-8" && charset !== "utf8")) { + return "JSON request bodies must declare charset=utf-8."; + } + return null; } /** @@ -301,6 +310,11 @@ export class AgentApiServer { let body: unknown = null; if (["POST", "PATCH", "PUT"].includes(method)) { + const contentTypeError = getJsonContentTypeError(req); + if (contentTypeError) { + this.json(res, 415, { error: contentTypeError }); + return; + } try { body = await this.readBody(req); } catch (bodyErr) { diff --git a/tests/integration/work-product-url-policy.spec.ts b/tests/integration/work-product-url-policy.spec.ts index e092bfdf..85ee1ee9 100644 --- a/tests/integration/work-product-url-policy.spec.ts +++ b/tests/integration/work-product-url-policy.spec.ts @@ -93,6 +93,46 @@ async function createHarness() { } describe("work product URL API policy", () => { + it("rejects JSON bodies that declare a non UTF-8 charset", async () => { + const harness = await createHarness(); + try { + const response = await fetch(`${harness.baseUrl}/api/issues/${harness.taskId}/work-products`, { + method: "POST", + headers: { + ...harness.headers, + "Content-Type": "application/json; charset=gbk", + }, + body: JSON.stringify({ type: "link", provider: "external", title: "Report" }), + }); + + expect(response.status).toBe(415); + expect(harness.db.queryAll("select id from work_products")).toHaveLength(0); + } finally { + harness.server.stop(); + harness.db.close(); + } + }); + + it("rejects JSON bodies with duplicate charset declarations", async () => { + const harness = await createHarness(); + try { + const response = await fetch(`${harness.baseUrl}/api/issues/${harness.taskId}/work-products`, { + method: "POST", + headers: { + ...harness.headers, + "Content-Type": "application/json; charset=gbk; charset=utf-8", + }, + body: JSON.stringify({ type: "link", provider: "external", title: "Report" }), + }); + + expect(response.status).toBe(415); + expect(harness.db.queryAll("select id from work_products")).toHaveLength(0); + } finally { + harness.server.stop(); + harness.db.close(); + } + }); + it("rejects unknown work product taxonomy during creation", async () => { const harness = await createHarness(); try { diff --git a/tests/unit/api-body-encoding.test.ts b/tests/unit/api-body-encoding.test.ts new file mode 100644 index 00000000..156d63f4 --- /dev/null +++ b/tests/unit/api-body-encoding.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { decodeBodyToUtf8 } from "../../src/main/api-server/server-core"; + +describe("API request body encoding", () => { + it("decodes valid UTF-8 JSON", () => { + const raw = Buffer.from(JSON.stringify({ text: "中文" }), "utf8"); + + expect(decodeBodyToUtf8(raw)).toBe("{\"text\":\"中文\"}"); + }); + + it("rejects GBK bytes instead of repairing them", () => { + const raw = Buffer.from([ + 0x7b, 0x22, 0x74, 0x65, 0x78, 0x74, 0x22, 0x3a, 0x22, + 0xd6, 0xd0, 0xce, 0xc4, + 0x22, 0x7d, + ]); + + expect(() => decodeBodyToUtf8(raw)).toThrow(); + }); +});