From 93bf7e174fea59c7c6b920f65f0c42494af65e90 Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 5 Nov 2026 14:11:45 +0000 Subject: [PATCH] fix(security): enforce ownership on private draft PDF generation --- pages/api/file/generateappealpdf.js | 88 +++- pages/api/file/generatepdf.js | 95 +++- tests/phase21/file-handler-contract.test.cjs | 488 +++++++++++++++++-- 3 files changed, 627 insertions(+), 44 deletions(-) diff --git a/pages/api/file/generateappealpdf.js b/pages/api/file/generateappealpdf.js index 34fc1119..3f014bbc 100644 --- a/pages/api/file/generateappealpdf.js +++ b/pages/api/file/generateappealpdf.js @@ -85,27 +85,68 @@ import { getPickLists } from "../../../actions/services/referenceDataService"; import { planningappeals78_pdf } from "../../../components/pdftemplates/planningappeals78_pdf"; import { other_pdf } from "../../../components/pdftemplates/other_pdf"; import { respondError, respondSuccess } from "../middleware/apiResponse"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; //const ApiProxy = nextConnect(); // ApiProxy.use(middleware); // ApiProxy.post(async (req, res) => { +const isValidContainer = (value) => + typeof value === "string" && + value.length >= 3 && + value.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value); + +const isValidDraftFolder = (value) => + typeof value === "string" && + value.length <= 100 && + !value.includes("..") && + /^[A-Za-z0-9](?:[A-Za-z0-9/-]*[A-Za-z0-9])?$/.test(value); + +const forbidden = (res) => + respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + export default async function handler(req, res) { const checkHash = req.query.hash; const appealType = req.query.appealType; const isDownload = req.query?.download === "true"; - let appealBodyObj = - typeof req.body === "string" ? JSON.parse(req.body) : req.body; + let appealBodyObj; + try { + appealBodyObj = + typeof req.body === "string" ? JSON.parse(req.body) : req.body; + } catch (_error) { + return respondError(res, { + status: 400, + code: "INVALID_REQUEST", + message: "Invalid request" + }); + } + + if ( + !appealBodyObj || + typeof appealBodyObj !== "object" || + Array.isArray(appealBodyObj) + ) { + return respondError(res, { + status: 400, + code: "INVALID_REQUEST", + message: "Invalid request" + }); + } + const containerID = appealBodyObj.containerID; const casefolderID = appealBodyObj.casefolderID; const locale = appealBodyObj.locale || "en"; let checkquerypath = "/api/file/generateappealpdf"; if ( - typeof checkHash === "undefined" || - checkHash.length === 0 || typeof appealType === "undefined" || String(appealType).length === 0 || typeof containerID === "undefined" || @@ -121,6 +162,45 @@ export default async function handler(req, res) { }); } + if ( + typeof appealType !== "string" || + !isValidContainer(containerID) || + !isValidDraftFolder(casefolderID) || + (typeof req.query.download !== "undefined" && + typeof req.query.download !== "string") || + [ + "container", + "containerID", + "casefolderID", + "caseRef", + "ticketnumber" + ].some((key) => Object.prototype.hasOwnProperty.call(req.query, key)) + ) { + 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 !== containerID + ) { + return forbidden(res); + } + + if (typeof checkHash !== "string" || checkHash.length === 0) { + return respondError(res, { + status: 400, + code: "MISSING_REQUIRED_QUERY", + message: + "hash, appealType, containerID and casefolderID are required" + }); + } + checkquerypath = checkquerypath + "?appealType=" + diff --git a/pages/api/file/generatepdf.js b/pages/api/file/generatepdf.js index 9963dd9f..67d7e05d 100644 --- a/pages/api/file/generatepdf.js +++ b/pages/api/file/generatepdf.js @@ -84,16 +84,58 @@ import { writtenStatement_pdf } from "../../../components/pdftemplates/writtenSt import { other_pdf } from "../../../components/pdftemplates/other_pdf"; import { consoleLogger } from "../../../actions/core/logger"; import { respondError, respondSuccess } from "../middleware/apiResponse"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; //const ApiProxy = nextConnect(); // ApiProxy.use(middleware); // ApiProxy.post(async (req, res) => { +const isValidContainer = (value) => + typeof value === "string" && + value.length >= 3 && + value.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value); + +const isValidDraftFolder = (value) => + typeof value === "string" && + value.length <= 100 && + !value.includes("..") && + /^[A-Za-z0-9](?:[A-Za-z0-9/-]*[A-Za-z0-9])?$/.test(value); + +const forbidden = (res) => + respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + export default async function handler(req, res) { const shouldDownload = req.query.download === "true"; const checkHash = req.query.hash; - let reqBodyobj = - typeof req.body === "string" ? JSON.parse(req.body) : req.body; + let reqBodyobj; + try { + reqBodyobj = + typeof req.body === "string" ? JSON.parse(req.body) : req.body; + } catch (_error) { + return respondError(res, { + status: 400, + code: "INVALID_REQUEST", + message: "Invalid request" + }); + } + + if ( + !reqBodyobj || + typeof reqBodyobj !== "object" || + Array.isArray(reqBodyobj) + ) { + return respondError(res, { + status: 400, + code: "INVALID_REQUEST", + message: "Invalid request" + }); + } const cleanFilesList = (blobList) => { let newblobList = Array.isArray(blobList?.filesList) @@ -164,7 +206,54 @@ export default async function handler(req, res) { checkquerypath += "?download=true"; } - if (typeof checkHash === "undefined" || checkHash.length === 0) { + if ( + typeof containerID === "undefined" || + String(containerID).length === 0 || + typeof caseRef === "undefined" || + String(caseRef).length === 0 + ) { + return respondError(res, { + status: 400, + code: "MISSING_REQUIRED_QUERY", + message: "containerID and case reference are required" + }); + } + + if ( + !isValidContainer(containerID) || + !isValidDraftFolder(caseRef) || + ["ticketnumber", "caseRef"].some( + (key) => + Object.prototype.hasOwnProperty.call(reqBodyobj, key) && + !isValidDraftFolder(reqBodyobj[key]) + ) || + (typeof req.query.download !== "undefined" && + typeof req.query.download !== "string") || + [ + "container", + "containerID", + "casefolderID", + "caseRef", + "ticketnumber" + ].some((key) => Object.prototype.hasOwnProperty.call(req.query, key)) + ) { + 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 !== containerID + ) { + return forbidden(res); + } + + if (typeof checkHash !== "string" || checkHash.length === 0) { return respondError(res, { status: 400, code: "HASH_REQUIRED", diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index 83f86e28..cb195bab 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -4795,77 +4795,491 @@ test("generateappealpdfcopy catch path returns APPEAL_PDF_COPY_GENERATION_FAILED ); }); -test("generatepdf catch path returns GENERATE_PDF_FAILED", async () => { +const PDF_OWNER_CONTAINER = "owner-container"; +const PDF_CASE_FOLDER = "CASE-1"; + +const loadGeneratePdf = (overrides = {}) => { const { Readable } = require("stream"); + const calls = { + session: [], + options: [], + hash: [], + render: [], + create: [] + }; + const options = { id: "generate-pdf-options" }; const mod = loadModule("pages/api/file/generatepdf.js", { Buffer, respondError: respondErrorMock, respondSuccess: respondSuccessMock, - hashAPIPath: () => "&hash=expected", + authOptions: (req, res) => { + calls.options.push([req, res]); + return options; + }, + getServerSession: async (...args) => { + calls.session.push(args); + return { user: { id: PDF_OWNER_CONTAINER } }; + }, + hashAPIPath: (queryPath) => { + calls.hash.push(queryPath); + return queryPath.includes("?") + ? "&hash=expected" + : "?hash=expected"; + }, consoleLogger: () => {}, ReactPDF: { - renderToStream: async () => Readable.from(["pdf"]) + renderToStream: async (...args) => { + calls.render.push(args); + return Readable.from(["pdf"]); + } }, - createRepPDFBlob: async () => { - throw new Error("pdf write failed"); + createRepPDFBlob: async (...args) => { + calls.create.push(args); + return { id: "representation-pdf" }; }, - other_pdf: () => ({ type: "other" }) + other_pdf: () => ({ type: "other" }), + ...overrides }); - const req = { + return { mod, calls, options }; +}; + +const generatePdfRequest = (overrides = {}) => ({ + method: "POST", + query: { hash: "expected" }, + body: { + representationType: "Other", + repfile_name: "rep-file", + containerID: PDF_OWNER_CONTAINER, + caseRef: PDF_CASE_FOLDER + }, + ...overrides +}); + +const loadGenerateAppealPdf = (overrides = {}) => { + const { Readable } = require("stream"); + const calls = { + session: [], + options: [], + hash: [], + temp: [], + progress: [], + download: [], + pickLists: [], + render: [], + create: [] + }; + const options = { id: "generate-appeal-pdf-options" }; + const mod = loadModule("pages/api/file/generateappealpdf.js", { + Buffer, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + authOptions: (req, res) => { + calls.options.push([req, res]); + return options; + }, + getServerSession: async (...args) => { + calls.session.push(args); + return { user: { id: PDF_OWNER_CONTAINER } }; + }, + hashAPIPath: (queryPath) => { + calls.hash.push(queryPath); + return "&hash=expected"; + }, + getTempCaseBlob: async (...args) => { + calls.temp.push(args); + return { ticketnumber: PDF_CASE_FOLDER }; + }, + getProgressBlobs: async (...args) => { + calls.progress.push(args); + return { path: `${PDF_CASE_FOLDER}/progress.json` }; + }, + downloadProgressFile: async (...args) => { + calls.download.push(args); + return { + pinswg_name: PDF_CASE_FOLDER, + pinswg_appealcasetype: "846040000" + }; + }, + getPickLists: async (...args) => { + calls.pickLists.push(args); + return {}; + }, + planningappeals78_pdf: () => ({ type: "planning" }), + other_pdf: () => ({ type: "other" }), + ReactPDF: { + renderToStream: async (...args) => { + calls.render.push(args); + return Readable.from(["pdf"]); + } + }, + createAppealPDFBlob: async (...args) => { + calls.create.push(args); + return { id: "appeal-pdf" }; + }, + ...overrides + }); + + return { mod, calls, options }; +}; + +const generateAppealPdfRequest = (overrides = {}) => ({ + method: "POST", + query: { hash: "expected", appealType: "846040000" }, + body: { + containerID: PDF_OWNER_CONTAINER, + casefolderID: PDF_CASE_FOLDER, + filesList: [] + }, + ...overrides +}); + +const assertNoGeneratePdfWork = (calls) => { + assert.strictEqual(calls.render.length, 0); + assert.strictEqual(calls.create.length, 0); +}; + +const assertNoGenerateAppealPdfWork = (calls) => { + assert.strictEqual(calls.temp.length, 0); + assert.strictEqual(calls.progress.length, 0); + assert.strictEqual(calls.download.length, 0); + assert.strictEqual(calls.pickLists.length, 0); + assert.strictEqual(calls.render.length, 0); + assert.strictEqual(calls.create.length, 0); +}; + +for (const endpoint of [ + { + name: "generatepdf", + load: loadGeneratePdf, + request: generatePdfRequest, + assertNoWork: assertNoGeneratePdfWork + }, + { + name: "generateappealpdf", + load: loadGenerateAppealPdf, + request: generateAppealPdfRequest, + assertNoWork: assertNoGenerateAppealPdfWork + } +]) { + for (const sessionCase of [ + { name: "missing session", session: null }, + { name: "missing session user id", session: { user: {} } }, + { name: "empty session user id", session: { user: { id: "" } } }, + { + name: "non-string session user id", + session: { user: { id: 123 } } + } + ]) { + test(`${endpoint.name} rejects ${sessionCase.name} before privileged work`, async () => { + const loaded = endpoint.load({ + getServerSession: async () => sessionCase.session + }); + const res = createRes(); + + await loaded.mod.default(endpoint.request(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + endpoint.assertNoWork(loaded.calls); + }); + } + + test(`${endpoint.name} rejects another user's container before privileged work`, async () => { + const loaded = endpoint.load({ + getServerSession: async () => ({ user: { id: "different-container" } }) + }); + const res = createRes(); + + await loaded.mod.default(endpoint.request(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + endpoint.assertNoWork(loaded.calls); + }); + + test(`${endpoint.name} ignores a client-provided user identity`, async () => { + const loaded = endpoint.load({ + getServerSession: async () => ({ user: { id: "different-container" } }) + }); + const req = endpoint.request(); + req.body.userID = PDF_OWNER_CONTAINER; + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 403); + endpoint.assertNoWork(loaded.calls); + }); + + test(`${endpoint.name} passes req, res and request-scoped auth options to getServerSession`, async () => { + const loaded = endpoint.load(); + const req = endpoint.request(); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.deepStrictEqual(loaded.calls.options, [[req, res]]); + assert.deepStrictEqual(loaded.calls.session, [ + [req, res, loaded.options] + ]); + }); +} + +for (const containerCase of [ + { name: "missing", value: undefined }, + { name: "invalid", value: "Invalid_Container" }, + { name: "ambiguous", value: [PDF_OWNER_CONTAINER] } +]) { + test(`generatepdf rejects ${containerCase.name} container before privileged work`, async () => { + const loaded = loadGeneratePdf(); + const req = generatePdfRequest(); + if (typeof containerCase.value === "undefined") { + delete req.body.containerID; + } else { + req.body.containerID = containerCase.value; + } + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assertNoGeneratePdfWork(loaded.calls); + }); + + test(`generateappealpdf rejects ${containerCase.name} container before privileged work`, async () => { + const loaded = loadGenerateAppealPdf(); + const req = generateAppealPdfRequest(); + if (typeof containerCase.value === "undefined") { + delete req.body.containerID; + } else { + req.body.containerID = containerCase.value; + } + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assertNoGenerateAppealPdfWork(loaded.calls); + }); +} + +test("generatepdf rejects missing draft-folder identifiers", async () => { + const loaded = loadGeneratePdf(); + const res = createRes(); + + await loaded.mod.default( + generatePdfRequest({ + body: { + representationType: "Other", + repfile_name: "rep-file", + containerID: PDF_OWNER_CONTAINER + } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assertNoGeneratePdfWork(loaded.calls); +}); + +test("generateappealpdf rejects missing or malformed draft-folder identifiers", async () => { + for (const value of [undefined, "../CASE-1", [PDF_CASE_FOLDER]]) { + const loaded = loadGenerateAppealPdf(); + const req = generateAppealPdfRequest(); + if (typeof value === "undefined") { + delete req.body.casefolderID; + } else { + req.body.casefolderID = value; + } + const res = createRes(); + await loaded.mod.default(req, res); + assert.strictEqual(res.state.statusCode, 400); + assertNoGenerateAppealPdfWork(loaded.calls); + } +}); + +for (const endpoint of [ + { + name: "generatepdf", + load: loadGeneratePdf, + request: generatePdfRequest, + assertNoWork: assertNoGeneratePdfWork + }, + { + name: "generateappealpdf", + load: loadGenerateAppealPdf, + request: generateAppealPdfRequest, + assertNoWork: assertNoGenerateAppealPdfWork + } +]) { + test(`${endpoint.name} rejects a query/body container ambiguity`, async () => { + const loaded = endpoint.load(); + const req = endpoint.request(); + req.query.containerID = PDF_OWNER_CONTAINER; + const res = createRes(); + await loaded.mod.default(req, res); + assert.strictEqual(res.state.statusCode, 400); + endpoint.assertNoWork(loaded.calls); + }); + + test(`${endpoint.name} rejects missing HMAC before privileged work`, async () => { + const loaded = endpoint.load(); + const req = endpoint.request(); + delete req.query.hash; + const res = createRes(); + await loaded.mod.default(req, res); + assert.strictEqual(res.state.statusCode, 400); + endpoint.assertNoWork(loaded.calls); + }); + + test(`${endpoint.name} rejects invalid HMAC before privileged work`, async () => { + const loaded = endpoint.load(); + const req = endpoint.request(); + req.query.hash = "wrong"; + const res = createRes(); + await loaded.mod.default(req, res); + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH"); + endpoint.assertNoWork(loaded.calls); + }); + + test(`${endpoint.name} rejects a valid HMAC for an unauthorised container`, async () => { + const loaded = endpoint.load({ + getServerSession: async () => ({ user: { id: "different-container" } }) + }); + const res = createRes(); + await loaded.mod.default(endpoint.request(), res); + assert.strictEqual(res.state.statusCode, 403); + endpoint.assertNoWork(loaded.calls); + }); +} + +test("generatepdf owner success writes the PDF to the authorised container", async () => { + const loaded = loadGeneratePdf(); + const res = createRes(); + + await loaded.mod.default(generatePdfRequest(), res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.calls.render.length, 1); + assert.strictEqual(loaded.calls.create.length, 1); + assert.strictEqual(loaded.calls.create[0][1], PDF_OWNER_CONTAINER); + assert.strictEqual(loaded.calls.create[0][2], PDF_CASE_FOLDER); + assert.strictEqual(loaded.calls.create[0][3], "rep-file"); + assert.strictEqual(res.state.jsonBody.status, "success"); +}); + +test("generatepdf preserves ticketnumber precedence when references differ", async () => { + const loaded = loadGeneratePdf(); + const res = createRes(); + + await loaded.mod.default( + generatePdfRequest({ + body: { + representationType: "Other", + repfile_name: "rep-file", + containerID: PDF_OWNER_CONTAINER, + ticketnumber: PDF_CASE_FOLDER, + caseRef: "CASE-2" + } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.calls.create.length, 1); + assert.strictEqual(loaded.calls.create[0][1], PDF_OWNER_CONTAINER); + assert.strictEqual(loaded.calls.create[0][2], PDF_CASE_FOLDER); + assert.strictEqual(res.state.jsonBody.status, "success"); +}); + +test("generatepdf rejects malformed secondary case references", async () => { + const loaded = loadGeneratePdf(); + const res = createRes(); + + await loaded.mod.default( + generatePdfRequest({ + body: { + representationType: "Other", + repfile_name: "rep-file", + containerID: PDF_OWNER_CONTAINER, + ticketnumber: PDF_CASE_FOLDER, + caseRef: "../other-case" + } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assertNoGeneratePdfWork(loaded.calls); +}); + +test("generateappealpdf owner success reads and writes only the authorised container", async () => { + const loaded = loadGenerateAppealPdf(); + const res = createRes(); + + await loaded.mod.default(generateAppealPdfRequest(), res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.calls.temp[0][0], PDF_OWNER_CONTAINER); + assert.strictEqual(loaded.calls.progress[0][0], PDF_OWNER_CONTAINER); + assert.strictEqual(loaded.calls.download[0][0], PDF_OWNER_CONTAINER); + assert.strictEqual(loaded.calls.create[0][1], PDF_OWNER_CONTAINER); + assert.strictEqual(loaded.calls.create[0][2], PDF_CASE_FOLDER); + assert.strictEqual(res.state.jsonBody.status, "success"); +}); + +test("generatepdf catch path returns GENERATE_PDF_FAILED for an authorised owner", async () => { + const loaded = loadGeneratePdf({ + createRepPDFBlob: async (...args) => { + loaded.calls.create.push(args); + throw new Error("pdf write failed"); + } + }); + const req = generatePdfRequest({ query: { hash: "expected", download: "true" }, body: { representationType: "Other", repfile_name: "rep-file", - containerID: "c1", - casefolderID: "f1", - caseRef: "CAS-1" + containerID: PDF_OWNER_CONTAINER, + caseRef: PDF_CASE_FOLDER } - }; + }); const res = createRes(); - await mod.default(req, res); + await loaded.mod.default(req, res); assert.strictEqual(res.state.statusCode, 400); assert.strictEqual(res.state.jsonBody.error.code, "GENERATE_PDF_FAILED"); + assert.strictEqual(loaded.calls.render.length, 1); + assert.strictEqual(loaded.calls.create.length, 1); }); -test("generateappealpdf catch path returns GENERATE_APPEAL_PDF_FAILED", async () => { - const mod = loadModule("pages/api/file/generateappealpdf.js", { - respondError: respondErrorMock, - respondSuccess: respondSuccessMock, - hashAPIPath: () => "&hash=expected", - getTempCaseBlob: async () => ({ ticketnumber: "CAS-1" }), - getProgressBlobs: async () => ({ path: "CAS-1/progress.json" }), +test("generateappealpdf catch path returns GENERATE_APPEAL_PDF_FAILED for an authorised owner", async () => { + const loaded = loadGenerateAppealPdf({ downloadProgressFile: async () => { + loaded.calls.download.push([ + PDF_OWNER_CONTAINER, + `${PDF_CASE_FOLDER}/progress.json`, + PDF_CASE_FOLDER + ]); throw new Error("progress failed"); - }, - getPickLists: async () => ({}), - planningappeals78_pdf: () => ({ type: "planning" }), - other_pdf: () => ({ type: "other" }), - ReactPDF: { - renderToStream: async () => { - throw new Error("render failed"); - } } }); - const req = { - query: { hash: "expected", appealType: "846040000" }, - body: { - containerID: "c1", - casefolderID: "f1", - caseRef: "CAS-1", - filesList: [] - } - }; const res = createRes(); - await mod.default(req, res); + await loaded.mod.default(generateAppealPdfRequest(), res); assert.strictEqual(res.state.statusCode, 400); assert.strictEqual( res.state.jsonBody.error.code, "GENERATE_APPEAL_PDF_FAILED" ); + assert.strictEqual(loaded.calls.temp.length, 1); + assert.strictEqual(loaded.calls.progress.length, 1); + assert.strictEqual(loaded.calls.download.length, 1); + assert.strictEqual(loaded.calls.render.length, 0); + assert.strictEqual(loaded.calls.create.length, 0); }); const run = async () => {