fix(security): enforce ownership on private draft case creation
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -5282,6 +5282,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) {
|
||||
|
||||
Reference in New Issue
Block a user