fix(api): enforce utf8 request bodies

This commit is contained in:
ZacharyZhang-NY committed 2026-04-24 03:42:00 -04:00
1 parent 6b228864de
commit 4582bdbee5
3 files changed
+91 -17

No files matched your search

+31 -17
View File
@@ -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) {
@@ -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 {
+20
View File
@@ -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();
});
});