fix(security): enforce ownership on private draft PDF generation

This commit is contained in:
2026-11-04 15:36:52 +00:00
parent 4829c49c7b
commit b59959fb83
3 changed files with 584 additions and 44 deletions
+84 -4
View File
@@ -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=" +
+90 -3
View File
@@ -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,52 @@ 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) ||
(Object.prototype.hasOwnProperty.call(reqBodyobj, "ticketnumber") &&
Object.prototype.hasOwnProperty.call(reqBodyobj, "caseRef") &&
reqBodyobj.ticketnumber !== reqBodyobj.caseRef) ||
(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",
+410 -37
View File
@@ -4795,77 +4795,450 @@ 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 and conflicting draft-folder identifiers", async () => {
for (const body of [
{
representationType: "Other",
repfile_name: "rep-file",
containerID: PDF_OWNER_CONTAINER
},
{
representationType: "Other",
repfile_name: "rep-file",
containerID: PDF_OWNER_CONTAINER,
ticketnumber: PDF_CASE_FOLDER,
caseRef: "CASE-2"
}
]) {
const loaded = loadGeneratePdf();
const res = createRes();
await loaded.mod.default(generatePdfRequest({ body }), 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("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 () => {