diff --git a/pages/api/file/deleteawaitingsubmissionfromblob.js b/pages/api/file/deleteawaitingsubmissionfromblob.js index 4740aa45..f4af12c6 100644 --- a/pages/api/file/deleteawaitingsubmissionfromblob.js +++ b/pages/api/file/deleteawaitingsubmissionfromblob.js @@ -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 + diff --git a/pages/api/file/deleteblob.js b/pages/api/file/deleteblob.js index 5402078b..2736f848 100644 --- a/pages/api/file/deleteblob.js +++ b/pages/api/file/deleteblob.js @@ -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 + diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index be84d5c8..c3ba9cac 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -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(),