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

This commit is contained in:
2026-11-04 14:01:50 +00:00
parent 9d8678f52a
commit f8245861b7
3 changed files with 510 additions and 23 deletions
@@ -1,6 +1,8 @@
import { hashAPIPath } from "../../../actions/core/hash";
import { deleteBlob } from "../../../actions/azurestorage";
import { respondError, respondSuccess } from "../middleware/apiResponse";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import nextConnect from "next-connect";
import middleware from "../middleware/middleware";
@@ -8,6 +10,22 @@ import middleware from "../middleware/middleware";
const ApiProxy = nextConnect();
ApiProxy.use(middleware);
const forbidden = (res) =>
respondError(res, {
status: 403,
code: "FORBIDDEN",
message: "Request not permitted"
});
const hasValue = (value) =>
typeof value === "string" && value.trim().length > 0;
const isValidContainer = (value) =>
typeof value === "string" &&
value.length >= 3 &&
value.length <= 63 &&
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
ApiProxy.get(async (req, res) => {
const containerName = req.query.container;
const casefolderID = req.query.casefolderID;
@@ -15,14 +33,10 @@ ApiProxy.get(async (req, res) => {
const checkHash = req.query.hash;
if (
typeof containerName === "undefined" ||
containerName.length === 0 ||
typeof casefolderID === "undefined" ||
casefolderID.length === 0 ||
typeof blobName === "undefined" ||
blobName.length === 0 ||
typeof checkHash === "undefined" ||
checkHash.length === 0
!hasValue(containerName) ||
!hasValue(casefolderID) ||
!hasValue(blobName) ||
!hasValue(checkHash)
) {
return respondError(res, {
status: 400,
@@ -34,6 +48,23 @@ ApiProxy.get(async (req, res) => {
const casefolderIDTrimmed = casefolderID.trim();
const blobNameTrimmed = blobName.trim();
if (!isValidContainer(containerName)) {
return respondError(res, {
status: 400,
code: "INVALID_REQUEST",
message: "Invalid request"
});
}
const session = await getServerSession(req, res, authOptions(req, res));
if (
typeof session?.user?.id !== "string" ||
session.user.id.length === 0 ||
session.user.id !== containerName
) {
return forbidden(res);
}
const hashCandidatePaths = [
"/api/file/deleteawaitingsubmissionfromblob?container=" +
containerName +
+39 -8
View File
@@ -1,6 +1,8 @@
import { hashAPIPath } from "../../../actions/core/hash";
import { deleteBlob } from "../../../actions/azurestorage";
import { respondError, respondSuccess } from "../middleware/apiResponse";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import nextConnect from "next-connect";
import middleware from "../middleware/middleware";
@@ -8,6 +10,22 @@ import middleware from "../middleware/middleware";
const ApiProxy = nextConnect();
ApiProxy.use(middleware);
const forbidden = (res) =>
respondError(res, {
status: 403,
code: "FORBIDDEN",
message: "Request not permitted"
});
const hasValue = (value) =>
typeof value === "string" && value.trim().length > 0;
const isValidContainer = (value) =>
typeof value === "string" &&
value.length >= 3 &&
value.length <= 63 &&
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
ApiProxy.get(async (req, res) => {
const containerName = req.query.container;
const casefolderID = req.query.casefolderID;
@@ -15,14 +33,10 @@ ApiProxy.get(async (req, res) => {
const checkHash = req.query.hash;
if (
typeof containerName === "undefined" ||
containerName.length === 0 ||
typeof casefolderID === "undefined" ||
casefolderID.length === 0 ||
typeof blobName === "undefined" ||
blobName.length === 0 ||
typeof checkHash === "undefined" ||
checkHash.length === 0
!hasValue(containerName) ||
!hasValue(casefolderID) ||
!hasValue(blobName) ||
!hasValue(checkHash)
) {
return respondError(res, {
status: 400,
@@ -34,6 +48,23 @@ ApiProxy.get(async (req, res) => {
const casefolderIDTrimmed = casefolderID.trim();
const blobNameTrimmed = blobName.trim();
if (!isValidContainer(containerName)) {
return respondError(res, {
status: 400,
code: "INVALID_REQUEST",
message: "Invalid request"
});
}
const session = await getServerSession(req, res, authOptions(req, res));
if (
typeof session?.user?.id !== "string" ||
session.user.id.length === 0 ||
session.user.id !== containerName
) {
return forbidden(res);
}
const hashCandidatePaths = [
"/api/file/deleteblob?container=" +
containerName +
+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(),