Skip to content

Commit fb5a29a

Browse files
Sign required PDF checksum headers for private uploads
1 parent 6872e89 commit fb5a29a

3 files changed

Lines changed: 25 additions & 3 deletions

File tree

‎backend/src/routes/bookkeepingDocuments.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,12 @@ export async function handleBookkeepingDocumentRoutes(
196196
ServerSideEncryption: "aws:kms",
197197
SSEKMSKeyId: kmsKey,
198198
});
199-
const uploadUrl = await signUrl(s3, command, { expiresIn: uploadSeconds });
199+
const uploadUrl = await signUrl(s3, command, {
200+
expiresIn: uploadSeconds,
201+
// The upload contract requires this header. Hoisting it into the query
202+
// leaves the same header unsigned and S3 rejects the actual PUT.
203+
unhoistableHeaders: new Set(["x-amz-checksum-sha256"]),
204+
});
200205
return json(prepared.outcome === "created" ? 201 : 200, {
201206
outcome: prepared.outcome,
202207
document: publicDocument(prepared.document!),

‎backend/tests/bookkeeping-document-atomic-api.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { after, before, describe, it } from "node:test";
22
import assert from "node:assert";
33
import { createHash } from "crypto";
44
import { Readable } from "stream";
5+
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
6+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
57
import { UpdateCommand, type DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
68
import { handler } from "../src/handler";
79
import { getClient } from "../src/db/client";
@@ -20,6 +22,7 @@ describe("atomic bookkeeping document API", () => {
2022
let client: DynamoDBDocumentClient;
2123
const deletedVersions: string[] = [];
2224
const signedInputs: Record<string, unknown>[] = [];
25+
const signingOptions: Parameters<typeof getSignedUrl>[2][] = [];
2326
let verifiedDocumentId = "";
2427
let objectBytes = pdf;
2528
before(async () => {
@@ -45,8 +48,9 @@ describe("atomic bookkeeping document API", () => {
4548
return {};
4649
},
4750
} as any,
48-
async (_client, command: any) => {
51+
async (_client, command: any, options) => {
4952
signedInputs.push(command.input);
53+
signingOptions.push(options);
5054
return "https://upload.invalid/signed";
5155
},
5256
);
@@ -70,6 +74,15 @@ describe("atomic bookkeeping document API", () => {
7074
assert.equal(signedInputs.at(-1)?.IfNoneMatch, "*");
7175
const winner = prepared.find((response) => response.statusCode === 201)!;
7276
const body = JSON.parse(winner.body);
77+
const options = signingOptions.at(-1)!;
78+
assert.ok(options.unhoistableHeaders?.has("x-amz-checksum-sha256"));
79+
const signingClient = new S3Client({ region: "eu-west-1", credentials: { accessKeyId: "synthetic", secretAccessKey: "synthetic" } });
80+
try {
81+
const url = new URL(await getSignedUrl(signingClient, new PutObjectCommand(signedInputs.at(-1) as any), { ...options, expiresIn: 300 }));
82+
assert.ok(url.searchParams.get("X-Amz-SignedHeaders")?.split(";").includes("x-amz-checksum-sha256"));
83+
assert.equal(url.searchParams.has("x-amz-checksum-sha256"), false);
84+
assert.equal(body.uploadHeaders["x-amz-checksum-sha256"], Buffer.from(sha256, "hex").toString("base64"));
85+
} finally { signingClient.destroy(); }
7386
const requestId = prepared[0] === winner ? "concurrent-a" : "concurrent-b";
7487
const ownership = { idempotencyKey: `${requestId}-owner` };
7588
const completed = await Promise.all([

‎cli/src/commands/sponsors.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,11 @@ export async function attachInvoice(args, io) {
252252
});
253253
if (prepared.uploadUrl) {
254254
const upload = await fetch(prepared.uploadUrl, { method: "PUT", body: bytes, headers: prepared.uploadHeaders });
255-
if (!upload.ok) throw new Error(`Invoice upload failed (${upload.status}); retry the same file`);
255+
if (!upload.ok) {
256+
const failure = (await upload.text()).slice(0, 8192);
257+
const code = failure.match(/<Code>([A-Za-z0-9_-]+)<\/Code>/)?.[1] || "UploadRejected";
258+
throw new Error(`Invoice upload failed (${upload.status}, ${code}); retry the same file`);
259+
}
256260
prepared = await api.post(`/api/bookkeeping/documents/${enc(prepared.document.id)}/complete`, { idempotencyKey });
257261
}
258262
let cursor, candidate;

0 commit comments

Comments
 (0)