ITHC: enforce container ownership on uploads

This commit is contained in:
2026-10-27 06:05:47 +00:00
parent 5e98d6e8c2
commit 65616f6307
4 changed files with 381 additions and 12 deletions
@@ -16,6 +16,7 @@ test("upload handler returns HASH_REQUIRED for missing hash", async () => {
const mod = loadModule("pages/api/file/upload.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getSession: async () => ({ user: { id: "c1" } }),
hashAPIPath: () => "?hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
@@ -35,6 +36,7 @@ test("upload handler returns INVALID_HASH for wrong hash", async () => {
const mod = loadModule("pages/api/file/upload.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getSession: async () => ({ user: { id: "c1" } }),
hashAPIPath: () => "?hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
@@ -57,6 +59,7 @@ test("upload handler accepts alternate canonical hash candidate", async () => {
const mod = loadModule("pages/api/file/upload.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getSession: async () => ({ user: { id: "c1" } }),
hashAPIPath: (queryPath) =>
queryPath === "/api/file/upload?"
? "?hash=expected"
@@ -85,6 +88,130 @@ test("upload handler accepts alternate canonical hash candidate", async () => {
});
});
const loadUploadAuthorization = (overrides = {}) => {
const calls = { createBlob: [], createRepBlob: [], hash: [] };
const mod = loadModule("pages/api/file/upload.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getSession: async () => ({ user: { id: "user-container" } }),
hashAPIPath: (queryPath) => {
calls.hash.push(queryPath);
return "?hash=expected";
},
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
createBlob: async (...args) => {
calls.createBlob.push(args);
return { id: "appeal-blob" };
},
createRepBlob: async (...args) => {
calls.createRepBlob.push(args);
return { id: "representation-blob" };
},
...overrides
});
return { mod, calls };
};
const uploadAuthorizationRequest = (overrides = {}) => ({
query: { hash: "expected" },
body: {
appealData: [JSON.stringify({ key: "value" })],
containerID: ["user-container"],
casefolderID: ["CASE-1"],
repOrAppeal: ["false"]
},
files: {},
...overrides
});
const assertNoUploadWrites = (calls) => {
assert.strictEqual(calls.createBlob.length, 0);
assert.strictEqual(calls.createRepBlob.length, 0);
};
test("upload rejects a missing NextAuth session before HMAC or Azure write", async () => {
const loaded = loadUploadAuthorization({ getSession: async () => null });
const res = createRes();
await loaded.mod.default.handler(uploadAuthorizationRequest(), res);
assert.strictEqual(res.state.statusCode, 401);
assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
assert.strictEqual(loaded.calls.hash.length, 0);
assertNoUploadWrites(loaded.calls);
});
test("upload rejects an unusable NextAuth session before HMAC or Azure write", async () => {
const loaded = loadUploadAuthorization({
getSession: async () => ({ user: {} })
});
const res = createRes();
await loaded.mod.default.handler(uploadAuthorizationRequest(), res);
assert.strictEqual(res.state.statusCode, 401);
assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
assert.strictEqual(loaded.calls.hash.length, 0);
assertNoUploadWrites(loaded.calls);
});
test("upload forbids another user's container before HMAC or Azure write", async () => {
const loaded = loadUploadAuthorization();
const req = uploadAuthorizationRequest();
req.body.containerID = ["other-container"];
const res = createRes();
await loaded.mod.default.handler(req, res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assert.strictEqual(loaded.calls.hash.length, 0);
assertNoUploadWrites(loaded.calls);
});
test("upload rejects invalid HMAC for the owning user without Azure write", async () => {
const loaded = loadUploadAuthorization({
hashAPIPath: () => "?hash=different"
});
const res = createRes();
await loaded.mod.default.handler(uploadAuthorizationRequest(), res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
assertNoUploadWrites(loaded.calls);
});
test("upload requires HMAC for the authenticated owning user", async () => {
const loaded = loadUploadAuthorization();
const req = uploadAuthorizationRequest({ query: {} });
const res = createRes();
await loaded.mod.default.handler(req, res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED");
assertNoUploadWrites(loaded.calls);
});
test("upload preserves the authorised owner success contract and helper arguments", async () => {
const loaded = loadUploadAuthorization();
const res = createRes();
await loaded.mod.default.handler(uploadAuthorizationRequest(), res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
data: { id: "appeal-blob" }
});
assert.deepStrictEqual(loaded.calls.createBlob, [
[JSON.stringify({ key: "value" }), "user-container", "CASE-1"]
]);
assert.strictEqual(loaded.calls.createRepBlob.length, 0);
});
test("deleteblob handler returns MISSING_REQUIRED_QUERY when blobname missing", async () => {
const mod = loadModule("pages/api/file/deleteblob.js", {
nextConnect: createNextConnectMock(),
@@ -1249,6 +1376,7 @@ test("uploadsinglefile handler returns HASH_REQUIRED when hash missing", async (
const mod = loadModule("pages/api/file/uploadsinglefile.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getSession: async () => ({ user: { id: "c1" } }),
hashAPIPath: () => "?hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
@@ -1270,6 +1398,7 @@ test("uploadsinglefile handler dependency failure returns UPLOAD_SINGLE_FILE_FAI
const mod = loadModule("pages/api/file/uploadsinglefile.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getSession: async () => ({ user: { id: "c1" } }),
hashAPIPath: () => "?hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
@@ -1307,6 +1436,176 @@ test("uploadsinglefile handler dependency failure returns UPLOAD_SINGLE_FILE_FAI
);
});
const loadSingleFileUploadAuthorization = (overrides = {}) => {
const calls = { upload: [], hash: [], fileReads: 0, typeChecks: 0 };
const mod = loadModule("pages/api/file/uploadsinglefile.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getSession: async () => ({ user: { id: "user-container" } }),
hashAPIPath: (queryPath) => {
calls.hash.push(queryPath);
return "?hash=expected";
},
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
uploadSingleFile: async (...args) => {
calls.upload.push(args);
return [{ file: "document.pdf" }];
},
fileTypeFromBuffer: async () => {
calls.typeChecks += 1;
return { mime: "application/pdf" };
},
fs: {
readFileSync: () => {
calls.fileReads += 1;
return Buffer.from("synthetic-pdf");
}
},
path: { basename: (value) => value },
...overrides
});
return { mod, calls };
};
const singleFileUploadAuthorizationRequest = (overrides = {}) => ({
query: { hash: "expected" },
body: {
containerID: ["user-container"],
casefolderID: ["CASE-1"]
},
files: {
document: [
{
fieldName: "document.pdf",
originalFilename: "document.pdf",
path: "/tmp/document.pdf",
size: 1,
headers: { "content-type": "application/pdf" }
}
]
},
...overrides
});
const assertNoSingleFileWork = (calls, { noHash = false } = {}) => {
assert.strictEqual(calls.upload.length, 0);
assert.strictEqual(calls.fileReads, 0);
assert.strictEqual(calls.typeChecks, 0);
if (noHash) assert.strictEqual(calls.hash.length, 0);
};
test("uploadsinglefile rejects a missing NextAuth session before HMAC, file processing or Azure write", async () => {
const loaded = loadSingleFileUploadAuthorization({
getSession: async () => null
});
const res = createRes();
await loaded.mod.default.handler(
singleFileUploadAuthorizationRequest(),
res
);
assert.strictEqual(res.state.statusCode, 401);
assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
assertNoSingleFileWork(loaded.calls, { noHash: true });
});
test("uploadsinglefile rejects an unusable NextAuth session before HMAC, file processing or Azure write", async () => {
const loaded = loadSingleFileUploadAuthorization({
getSession: async () => ({ user: { id: "" } })
});
const res = createRes();
await loaded.mod.default.handler(
singleFileUploadAuthorizationRequest(),
res
);
assert.strictEqual(res.state.statusCode, 401);
assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
assertNoSingleFileWork(loaded.calls, { noHash: true });
});
test("uploadsinglefile forbids another user's container before HMAC, file processing or Azure write", async () => {
const loaded = loadSingleFileUploadAuthorization();
const req = singleFileUploadAuthorizationRequest();
req.body.containerID = ["other-container"];
const res = createRes();
await loaded.mod.default.handler(req, res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assertNoSingleFileWork(loaded.calls, { noHash: true });
});
test("uploadsinglefile rejects invalid HMAC for the owning user before file processing or Azure write", async () => {
const loaded = loadSingleFileUploadAuthorization({
hashAPIPath: () => "?hash=different"
});
const res = createRes();
await loaded.mod.default.handler(
singleFileUploadAuthorizationRequest(),
res
);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
assertNoSingleFileWork(loaded.calls);
});
test("uploadsinglefile requires HMAC for the authenticated owning user", async () => {
const loaded = loadSingleFileUploadAuthorization();
const req = singleFileUploadAuthorizationRequest({ query: {} });
const res = createRes();
await loaded.mod.default.handler(req, res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED");
assertNoSingleFileWork(loaded.calls);
});
test("uploadsinglefile preserves multipart owner success response and exact Azure helper arguments", async () => {
const loaded = loadSingleFileUploadAuthorization();
const res = createRes();
await loaded.mod.default.handler(
singleFileUploadAuthorizationRequest(),
res
);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
data: [{ file: "document.pdf" }],
invalidFiles: []
});
assert.strictEqual(loaded.calls.upload.length, 1);
assert.strictEqual(loaded.calls.upload[0][1], "user-container");
assert.strictEqual(loaded.calls.upload[0][2], "CASE-1");
assert.deepStrictEqual(
JSON.parse(JSON.stringify(loaded.calls.upload[0][0])),
{
document: [
{
fieldName: "document.pdf",
originalFilename: "document.pdf",
path: "/tmp/document.pdf",
size: 1,
headers: { "content-type": "application/pdf" },
contentType: "application/pdf"
}
]
}
);
assert.strictEqual(loaded.calls.fileReads, 1);
assert.strictEqual(loaded.calls.typeChecks, 1);
});
const loadOrdinaryRepCompletion = (overrides = {}) => {
const calls = { hash: [], queue: [] };
const payload = { messageId: "ordinary-message" };
@@ -2597,6 +2896,7 @@ test("upload handler success returns data payload with 200", async () => {
const mod = loadModule("pages/api/file/upload.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getSession: async () => ({ user: { id: "c1" } }),
hashAPIPath: () => "?hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
@@ -2699,6 +2999,7 @@ test("upload handler dependency failure returns UPLOAD_FAILED", async () => {
const mod = loadModule("pages/api/file/upload.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getSession: async () => ({ user: { id: "c1" } }),
hashAPIPath: () => "?hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,