fix(api): enforce utf8 request bodies
This commit is contained in:
1 parent
6b228864de
commit
4582bdbee5
3 files changed
+91
-17
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user