fix(security): enforce ownership on private draft case creation

This commit is contained in:
2026-11-04 16:07:23 +00:00
parent b59959fb83
commit 5729625229
2 changed files with 338 additions and 11 deletions
+80 -11
View File
@@ -9,30 +9,59 @@ import { getTempCaseRef } from "../../../components/utils";
import { v4 as uuidv4 } from "uuid";
import { hashAPIPath } from "../../../actions/core/hash";
import { respondError, respondSuccess } from "../middleware/apiResponse";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
const WEBAPI_URL =
process.env.RELAY_ROOT ||
"https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/";
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);
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 ApiProxy(req, res) {
var createCaseBody = req.body;
let createCaseBody;
try {
createCaseBody =
typeof req.body === "string" ? JSON.parse(req.body) : req.body;
} catch (_error) {
return respondError(res, {
status: 400,
code: "INVALID_REQUEST",
message: "Invalid request"
});
}
var contactid = req.query.contactid;
var appealTypeId = req.query.appealTypeId;
var containerName = req.query.containername;
var lpaID = req.query.lpaID;
var queryUrl = "incidents";
var token = await getToken();
var tempCaseRef = getTempCaseRef();
if (
typeof contactid === "undefined" ||
contactid.length === 0 ||
typeof appealTypeId === "undefined" ||
appealTypeId.length === 0 ||
typeof containerName === "undefined" ||
containerName.length === 0 ||
typeof lpaID === "undefined" ||
lpaID.length === 0
!hasValue(contactid) ||
!hasValue(appealTypeId) ||
!hasValue(containerName) ||
!hasValue(lpaID)
) {
return respondError(res, {
status: 400,
@@ -42,6 +71,46 @@ export default async function ApiProxy(req, res) {
});
}
if (
!createCaseBody ||
typeof createCaseBody !== "object" ||
Array.isArray(createCaseBody) ||
!isValidContainer(containerName) ||
["container", "containerID", "containername"].some(
(key) => Object.prototype.hasOwnProperty.call(createCaseBody, key)
) ||
["container", "containerID"].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 !== containerName
) {
return forbidden(res);
}
var tempCaseRef = getTempCaseRef();
if (!isValidDraftFolder(tempCaseRef)) {
return respondError(res, {
status: 400,
code: "INVALID_REQUEST",
message: "Invalid request"
});
}
var token = await getToken();
var data = {
"title": tempCaseRef,
"ticketnumber": tempCaseRef,
@@ -5241,6 +5241,264 @@ test("generateappealpdf catch path returns GENERATE_APPEAL_PDF_FAILED for an aut
assert.strictEqual(loaded.calls.create.length, 0);
});
const CASE_OWNER_CONTAINER = "case-owner-container";
const GENERATED_CASE_REFERENCE = "TMP-CASE-04E";
const loadCreateCase = (overrides = {}) => {
const calls = {
session: [],
options: [],
token: 0,
reference: 0,
caseBlob: [],
appealBlob: []
};
const options = { id: "create-case-options" };
const mod = loadModule("pages/api/file/createcase_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
authOptions: (req, res) => {
calls.options.push([req, res]);
return options;
},
getServerSession: async (...args) => {
calls.session.push(args);
return { user: { id: CASE_OWNER_CONTAINER } };
},
getToken: async () => {
calls.token += 1;
return { access_token: "synthetic-token" };
},
getTempCaseRef: () => {
calls.reference += 1;
return GENERATED_CASE_REFERENCE;
},
uuidv4: () => "00000000-0000-4000-8000-00000000004e",
getCaseBlob: async (...args) => {
calls.caseBlob.push(args);
return `${GENERATED_CASE_REFERENCE}/${GENERATED_CASE_REFERENCE}_case.json`;
},
createBlob: async (...args) => {
calls.appealBlob.push(args);
return GENERATED_CASE_REFERENCE;
},
_: { isEmpty: (value) => !value || Object.keys(value).length === 0 },
...overrides
});
return { mod, calls, options };
};
const createCaseRequest = (overrides = {}) => ({
method: "POST",
query: {
contactid: "00000000-0000-4000-8000-000000000001",
appealTypeId: "846040000",
containername: CASE_OWNER_CONTAINER,
lpaID: "00000000-0000-4000-8000-000000000002"
},
body: {
pinswg_lpaname: "Synthetic LPA",
createdon: "2026-11-04T00:00:00.000Z"
},
...overrides
});
const assertNoCreateCaseSideEffects = (calls) => {
assert.strictEqual(calls.token, 0);
assert.strictEqual(calls.reference, 0);
assert.strictEqual(calls.caseBlob.length, 0);
assert.strictEqual(calls.appealBlob.length, 0);
};
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(`createcase_api rejects ${sessionCase.name} before private draft creation`, async () => {
const loaded = loadCreateCase({
getServerSession: async () => sessionCase.session
});
const res = createRes();
await loaded.mod.default(createCaseRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assertNoCreateCaseSideEffects(loaded.calls);
});
}
test("createcase_api rejects a foreign destination container before private draft creation", async () => {
const loaded = loadCreateCase({
getServerSession: async () => ({ user: { id: "different-container" } })
});
const res = createRes();
await loaded.mod.default(createCaseRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assertNoCreateCaseSideEffects(loaded.calls);
});
test("createcase_api does not trust a caller-supplied identity", async () => {
const loaded = loadCreateCase({
getServerSession: async () => ({ user: { id: "different-container" } })
});
const req = createCaseRequest();
req.body.userID = CASE_OWNER_CONTAINER;
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 403);
assertNoCreateCaseSideEffects(loaded.calls);
});
test("createcase_api passes req, res and request-scoped options to getServerSession", async () => {
const loaded = loadCreateCase();
const req = createCaseRequest();
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: "array-valued", value: [CASE_OWNER_CONTAINER] }
]) {
test(`createcase_api rejects a ${containerCase.name} container before private draft creation`, async () => {
const loaded = loadCreateCase();
const req = createCaseRequest();
if (typeof containerCase.value === "undefined") {
delete req.query.containername;
} else {
req.query.containername = containerCase.value;
}
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 400);
assertNoCreateCaseSideEffects(loaded.calls);
});
}
test("createcase_api rejects malformed JSON before private draft creation", async () => {
const loaded = loadCreateCase();
const res = createRes();
await loaded.mod.default(createCaseRequest({ body: "{" }), res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
assertNoCreateCaseSideEffects(loaded.calls);
});
test("createcase_api rejects ambiguous destination identifiers before private draft creation", async () => {
for (const request of [
createCaseRequest({
query: {
...createCaseRequest().query,
containerID: CASE_OWNER_CONTAINER
}
}),
createCaseRequest({
body: {
...createCaseRequest().body,
containername: CASE_OWNER_CONTAINER
}
})
]) {
const loaded = loadCreateCase();
const res = createRes();
await loaded.mod.default(request, res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
assertNoCreateCaseSideEffects(loaded.calls);
}
});
test("createcase_api rejects an invalid generated folder before token acquisition and Blob writes", async () => {
const loaded = loadCreateCase({ getTempCaseRef: () => "../foreign-case" });
const res = createRes();
await loaded.mod.default(createCaseRequest(), res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
assert.strictEqual(loaded.calls.token, 0);
assert.strictEqual(loaded.calls.caseBlob.length, 0);
assert.strictEqual(loaded.calls.appealBlob.length, 0);
});
test("createcase_api creates both draft JSON blobs in the authenticated owner's container", async () => {
const loaded = loadCreateCase();
const req = createCaseRequest();
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(loaded.calls.token, 1);
assert.strictEqual(loaded.calls.reference, 1);
assert.strictEqual(loaded.calls.caseBlob.length, 1);
assert.strictEqual(loaded.calls.appealBlob.length, 1);
assert.strictEqual(loaded.calls.caseBlob[0][0], CASE_OWNER_CONTAINER);
assert.strictEqual(
loaded.calls.caseBlob[0][1],
GENERATED_CASE_REFERENCE
);
assert.strictEqual(loaded.calls.appealBlob[0][1], CASE_OWNER_CONTAINER);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(loaded.calls.caseBlob[0][2])),
JSON.parse(JSON.stringify(res.state.jsonBody))
);
assert.deepStrictEqual(JSON.parse(loaded.calls.appealBlob[0][0]), {
pinswg_name: GENERATED_CASE_REFERENCE,
pinswg_appealcasetype: "846040000",
_pinswg_associatedlpa_value:
"00000000-0000-4000-8000-000000000002"
});
assert.strictEqual(res.state.jsonBody.ticketnumber, GENERATED_CASE_REFERENCE);
assert.strictEqual(res.state.jsonBody.title, GENERATED_CASE_REFERENCE);
assert.strictEqual(
res.state.jsonBody.incidentid,
"00000000-0000-4000-8000-00000000004e"
);
});
test("createcase_api preserves the existing propagated token dependency failure for an authorised owner", async () => {
const loaded = loadCreateCase({
getToken: async () => {
loaded.calls.token += 1;
throw new Error("token failure");
}
});
const res = createRes();
await assert.rejects(
loaded.mod.default(createCaseRequest(), res),
/token failure/
);
assert.strictEqual(loaded.calls.token, 1);
assert.strictEqual(loaded.calls.caseBlob.length, 0);
assert.strictEqual(loaded.calls.appealBlob.length, 0);
});
const run = async () => {
let passed = 0;
for (const currentTest of tests) {