fix(security): enforce ownership on private draft blob deletion

This commit is contained in:
2026-11-05 13:04:09 +00:00
parent 056dace563
commit fc960f907f
3 changed files with 510 additions and 23 deletions
+432 -7
View File
@@ -247,6 +247,8 @@ test("deleteblob handler returns MISSING_REQUIRED_QUERY when blobname missing",
const mod = loadModule("pages/api/file/deleteblob.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getServerSession: async () => ({ user: { id: "container-1" } }),
authOptions: () => ({}),
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
@@ -254,7 +256,11 @@ test("deleteblob handler returns MISSING_REQUIRED_QUERY when blobname missing",
});
const req = {
query: { container: "c1", casefolderID: "f1", hash: "expected" }
query: {
container: "container-1",
casefolderID: "f1",
hash: "expected"
}
};
const res = createRes();
await mod.default.handler(req, res);
@@ -268,8 +274,10 @@ test("deleteblob handler accepts encoded hash variant and normalizes prefixed pa
const mod = loadModule("pages/api/file/deleteblob.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getServerSession: async () => ({ user: { id: "container-1" } }),
authOptions: () => ({}),
hashAPIPath: (queryPath) =>
queryPath.includes("/api/file/deleteblob?container=c1")
queryPath.includes("/api/file/deleteblob?container=container-1")
? "&hash=expected"
: "&hash=other",
respondError: respondErrorMock,
@@ -282,7 +290,7 @@ test("deleteblob handler accepts encoded hash variant and normalizes prefixed pa
const req = {
query: {
container: "c1",
container: "container-1",
casefolderID: "TMP-LFE1C",
blobname:
"TMP-LFE1C/files/2026-03-18_-_Site_Location_Plan_-_test document 021.xlsx",
@@ -303,6 +311,8 @@ test("deleteblob handler dependency failure returns DELETE_BLOB_FAILED", async (
const mod = loadModule("pages/api/file/deleteblob.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getServerSession: async () => ({ user: { id: "container-1" } }),
authOptions: () => ({}),
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
@@ -313,7 +323,7 @@ test("deleteblob handler dependency failure returns DELETE_BLOB_FAILED", async (
const req = {
query: {
container: "c1",
container: "container-1",
casefolderID: "f1",
blobname: "doc.pdf",
hash: "expected"
@@ -1221,8 +1231,12 @@ test("deleteawaitingsubmissionfromblob accepts legacy deleteblob hash variant",
{
nextConnect: createNextConnectMock(),
middleware: () => {},
getServerSession: async () => ({ user: { id: "container-1" } }),
authOptions: () => ({}),
hashAPIPath: (queryPath) =>
queryPath.includes("/api/file/deleteblob?container=c1")
queryPath.includes(
"/api/file/deleteblob?container=container-1"
)
? "&hash=expected"
: "&hash=other",
respondError: respondErrorMock,
@@ -1236,7 +1250,7 @@ test("deleteawaitingsubmissionfromblob accepts legacy deleteblob hash variant",
const req = {
query: {
container: "c1",
container: "container-1",
casefolderID: "CASE-1",
blobname: "CASE-1/files/doc.pdf",
hash: "expected"
@@ -1255,6 +1269,8 @@ test("deleteawaitingsubmissionfromblob dependency failure returns DELETE_AWAITIN
{
nextConnect: createNextConnectMock(),
middleware: () => {},
getServerSession: async () => ({ user: { id: "container-1" } }),
authOptions: () => ({}),
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
@@ -1266,7 +1282,7 @@ test("deleteawaitingsubmissionfromblob dependency failure returns DELETE_AWAITIN
const req = {
query: {
container: "c1",
container: "container-1",
casefolderID: "CASE-1",
blobname: "doc.pdf",
hash: "expected"
@@ -1282,6 +1298,245 @@ test("deleteawaitingsubmissionfromblob dependency failure returns DELETE_AWAITIN
);
});
const ownerBoundBlobDeletionCases = [
{
name: "deleteblob",
route: "pages/api/file/deleteblob.js"
},
{
name: "deleteawaitingsubmissionfromblob",
route: "pages/api/file/deleteawaitingsubmissionfromblob.js"
}
];
const loadOwnerBoundBlobDeletion = (
deletionCase,
{ session, ...overrides } = {}
) => {
const calls = {
options: [],
session: [],
hash: [],
deletion: []
};
const options = { id: `${deletionCase.name}-options` };
const resolvedSession =
typeof session === "undefined"
? { user: { id: "owner-container" } }
: session;
const mod = loadModule(deletionCase.route, {
nextConnect: createNextConnectMock(),
middleware: () => {},
authOptions: (req, res) => {
calls.options.push([req, res]);
return options;
},
getServerSession: async (...args) => {
calls.session.push(args);
return resolvedSession;
},
hashAPIPath: (queryPath) => {
calls.hash.push(queryPath);
return "&hash=expected";
},
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
deleteBlob: async (...args) => {
calls.deletion.push(args);
return { deleted: true };
},
...overrides
});
return { mod, calls, options };
};
const ownerBoundBlobDeletionRequest = (overrides = {}) => ({
query: {
container: "owner-container",
casefolderID: "CASE-1",
blobname: "document.pdf",
hash: "expected"
},
...overrides
});
const assertNoOwnerBoundBlobDeletion = (calls) => {
assert.strictEqual(calls.hash.length, 0);
assert.strictEqual(calls.deletion.length, 0);
};
for (const deletionCase of ownerBoundBlobDeletionCases) {
for (const sessionCase of [
{ name: "missing session", session: null },
{ name: "session without user id", session: { user: {} } },
{ name: "session with empty user id", session: { user: { id: "" } } },
{
name: "session with non-string user id",
session: { user: { id: 123 } }
}
]) {
test(`${deletionCase.name} rejects ${sessionCase.name} before HMAC or Azure deletion`, async () => {
const loaded = loadOwnerBoundBlobDeletion(deletionCase, {
session: sessionCase.session
});
const res = createRes();
await loaded.mod.default.handler(
ownerBoundBlobDeletionRequest(),
res
);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assertNoOwnerBoundBlobDeletion(loaded.calls);
});
}
test(`${deletionCase.name} rejects cross-container deletion before HMAC or Azure deletion`, async () => {
const loaded = loadOwnerBoundBlobDeletion(deletionCase, {
session: { user: { id: "different-container" } }
});
const res = createRes();
await loaded.mod.default.handler(ownerBoundBlobDeletionRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assertNoOwnerBoundBlobDeletion(loaded.calls);
});
test(`${deletionCase.name} rejects an invalid container before authentication or deletion`, async () => {
const loaded = loadOwnerBoundBlobDeletion(deletionCase);
const res = createRes();
await loaded.mod.default.handler(
ownerBoundBlobDeletionRequest({
query: {
container: "Invalid_Container",
casefolderID: "CASE-1",
blobname: "document.pdf",
hash: "expected"
}
}),
res
);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
assert.strictEqual(loaded.calls.options.length, 0);
assert.strictEqual(loaded.calls.session.length, 0);
assertNoOwnerBoundBlobDeletion(loaded.calls);
});
test(`${deletionCase.name} rejects a missing blob name before authentication or deletion`, async () => {
const loaded = loadOwnerBoundBlobDeletion(deletionCase);
const res = createRes();
await loaded.mod.default.handler(
ownerBoundBlobDeletionRequest({
query: {
container: "owner-container",
casefolderID: "CASE-1",
hash: "expected"
}
}),
res
);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(
res.state.jsonBody.error.code,
"MISSING_REQUIRED_QUERY"
);
assert.strictEqual(loaded.calls.options.length, 0);
assert.strictEqual(loaded.calls.session.length, 0);
assertNoOwnerBoundBlobDeletion(loaded.calls);
});
test(`${deletionCase.name} rejects malformed folder and blob parameters before authentication or deletion`, async () => {
const loaded = loadOwnerBoundBlobDeletion(deletionCase);
for (const query of [
{
container: "owner-container",
casefolderID: ["CASE-1"],
blobname: "document.pdf",
hash: "expected"
},
{
container: "owner-container",
casefolderID: "CASE-1",
blobname: ["document.pdf"],
hash: "expected"
},
{
container: "owner-container",
casefolderID: " ",
blobname: "document.pdf",
hash: "expected"
},
{
container: "owner-container",
casefolderID: "CASE-1",
blobname: " ",
hash: "expected"
}
]) {
const res = createRes();
await loaded.mod.default.handler(
ownerBoundBlobDeletionRequest({ query }),
res
);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(
res.state.jsonBody.error.code,
"MISSING_REQUIRED_QUERY"
);
}
assert.strictEqual(loaded.calls.options.length, 0);
assert.strictEqual(loaded.calls.session.length, 0);
assertNoOwnerBoundBlobDeletion(loaded.calls);
});
test(`${deletionCase.name} rejects an invalid HMAC after owner authentication and before deletion`, async () => {
const loaded = loadOwnerBoundBlobDeletion(deletionCase, {
hashAPIPath: () => "&hash=different"
});
const res = createRes();
await loaded.mod.default.handler(ownerBoundBlobDeletionRequest(), res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
assert.strictEqual(loaded.calls.deletion.length, 0);
});
test(`${deletionCase.name} preserves owner deletion and request-aware session arguments`, async () => {
const loaded = loadOwnerBoundBlobDeletion(deletionCase);
const req = ownerBoundBlobDeletionRequest();
const res = createRes();
await loaded.mod.default.handler(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(loaded.calls.options, [[req, res]]);
assert.deepStrictEqual(loaded.calls.session, [
[req, res, loaded.options]
]);
assert.strictEqual(loaded.calls.hash.length, 1);
assert.deepStrictEqual(loaded.calls.deletion, [
["owner-container", "CASE-1/files/document.pdf"]
]);
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
data: { deleted: true }
});
});
}
test("getrepsblob handler success returns representations payload", async () => {
const mod = loadModule("pages/api/file/getrepsblob.js", {
nextConnect: createNextConnectMock(),
@@ -3527,6 +3782,176 @@ test("downloadblob handler success sets attachment header and returns body", asy
assert.strictEqual(res.state.sentBody.toString(), "file-content");
});
const loadDownloadBlobCapability = (overrides = {}) => {
const calls = { hash: [], download: [] };
const mod = loadModule("pages/api/file/downloadblob.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
hashAPIPath: (queryPath) => {
calls.hash.push(queryPath);
return queryPath ===
"/api/file/downloadblob?container=selected-container&casefolderID=CASE%2F1&blobname=document.pdf"
? "&hash=valid-capability"
: "&hash=other-capability";
},
respondError: respondErrorMock,
downloadFile: async (...args) => {
calls.download.push(args);
return Buffer.from("private-draft-content");
},
...overrides
});
return { mod, calls };
};
const downloadBlobCapabilityRequest = (overrides = {}) => ({
query: {
container: "selected-container",
casefolderID: "CASE/1",
blobname: "document.pdf",
hash: "valid-capability"
},
...overrides
});
test("downloadblob preserves anonymous access with a valid exact-resource capability", async () => {
const loaded = loadDownloadBlobCapability();
const res = createRes();
await loaded.mod.default.handler(downloadBlobCapabilityRequest(), res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(loaded.calls.download, [
["selected-container", "CASE/1/files/document.pdf"]
]);
assert.strictEqual(
res.state.headers["content-disposition"],
"attachment; filename=document.pdf"
);
assert.strictEqual(res.state.sentBody.toString(), "private-draft-content");
});
test("downloadblob rejects a missing capability before Azure download", async () => {
const loaded = loadDownloadBlobCapability();
const res = createRes();
await loaded.mod.default.handler(
downloadBlobCapabilityRequest({
query: {
container: "selected-container",
casefolderID: "CASE/1",
blobname: "document.pdf"
}
}),
res
);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY");
assert.strictEqual(loaded.calls.hash.length, 0);
assert.strictEqual(loaded.calls.download.length, 0);
});
test("downloadblob rejects an invalid capability before Azure download", async () => {
const loaded = loadDownloadBlobCapability();
const res = createRes();
await loaded.mod.default.handler(
downloadBlobCapabilityRequest({
query: {
container: "selected-container",
casefolderID: "CASE/1",
blobname: "document.pdf",
hash: "invalid-capability"
}
}),
res
);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
assert.strictEqual(loaded.calls.download.length, 0);
});
for (const capabilityCase of [
{
name: "different blob",
query: {
container: "selected-container",
casefolderID: "CASE/1",
blobname: "other-document.pdf",
hash: "valid-capability"
}
},
{
name: "different folder",
query: {
container: "selected-container",
casefolderID: "CASE/2",
blobname: "document.pdf",
hash: "valid-capability"
}
},
{
name: "different container",
query: {
container: "other-container",
casefolderID: "CASE/1",
blobname: "document.pdf",
hash: "valid-capability"
}
}
]) {
test(`downloadblob rejects a capability issued for a ${capabilityCase.name}`, async () => {
const loaded = loadDownloadBlobCapability();
const res = createRes();
await loaded.mod.default.handler(
downloadBlobCapabilityRequest({ query: capabilityCase.query }),
res
);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
assert.strictEqual(loaded.calls.download.length, 0);
});
}
test("downloadblob rejects a delete-operation capability before Azure download", async () => {
const loaded = loadDownloadBlobCapability({
hashAPIPath: (queryPath) => {
loaded.calls.hash.push(queryPath);
return queryPath.startsWith("/api/file/deleteblob?")
? "&hash=valid-capability"
: "&hash=other-capability";
}
});
const res = createRes();
await loaded.mod.default.handler(downloadBlobCapabilityRequest(), res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
assert.strictEqual(loaded.calls.download.length, 0);
});
test("downloadblob preserves dependency failure contract for a valid capability", async () => {
const loaded = loadDownloadBlobCapability({
downloadFile: async (...args) => {
loaded.calls.download.push(args);
throw new Error("download failed");
}
});
const res = createRes();
await loaded.mod.default.handler(downloadBlobCapabilityRequest(), res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "DOWNLOAD_BLOB_FAILED");
assert.strictEqual(loaded.calls.download.length, 1);
});
test("upload handler dependency failure returns UPLOAD_FAILED", async () => {
const mod = loadModule("pages/api/file/upload.js", {
nextConnect: createNextConnectMock(),