From 52202f4e1c8962c6073dadb922c2c1fc14c0dd5a Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 13 Mar 2026 11:38:14 +0000 Subject: [PATCH] TASK22017: phase 8 hardening for sensitive hash-guarded APIs --- actions/services/documentDirectService.js | 4 +- actions/services/portalDirectService.js | 9 +- pages/api/endpoint/getportallogin_api.js | 51 ++-- .../file/createappealcompletemessage_api.js | 52 ++-- pages/api/file/deleteblobcase.js | 27 +- pages/api/file/deleteblobrep.js | 31 ++- tests/phase8/service-behaviour.test.cjs | 263 ++++++++++++++++++ 7 files changed, 347 insertions(+), 90 deletions(-) create mode 100644 tests/phase8/service-behaviour.test.cjs diff --git a/actions/services/documentDirectService.js b/actions/services/documentDirectService.js index 0b185789..d44bac8c 100644 --- a/actions/services/documentDirectService.js +++ b/actions/services/documentDirectService.js @@ -74,7 +74,7 @@ export const deleteAwaitingSubmissionsFromBlob = ( var config = { method: "get", - url: queryUrl + url: queryUrl + hashAPIPath(queryUrl) }; return axios(config) .then((res) => { @@ -100,7 +100,7 @@ export const deleteMyRepresentationsFromBlob = ( var config = { method: "get", - url: queryUrl + url: queryUrl + hashAPIPath(queryUrl) }; return axios(config) .then((res) => { diff --git a/actions/services/portalDirectService.js b/actions/services/portalDirectService.js index 5645af51..39b7b937 100644 --- a/actions/services/portalDirectService.js +++ b/actions/services/portalDirectService.js @@ -222,13 +222,20 @@ export const sendCaseCompleteMessage = async ( caseReference, inv ) => { + var hashQueryPath = + "/api/file/createappealcompletemessage_api?container=" + + containerID + + "&tempcaseref=" + + caseReference; + var queryUrl = "/api/file/createappealcompletemessage_api?container=" + containerID + "&tempcaseref=" + caseReference + "&inv=" + - inv; + inv + + hashAPIPath(hashQueryPath); var config = { method: "get", diff --git a/pages/api/endpoint/getportallogin_api.js b/pages/api/endpoint/getportallogin_api.js index 02d06cb9..00c48ce2 100644 --- a/pages/api/endpoint/getportallogin_api.js +++ b/pages/api/endpoint/getportallogin_api.js @@ -18,7 +18,6 @@ import axios from "axios"; import CryptoJS from "crypto-js"; -import _ from "lodash"; import { azureHeadersPaged } from "../../../actions/core/headers"; import { consoleLogger } from "../../../actions/core/logger"; import { getToken } from "../../../actions/core/token"; @@ -40,44 +39,38 @@ const hashAPIPath = (queryPath) => { export default async function ApiProxy(req, res) { var emailAddress = req.query.emailAddress; - var token = await getToken(); var checkHash = req.query.hash; + + if (typeof emailAddress == "undefined" || emailAddress.length === 0) { + return res.status(400).json(); + } + var checkquerypath = "/api/endpoint/getportallogin_api?emailAddress=" + emailAddress; - // console.log("-----", casefolderID); - // console.log("-----", req.query); - // console.log("-----", checkquerypath); - // console.log("-----", hashAPIPath(checkquerypath)); - // console.log("-----", checkHash); - // console.log(hashAPIPath(checkquerypath) == "&hash=" + checkHash); + if (hashAPIPath(checkquerypath) != "&hash=" + checkHash) { + return res.status(400).json(); + } + + var token = await getToken(); var queryUrl = "contacts?$filter=emailaddress1 eq '" + emailAddress + "' and statuscode eq 1&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname"; - if (hashAPIPath(checkquerypath) == "&hash=" + checkHash) { - var apiResponse = _.isEmpty(req.query) - ? res.status(400).json() - : typeof emailAddress != "undefined" && emailAddress.length > 0 - ? axios - .get( - WEBAPI_URL + queryUrl + hashAPIPath(queryUrl), - azureHeadersPaged(token.access_token) - ) - .then(({ data }) => { - //console.log(data); - res.status(200).json(data); - }) - .catch((error) => { - consoleLogger(error); - res.status(400).json(error); - }) - : res.status(400).json(); - } else { - return res.status(400).json(); - } + var apiResponse = axios + .get( + WEBAPI_URL + queryUrl + hashAPIPath(queryUrl), + azureHeadersPaged(token.access_token) + ) + .then(({ data }) => { + res.status(200).json(data); + }) + .catch((error) => { + consoleLogger(error); + res.status(400).json(error); + }); return apiResponse; } diff --git a/pages/api/file/createappealcompletemessage_api.js b/pages/api/file/createappealcompletemessage_api.js index 2cbdbc7e..83fcb8a9 100644 --- a/pages/api/file/createappealcompletemessage_api.js +++ b/pages/api/file/createappealcompletemessage_api.js @@ -1,4 +1,6 @@ import { updateAccount } from "../../../actions/services/accountService"; +import { hashAPIPath } from "../../../actions/core/hash"; +import { consoleLogger } from "../../../actions/core/logger"; import { createCaseCompleteMessage, getProgressBlobs, @@ -20,16 +22,25 @@ ApiProxy.get(async (req, res) => { var typeofinvolvement = req.query.inv; var checkHash = req.query.hash; - console.log("/////Create Case Message:\n", tempCaseRef, "\n//////////////"); + if ( + typeof containerName === "undefined" || + containerName.length === 0 || + typeof tempCaseRef === "undefined" || + tempCaseRef.length === 0 + ) { + return res.status(400).json(); + } - //console.log(hashAPIPath(checkquerypath), checkHash); - //console.log(hashAPIPath(checkquerypath) == "&hash=" + checkHash); var checkquerypath = "/api/file/createappealcompletemessage_api?container=" + containerName + "&tempcaseref=" + tempCaseRef; + if (hashAPIPath(checkquerypath) != "&hash=" + checkHash) { + return res.status(400).json(); + } + const blobProgress = await getProgressBlobs( containerName, tempCaseRef @@ -49,8 +60,6 @@ ApiProxy.get(async (req, res) => { delete blobProgress["pinswg_name"]; - //http: if (hashAPIPath(checkquerypath) == "&hash=" + checkHash) { - await createBlob(JSON.stringify(blobProgress), containerName, tempCaseRef) .then(() => { //update case.json with lpa ref and description @@ -90,15 +99,8 @@ ApiProxy.get(async (req, res) => { "pinswg_typeofinvolvement": 846040001 }, true - ).then((data) => { - console.log( - "uopdated account : " + - contactId + - " \nfrom: " + - data.pinswg_typeofinvolvement + - "\nto: " + - typeofinvolvement - ); + ).catch((error) => { + consoleLogger(error); }); } @@ -109,29 +111,9 @@ ApiProxy.get(async (req, res) => { }); }) .catch((error) => { - console.log( - "///////////////////////\n createCaseCompleteMessage:", - error, - "///////////////////////\n" - ); + consoleLogger(error); return res.status(400).json(error); }); - - // await createCaseCompleteMessage(containerName, tempCaseRef) - // .then((data) => { - // console.log(data); - // createBlob( - // JSON.stringify(blobProgress), - // containerName, - // tempCaseRef - // ); - // return res.status(200).json(data); - // }) - // .catch((error) => { - // console.log(error); - - // return res.status(400).json(error); - // }); }); export const config = { diff --git a/pages/api/file/deleteblobcase.js b/pages/api/file/deleteblobcase.js index c849ef80..bedd0fb0 100644 --- a/pages/api/file/deleteblobcase.js +++ b/pages/api/file/deleteblobcase.js @@ -10,25 +10,30 @@ ApiProxy.use(middleware); ApiProxy.get(async (req, res) => { var containerName = req.query.container; var casefolderID = req.query.casefolderID; - var blobName = req.query.blobname; var checkHash = req.query.hash; + if ( + typeof containerName === "undefined" || + containerName.length === 0 || + typeof casefolderID === "undefined" || + casefolderID.length === 0 + ) { + return res.status(400).json(); + } + var checkquerypath = "/api/file/deleteblobcase?container=" + containerName + "&casefolderID=" + casefolderID; - //console.log(hashAPIPath(checkquerypath), checkHash); - //console.log(hashAPIPath(checkquerypath) == "&hash=" + checkHash); - - //if (hashAPIPath(checkquerypath) == "&hash=" + checkHash) { - await deleteBlobCase(containerName, casefolderID).then((data) => { - return res.status(200).json({ data: data }); - }); - // } else { - // return res.status(400).json(); - // } + if (hashAPIPath(checkquerypath) == "&hash=" + checkHash) { + await deleteBlobCase(containerName, casefolderID).then((data) => { + return res.status(200).json({ data: data }); + }); + } else { + return res.status(400).json(); + } }); export const config = { diff --git a/pages/api/file/deleteblobrep.js b/pages/api/file/deleteblobrep.js index 2c3e55c2..271cc21d 100644 --- a/pages/api/file/deleteblobrep.js +++ b/pages/api/file/deleteblobrep.js @@ -1,5 +1,5 @@ import { hashAPIPath } from "../../../actions/core/hash"; -import { deleteBlobCase, deleteBlobRep } from "../../../actions/azurestorage"; +import { deleteBlobRep } from "../../../actions/azurestorage"; import nextConnect from "next-connect"; import middleware from "../middleware/middleware"; @@ -11,9 +11,19 @@ ApiProxy.get(async (req, res) => { var containerName = req.query.container; var casefolderID = req.query.casefolderID; var repfile = req.query.repfile; - var blobName = req.query.blobname; var checkHash = req.query.hash; + if ( + typeof containerName === "undefined" || + containerName.length === 0 || + typeof casefolderID === "undefined" || + casefolderID.length === 0 || + typeof repfile === "undefined" || + repfile.length === 0 + ) { + return res.status(400).json(); + } + var checkquerypath = "/api/file/deleteblobrep?container=" + containerName + @@ -24,16 +34,13 @@ ApiProxy.get(async (req, res) => { casefolderID = casefolderID + "/" + repfile; - //console.log(hashAPIPath(checkquerypath), checkHash); - //console.log(hashAPIPath(checkquerypath) == "&hash=" + checkHash); - - //if (hashAPIPath(checkquerypath) == "&hash=" + checkHash) { - await deleteBlobRep(containerName, casefolderID).then((data) => { - return res.status(200).json({ data: data }); - }); - // } else { - // return res.status(400).json(); - // } + if (hashAPIPath(checkquerypath) == "&hash=" + checkHash) { + await deleteBlobRep(containerName, casefolderID).then((data) => { + return res.status(200).json({ data: data }); + }); + } else { + return res.status(400).json(); + } }); export const config = { diff --git a/tests/phase8/service-behaviour.test.cjs b/tests/phase8/service-behaviour.test.cjs new file mode 100644 index 00000000..eb677911 --- /dev/null +++ b/tests/phase8/service-behaviour.test.cjs @@ -0,0 +1,263 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadModule = (relativePath, injected = {}) => { + const filePath = path.join(rootDir, relativePath); + let source = fs.readFileSync(filePath, "utf8"); + + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + source = source.replace( + /export default async function\s+(\w+)\s*\(/, + "async function $1(" + ); + source = source.replace(/export const\s+/g, "const "); + source = source.replace( + /export default\s+(\w+);/g, + "module.exports.default = $1;" + ); + + source += + '\nif (typeof ApiProxy !== "undefined" && !module.exports.default) module.exports.default = ApiProxy;\n'; + + const context = { + module: { exports: {} }, + exports: {}, + require, + process, + console: { + log: () => {}, + info: () => {}, + warn: () => {}, + error: () => {} + }, + ...injected + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const createNextConnectMock = () => { + const router = { + handler: null, + use: () => {}, + get(fn) { + this.handler = fn; + } + }; + + return () => router; +}; + +const createRes = () => { + const state = { + statusCode: null, + jsonBody: undefined + }; + + return { + state, + status(code) { + state.statusCode = code; + return this; + }, + json(payload) { + state.jsonBody = payload; + return payload; + } + }; +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +test("deleteblobcase rejects missing hash with 400", async () => { + const calls = []; + const nextConnect = createNextConnectMock(); + + const mod = loadModule("pages/api/file/deleteblobcase.js", { + hashAPIPath: () => "&hash=good", + deleteBlobCase: async (...args) => { + calls.push(args); + return { ok: true }; + }, + nextConnect, + middleware: () => {} + }); + + const req = { query: { container: "c1", casefolderID: "case-1" } }; + const res = createRes(); + + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(calls.length, 0); +}); + +test("deleteblobrep rejects missing repfile with 400", async () => { + const calls = []; + const nextConnect = createNextConnectMock(); + + const mod = loadModule("pages/api/file/deleteblobrep.js", { + hashAPIPath: () => "&hash=good", + deleteBlobRep: async (...args) => { + calls.push(args); + return { ok: true }; + }, + nextConnect, + middleware: () => {} + }); + + const req = { + query: { container: "c1", casefolderID: "case-1", hash: "good" } + }; + const res = createRes(); + + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(calls.length, 0); +}); + +test("createappealcompletemessage_api rejects invalid hash with 400", async () => { + const calls = []; + const nextConnect = createNextConnectMock(); + + const mod = loadModule( + "pages/api/file/createappealcompletemessage_api.js", + { + hashAPIPath: () => "&hash=expected", + consoleLogger: () => {}, + getProgressBlobs: async () => { + calls.push("getProgressBlobs"); + return { path: "x" }; + }, + downloadProgressFile: async () => ({}), + createBlob: async () => {}, + getCaseBlob: async () => {}, + createCaseCompleteMessage: () => {}, + updateAccount: async () => {}, + _: { isEmpty: (value) => !value }, + nextConnect, + middleware: () => {} + } + ); + + const req = { + query: { + container: "c1", + tempcaseref: "temp-1", + inv: "846040001", + hash: "wrong" + } + }; + const res = createRes(); + + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(calls.length, 0); +}); + +test("getportallogin_api rejects invalid hash with 400", async () => { + const tokenCalls = []; + + const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { + CryptoJS: { + HmacSHA256: () => ({ toString: () => "hashed" }), + enc: { + Hex: { + parse: () => "parsed", + toString: () => "" + } + } + }, + axios: { + get: async () => ({ data: { value: [] } }) + }, + azureHeadersPaged: () => ({ headers: {} }), + consoleLogger: () => {}, + getToken: async () => { + tokenCalls.push(true); + return { access_token: "token" }; + }, + process: { env: { HASHKEY: "00", RELAY_ROOT: "http://relay/" } } + }); + + const req = { + query: { + emailAddress: "person@example.com", + hash: "wrong" + } + }; + const res = createRes(); + + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(tokenCalls.length, 0); +}); + +test("getportallogin_api happy path returns 200 and payload", async () => { + const axiosCalls = []; + + const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { + CryptoJS: { + HmacSHA256: () => ({ toString: () => "hashed" }), + enc: { + Hex: { + parse: () => "parsed", + toString: () => "" + } + } + }, + axios: { + get: async (url, config) => { + axiosCalls.push({ url, config }); + return { data: { value: [{ id: "user-1" }] } }; + } + }, + azureHeadersPaged: (token) => ({ token }), + consoleLogger: () => {}, + getToken: async () => ({ access_token: "token-1" }), + process: { env: { HASHKEY: "00", RELAY_ROOT: "http://relay/" } } + }); + + const req = { + query: { + emailAddress: "person@example.com", + hash: "hashed" + } + }; + const res = createRes(); + + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + value: [{ id: "user-1" }] + }); + assert.strictEqual(axiosCalls.length, 1); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 8 behavioural tests passed (${passed}/${tests.length}).` + ); +}; + +run().catch((error) => { + console.error(error); + process.exit(1); +});