diff --git a/packages/utils/src/curl.test.ts b/packages/utils/src/curl.test.ts index 1591d937..c64c4fc8 100644 --- a/packages/utils/src/curl.test.ts +++ b/packages/utils/src/curl.test.ts @@ -30,6 +30,80 @@ describe("buildCurlCommand", () => { expect(command).toContain(`--data-raw '{"a":1}'`); }); + it("sends decoded binary POST bytes without shell or text conversion", async () => { + const server = Deno.serve( + { hostname: "127.0.0.1", port: 0 }, + async (request) => new Response(await request.arrayBuffer()), + ); + try { + for (const { body, bytes } of [ + { + body: "data:application/octet-stream;base64,AP8nJFwNCgA=", + bytes: [0, 255, 39, 36, 92, 13, 10, 0], + }, + { body: "data:application/octet-stream;base64,", bytes: [] }, + { + body: "data:application/octet-stream;base64,aGVs\r\nbG8=", + bytes: [104, 101, 108, 108, 111], + }, + ]) { + const command = buildCurlCommand({ + url: `http://127.0.0.1:${server.addr.port}/upload`, + method: "POST", + body, + headers: [{ key: "Content-Type", value: "application/octet-stream" }], + timeout: 5000, + }); + const result = await new Deno.Command("sh", { + args: ["-c", command], + env: { NO_PROXY: "*" }, + stdout: "piped", + stderr: "piped", + }).output(); + expect(result.code).toBe(0); + expect(result.stdout).toEqual(new Uint8Array(bytes)); + } + } finally { + await server.shutdown(); + } + }); + + it("rejects malformed binary bodies before sending a request", async () => { + let requests = 0; + const server = Deno.serve({ hostname: "127.0.0.1", port: 0 }, () => { + requests++; + return new Response("received"); + }); + try { + for (const body of [ + "", + "not a data URL", + "data:application/octet-stream;base64,aGVsbG8=,extra", + "data:application/octet-stream;base64,aGVsbG8=!", + "data:application/octet-stream;base64,aGVsbG8", + "data:application/octet-stream;base64,aGVsbG8=\u2028", + ]) { + const command = buildCurlCommand({ + url: `http://127.0.0.1:${server.addr.port}/upload`, + method: "POST", + body, + headers: [{ key: "Content-Type", value: "application/octet-stream" }], + timeout: 5000, + }); + const result = await new Deno.Command("sh", { + args: ["-c", command], + env: { NO_PROXY: "*" }, + stdout: "piped", + stderr: "piped", + }).output(); + expect(requests).toBe(0); + expect(result.code).not.toBe(0); + } + } finally { + await server.shutdown(); + } + }); + it("does not override a custom content type or user agent", () => { const command = buildCurlCommand({ url: "https://example.com", diff --git a/packages/utils/src/curl.ts b/packages/utils/src/curl.ts index b538477a..57aedfda 100644 --- a/packages/utils/src/curl.ts +++ b/packages/utils/src/curl.ts @@ -46,9 +46,34 @@ export function buildCurlCommand(request: CurlRequest): string { args.push(`-H ${quote("Content-Type: application/json")}`); } - if (body) args.push(`--data-raw ${quote(body)}`); + let command = "curl"; + if ( + method === "POST" && + headers.find((h) => h.key === "Content-Type")?.value === + "application/octet-stream" + ) { + const parts = body.split(","); + const encoded = parts[1]?.replace(/[\r\n]/g, ""); + if ( + parts.length !== 2 || + encoded === undefined || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?(?![\s\S])/.test( + encoded, + ) + ) { + return "printf '%s\\n' 'Invalid base64 data URL body' >&2; false"; + } + const octal = atob(encoded).replace( + /./gs, + (byte) => `\\0${byte.charCodeAt(0).toString(8).padStart(3, "0")}`, + ); + command = `printf %b ${quote(octal)} | curl`; + args.push("--data-binary @-"); + } else if (body) { + args.push(`--data-raw ${quote(body)}`); + } if (request.followRedirects) args.push("-L"); if (request.timeout) args.push(`--max-time ${seconds(request.timeout)}`); - return ["curl", ...args].join(" \\\n "); + return [command, ...args].join(" \\\n "); }