From 82f891ada837bc09f279422a995c39744b14521c Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 13 Mar 2026 13:49:28 +0000 Subject: [PATCH] Fix client hash signing via authenticated endpoint --- actions/services/documentDirectService.js | 32 ++++- actions/services/portalDirectService.js | 29 ++++- pages/api/endpoint/gethash_api.js | 35 ++++++ tests/phase14/service-behaviour.test.cjs | 144 ++++++++++++++++++++++ 4 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 pages/api/endpoint/gethash_api.js create mode 100644 tests/phase14/service-behaviour.test.cjs diff --git a/actions/services/documentDirectService.js b/actions/services/documentDirectService.js index 870bd8bc..d0afabb8 100644 --- a/actions/services/documentDirectService.js +++ b/actions/services/documentDirectService.js @@ -3,6 +3,26 @@ import { BASE_URL } from "../core/env"; import { consoleLogger } from "../core/logger"; import { hashAPIPath } from "../core/hash"; +const buildHashedQueryUrl = async (queryUrl) => { + try { + const signRes = await axios.get( + "/api/endpoint/gethash_api?path=" + encodeURIComponent(queryUrl) + ); + + if (!signRes?.data?.hash) { + throw new Error("Hash signature unavailable"); + } + + return queryUrl + signRes.data.hash; + } catch (error) { + if (typeof window === "undefined" && process.env.HASHKEY) { + return queryUrl + hashAPIPath(queryUrl); + } + + throw error; + } +}; + export const getAwaitingSubmissionFromBlob = (containerName) => { return axios .get( @@ -132,9 +152,11 @@ export const uploadFiles = async ( var queryUrl = "/api/file/upload"; + const hashedUrl = await buildHashedQueryUrl(queryUrl); + const config = { method: "post", - url: queryUrl + hashAPIPath(queryUrl), + url: hashedUrl, data: formData, headers: { "content-type": "multipart/form-data" } }; @@ -160,9 +182,11 @@ export const uploadSingleFile = async (filesObj, containerID, casefolderID) => { var queryUrl = "/api/file/uploadsinglefile"; + const hashedUrl = await buildHashedQueryUrl(queryUrl); + const config = { method: "post", - url: queryUrl + hashAPIPath(queryUrl), + url: hashedUrl, data: formData, headers: { "content-type": "multipart/form-data" } }; @@ -189,9 +213,11 @@ export const uploadRepFiles = async ( var queryUrl = "/api/file/upload"; + const hashedUrl = await buildHashedQueryUrl(queryUrl); + const config = { method: "post", - url: queryUrl + hashAPIPath(queryUrl), + url: hashedUrl, data: formData, headers: { "content-type": "multipart/form-data" } }; diff --git a/actions/services/portalDirectService.js b/actions/services/portalDirectService.js index 63acc1b2..9bf17190 100644 --- a/actions/services/portalDirectService.js +++ b/actions/services/portalDirectService.js @@ -3,6 +3,26 @@ import { BASE_URL } from "../core/env"; import { consoleLogger } from "../core/logger"; import { hashAPIPath } from "../core/hash"; +const buildHashedQueryUrl = async (queryUrl) => { + try { + const signRes = await axios.get( + "/api/endpoint/gethash_api?path=" + encodeURIComponent(queryUrl) + ); + + if (!signRes?.data?.hash) { + throw new Error("Hash signature unavailable"); + } + + return queryUrl + signRes.data.hash; + } catch (error) { + if (typeof window === "undefined" && process.env.HASHKEY) { + return queryUrl + hashAPIPath(queryUrl); + } + + throw error; + } +}; + export const getMyCases = (loggedInUserId) => { return axios .get( @@ -234,8 +254,11 @@ export const sendCaseCompleteMessage = async ( "&tempcaseref=" + caseReference + "&inv=" + - inv + - hashAPIPath(hashQueryPath); + inv; + + var signedQueryUrl = await buildHashedQueryUrl(hashQueryPath); + + queryUrl = queryUrl + signedQueryUrl.replace(hashQueryPath, ""); var config = { method: "get", @@ -288,7 +311,7 @@ export const sendRepCompleteMessage = async ( "&repid=" + fileName; - var queryUrl = hashQueryPath + hashAPIPath(hashQueryPath); + var queryUrl = await buildHashedQueryUrl(hashQueryPath); var config = { method: "get", diff --git a/pages/api/endpoint/gethash_api.js b/pages/api/endpoint/gethash_api.js new file mode 100644 index 00000000..3555f75a --- /dev/null +++ b/pages/api/endpoint/gethash_api.js @@ -0,0 +1,35 @@ +import { hashAPIPath } from "../../../actions/core/hash"; +import nextConnect from "next-connect"; +import { getSession } from "next-auth/react"; + +const ApiProxy = nextConnect(); + +ApiProxy.get(async (req, res) => { + const session = await getSession({ req }); + + if (!session) { + return res.status(401).json(); + } + + const queryPath = req.query.path; + const allowedPrefix = [ + "/api/file/upload", + "/api/file/uploadsinglefile", + "/api/file/createrepcompletemessage_api", + "/api/file/createappealcompletemessage_api" + ]; + + if ( + typeof queryPath !== "string" || + queryPath.length === 0 || + !queryPath.startsWith("/api/") || + !allowedPrefix.some((prefix) => queryPath.startsWith(prefix)) || + queryPath.includes("/api/endpoint/gethash_api") + ) { + return res.status(400).json(); + } + + return res.status(200).json({ hash: hashAPIPath(queryPath) }); +}); + +export default ApiProxy; diff --git a/tests/phase14/service-behaviour.test.cjs b/tests/phase14/service-behaviour.test.cjs new file mode 100644 index 00000000..004b7735 --- /dev/null +++ b/tests/phase14/service-behaviour.test.cjs @@ -0,0 +1,144 @@ +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: () => {}, 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("gethash_api rejects missing path with 400", async () => { + const mod = loadModule("pages/api/endpoint/gethash_api.js", { + hashAPIPath: () => "?hash=expected", + getSession: async () => ({ user: { id: "u1" } }), + nextConnect: createNextConnectMock(), + middleware: () => {} + }); + + const req = { query: {} }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); +}); + +test("gethash_api rejects non-api path with 400", async () => { + const mod = loadModule("pages/api/endpoint/gethash_api.js", { + hashAPIPath: () => "?hash=expected", + getSession: async () => ({ user: { id: "u1" } }), + nextConnect: createNextConnectMock(), + middleware: () => {} + }); + + const req = { query: { path: "/not-api/path" } }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); +}); + +test("gethash_api returns hash for valid api path", async () => { + const mod = loadModule("pages/api/endpoint/gethash_api.js", { + hashAPIPath: () => "&hash=expected", + getSession: async () => ({ user: { id: "u1" } }), + nextConnect: createNextConnectMock(), + middleware: () => {} + }); + + const req = { query: { path: "/api/file/upload" } }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + hash: "&hash=expected" + }); +}); + +test("gethash_api rejects unauthenticated requests with 401", async () => { + const mod = loadModule("pages/api/endpoint/gethash_api.js", { + hashAPIPath: () => "&hash=expected", + getSession: async () => null, + nextConnect: createNextConnectMock(), + middleware: () => {} + }); + + const req = { query: { path: "/api/file/upload" } }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 401); +}); + +const run = async () => { + let passed = 0; + for (const t of tests) { + await t.fn(); + passed += 1; + } + console.log( + `Phase 14 behavioural tests passed (${passed}/${tests.length}).` + ); +}; + +run().catch((error) => { + console.error(error); + process.exit(1); +});