From 034b59a9818a6909968f91eb56e834566c7c0c53 Mon Sep 17 00:00:00 2001 From: robbond Date: Mon, 26 Oct 2026 16:17:52 +0000 Subject: [PATCH] ITHC: enforce representation blob proxy ownership and admin access --- components/admin/tabs/storage.js | 19 +- pages/api/admin/getrepsblobproxy.js | 76 ++++ pages/api/file/getrepsblobproxy.js | 44 +- tests/phase21/file-handler-contract.test.cjs | 403 ++++++++++++++++++- 4 files changed, 522 insertions(+), 20 deletions(-) create mode 100644 pages/api/admin/getrepsblobproxy.js diff --git a/components/admin/tabs/storage.js b/components/admin/tabs/storage.js index eadd487f..ad8e2045 100644 --- a/components/admin/tabs/storage.js +++ b/components/admin/tabs/storage.js @@ -2,10 +2,7 @@ import { useState } from "react"; import Link from "next/link"; import { bytesToSize } from "../../../components/utils"; -import { - deleteAwaitingSubmissionsFromBlob, - getRepsFromBlobProxy -} from "../../../actions/services/documentService"; +import { deleteAwaitingSubmissionsFromBlob } from "../../../actions/services/documentService"; import { sendRepCompleteMessage } from "../../../actions/services/portalService"; import { consoleLogger } from "../../../actions/core/logger"; @@ -42,7 +39,19 @@ export default function StorageTab({ data, repCompleteCount }) { return data; }) .then(() => { - getRepsFromBlobProxy(containerID) + fetch("/api/admin/getrepsblobproxy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ container: containerID }) + }) + .then((response) => { + if (!response.ok) { + throw new Error( + "Unable to refresh representations" + ); + } + return response.json(); + }) .then((data) => { setMyRepresentations(data); setMyRepresentationsDetails(data); diff --git a/pages/api/admin/getrepsblobproxy.js b/pages/api/admin/getrepsblobproxy.js new file mode 100644 index 00000000..129671d7 --- /dev/null +++ b/pages/api/admin/getrepsblobproxy.js @@ -0,0 +1,76 @@ +import { getToken } from "../../../actions/core/token"; +import { azureHeaders } from "../../../actions/core/headers"; +import { consoleLogger } from "../../../actions/core/logger"; +import { hashAPIPath } from "../../../actions/core/hash"; +import axios from "axios"; +import { + hasTrustedSameOrigin, + readAdminSessionCookie, + setAdminAuthNoStore, + validateAdminSession +} from "../../../lib/admin/adminAuth"; +import { respondError, respondSuccess } from "../middleware/apiResponse"; + +const BASE_URL = process.env.API_ROOT || `http://localhost:${port}`; + +const forbidden = (res) => + respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + +const isValidContainer = (value) => + typeof value === "string" && + value.length >= 3 && + value.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value); + +export default async function handler(req, res) { + setAdminAuthNoStore(res); + res.setHeader("Allow", "POST"); + + if (req.method !== "POST") { + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }); + } + + const adminSession = readAdminSessionCookie(req); + if ( + !validateAdminSession(adminSession) || + !hasTrustedSameOrigin(req) + ) { + return forbidden(res); + } + + const { container } = req.body ?? {}; + if (!isValidContainer(container)) { + return respondError(res, { + status: 400, + code: "INVALID_REQUEST", + message: "Invalid request" + }); + } + + const token = await getToken(); + const queryUrl = + "/api/file/getrepsblob?container=" + encodeURIComponent(container); + + try { + const { data } = await axios.get( + BASE_URL + queryUrl + hashAPIPath(queryUrl), + azureHeaders(token.access_token) + ); + return respondSuccess(res, data); + } catch (error) { + consoleLogger(error); + return respondError(res, { + status: 400, + code: "GET_REPS_BLOB_PROXY_FAILED", + message: "Failed to fetch representation blobs" + }); + } +} \ No newline at end of file diff --git a/pages/api/file/getrepsblobproxy.js b/pages/api/file/getrepsblobproxy.js index 64a02e54..228fedf7 100644 --- a/pages/api/file/getrepsblobproxy.js +++ b/pages/api/file/getrepsblobproxy.js @@ -4,16 +4,37 @@ import { consoleLogger } from "../../../actions/core/logger"; import { hashAPIPath } from "../../../actions/core/hash"; import axios from "axios"; import { respondError, respondSuccess } from "../middleware/apiResponse"; +import { getSession } from "next-auth/react"; const BASE_URL = process.env.API_ROOT || `http://localhost:${port}`; -const hasValue = (value) => - typeof value === "string" && value.trim().length > 0; +const forbidden = (res) => + respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + +const isValidContainer = (value) => + typeof value === "string" && + value.length >= 3 && + value.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value); export default async function ApiProxy(req, res) { + res.setHeader("Allow", "GET"); + + if (req.method !== "GET") { + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }); + } + const containerName = req.query.container; - if (!hasValue(containerName)) { + if (typeof containerName === "undefined" || containerName === "") { return respondError(res, { status: 400, code: "MISSING_REQUIRED_QUERY", @@ -21,6 +42,23 @@ export default async function ApiProxy(req, res) { }); } + if (!isValidContainer(containerName)) { + return respondError(res, { + status: 400, + code: "INVALID_REQUEST", + message: "Invalid request" + }); + } + + const session = await getSession({ req }); + if ( + typeof session?.user?.id !== "string" || + session.user.id.length === 0 || + session.user.id !== containerName + ) { + return forbidden(res); + } + const token = await getToken(); const queryUrl = diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index 1d40d659..6e6a016c 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -908,6 +908,91 @@ test("getrepsblob handler success returns representations payload", async () => }); }); +test("getrepsblob handler rejects missing HMAC before Azure access", async () => { + let azureCalls = 0; + const mod = loadModule("pages/api/file/getrepsblob.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + consoleLogger: () => {}, + getRepsBlobs: async () => { + azureCalls += 1; + return []; + }, + downloadAllRepsFiles: async () => { + azureCalls += 1; + return {}; + } + }); + const res = createRes(); + + await mod.default.handler({ query: { container: "c1" } }, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY"); + assert.strictEqual(azureCalls, 0); +}); + +test("getrepsblob handler rejects invalid HMAC before Azure access", async () => { + let azureCalls = 0; + const mod = loadModule("pages/api/file/getrepsblob.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + consoleLogger: () => {}, + getRepsBlobs: async () => { + azureCalls += 1; + return []; + }, + downloadAllRepsFiles: async () => { + azureCalls += 1; + return {}; + } + }); + const res = createRes(); + + await mod.default.handler( + { query: { container: "c1", hash: "wrong" } }, + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH"); + assert.strictEqual(azureCalls, 0); +}); + +test("general hash endpoint does not authorize getrepsblob signing", async () => { + let hashCalls = 0; + const mod = loadModule("pages/api/endpoint/gethash_api.js", { + nextConnect: createNextConnectMock(), + getSession: async () => ({ user: { id: "user-container" } }), + hashAPIPath: () => { + hashCalls += 1; + return "&hash=unexpected"; + }, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock + }); + const res = createRes(); + + await mod.default.handler( + { + query: { + path: "/api/file/getrepsblob?container=user-container" + } + }, + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH_PATH"); + assert.strictEqual(hashCalls, 0); +}); + test("getrepsblob handler dependency failure returns GET_REPS_BLOB_FAILED", async () => { const mod = loadModule("pages/api/file/getrepsblob.js", { nextConnect: createNextConnectMock(), @@ -1138,30 +1223,324 @@ test("getbloblistproxy dependency failure returns GET_BLOB_LIST_PROXY_FAILED", a ); }); -test("getrepsblobproxy success returns proxied payload", async () => { +const loadOrdinaryRepsProxy = (overrides = {}) => { + const calls = { token: 0, hash: [], axios: [] }; + const payload = { "@odata.count": 1, value: [{ id: "r1" }] }; const mod = loadModule("pages/api/file/getrepsblobproxy.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - getToken: async () => ({ access_token: "tok" }), - azureHeaders: () => ({ headers: {} }), - hashAPIPath: () => "&hash=abc", + getSession: async () => ({ user: { id: "user-container" } }), + getToken: async () => { + calls.token += 1; + return { access_token: "tok" }; + }, + azureHeaders: () => ({ headers: { Authorization: "Bearer tok" } }), + hashAPIPath: (queryPath) => { + calls.hash.push(queryPath); + return "&hash=abc"; + }, consoleLogger: () => {}, process: { env: { API_ROOT: "http://example.com" } }, axios: { - get: async () => ({ data: { value: [{ id: "r1" }] } }) - } + get: async (...args) => { + calls.axios.push(args); + return { data: payload }; + } + }, + ...overrides }); - const req = { - query: { container: "c1" } - }; + return { mod, calls, payload }; +}; + +const ordinaryRepsRequest = (overrides = {}) => ({ + method: "GET", + query: { container: "user-container" }, + headers: {}, + ...overrides +}); + +const assertNoProxyWork = (calls) => { + assert.strictEqual(calls.token, 0); + assert.strictEqual(calls.hash.length, 0); + assert.strictEqual(calls.axios.length, 0); +}; + +for (const sessionCase of [ + { name: "missing session", session: null }, + { name: "session without user id", session: { user: {} } }, + { name: "session with empty user id", session: { user: { id: "" } } }, + { name: "session with non-string user id", session: { user: { id: 123 } } } +]) { + test(`getrepsblobproxy rejects ${sessionCase.name} before proxy work`, async () => { + const loaded = loadOrdinaryRepsProxy({ + getSession: async () => sessionCase.session + }); + const res = createRes(); + + await loaded.mod.default(ordinaryRepsRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assertNoProxyWork(loaded.calls); + }); +} + +test("getrepsblobproxy rejects cross-container access before proxy work", async () => { + const loaded = loadOrdinaryRepsProxy({ + getSession: async () => ({ user: { id: "different-container" } }) + }); const res = createRes(); - await mod.default(req, res); + + await loaded.mod.default(ordinaryRepsRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assertNoProxyWork(loaded.calls); +}); + +test("administrative cookie alone cannot authorize getrepsblobproxy", async () => { + const loaded = loadOrdinaryRepsProxy({ getSession: async () => null }); + const res = createRes(); + + await loaded.mod.default( + ordinaryRepsRequest({ + headers: { cookie: "pedw_admin_session=valid-admin-token" } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 403); + assertNoProxyWork(loaded.calls); +}); + +test("getrepsblobproxy rejects unsupported methods before proxy work", async () => { + const loaded = loadOrdinaryRepsProxy(); + const res = createRes(); + + await loaded.mod.default(ordinaryRepsRequest({ method: "POST" }), res); + + assert.strictEqual(res.state.statusCode, 405); + assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED"); + assertNoProxyWork(loaded.calls); +}); + +for (const containerCase of [ + { name: "missing container", query: {} }, + { name: "empty container", query: { container: "" } }, + { name: "null container", query: { container: null } }, + { name: "non-string container", query: { container: ["user-container"] } }, + { name: "malformed container", query: { container: "Invalid_Container" } } +]) { + test(`getrepsblobproxy rejects ${containerCase.name} before proxy work`, async () => { + const loaded = loadOrdinaryRepsProxy(); + const res = createRes(); + + await loaded.mod.default( + ordinaryRepsRequest({ query: containerCase.query }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assertNoProxyWork(loaded.calls); + }); +} + +test("getrepsblobproxy preserves signed path and response for owning user", async () => { + const loaded = loadOrdinaryRepsProxy(); + const res = createRes(); + + await loaded.mod.default(ordinaryRepsRequest(), res); assert.strictEqual(res.state.statusCode, 200); - assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { - value: [{ id: "r1" }] + assert.strictEqual(loaded.calls.token, 1); + assert.deepStrictEqual(loaded.calls.hash, [ + "/api/file/getrepsblob?container=user-container" + ]); + assert.strictEqual( + loaded.calls.axios[0][0], + "http://example.com/api/file/getrepsblob?container=user-container&hash=abc" + ); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(res.state.jsonBody)), + loaded.payload + ); +}); + +const loadAdminRepsProxy = (overrides = {}) => { + const calls = { token: 0, hash: [], axios: [] }; + const payload = { "@odata.count": 1, value: [{ id: "admin-r1" }] }; + const mod = loadModule("pages/api/admin/getrepsblobproxy.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + readAdminSessionCookie: () => "admin-token", + validateAdminSession: () => true, + hasTrustedSameOrigin: () => true, + setAdminAuthNoStore: () => {}, + getToken: async () => { + calls.token += 1; + return { access_token: "tok" }; + }, + azureHeaders: () => ({ headers: { Authorization: "Bearer tok" } }), + hashAPIPath: (queryPath) => { + calls.hash.push(queryPath); + return "&hash=abc"; + }, + consoleLogger: () => {}, + process: { env: { API_ROOT: "http://example.com" } }, + axios: { + get: async (...args) => { + calls.axios.push(args); + return { data: payload }; + } + }, + ...overrides }); + + return { mod, calls, payload }; +}; + +const adminRepsRequest = (overrides = {}) => ({ + method: "POST", + headers: { origin: "https://portal.example.test" }, + body: { container: "selected-user-container" }, + ...overrides +}); + +for (const adminSessionCase of [ + { + name: "missing administrative session", + overrides: { + readAdminSessionCookie: () => null, + validateAdminSession: () => false + } + }, + { + name: "invalid administrative session", + overrides: { validateAdminSession: () => false } + }, + { + name: "expired administrative session", + overrides: { validateAdminSession: () => false } + } +]) { + test(`admin getrepsblobproxy rejects ${adminSessionCase.name} before proxy work`, async () => { + const loaded = loadAdminRepsProxy(adminSessionCase.overrides); + const res = createRes(); + + await loaded.mod.default(adminRepsRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assertNoProxyWork(loaded.calls); + }); +} + +for (const originCase of ["missing Origin", "cross-origin request"]) { + test(`admin getrepsblobproxy rejects ${originCase} before proxy work`, async () => { + const loaded = loadAdminRepsProxy({ hasTrustedSameOrigin: () => false }); + const req = adminRepsRequest(); + if (originCase === "missing Origin") delete req.headers.origin; + if (originCase === "cross-origin request") { + req.headers.origin = "https://attacker.example"; + } + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 403); + assertNoProxyWork(loaded.calls); + }); +} + +for (const containerCase of [ + { name: "missing container", body: {} }, + { name: "malformed container", body: { container: "Invalid_Container" } } +]) { + test(`admin getrepsblobproxy rejects ${containerCase.name} before proxy work`, async () => { + const loaded = loadAdminRepsProxy(); + const res = createRes(); + + await loaded.mod.default( + adminRepsRequest({ body: containerCase.body }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST"); + assertNoProxyWork(loaded.calls); + }); +} + +test("admin getrepsblobproxy rejects unsupported methods before proxy work", async () => { + const loaded = loadAdminRepsProxy(); + const res = createRes(); + + await loaded.mod.default(adminRepsRequest({ method: "GET" }), res); + + assert.strictEqual(res.state.statusCode, 405); + assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED"); + assertNoProxyWork(loaded.calls); +}); + +test("ordinary NextAuth session alone cannot authorize admin getrepsblobproxy", async () => { + const loaded = loadAdminRepsProxy({ + readAdminSessionCookie: () => null, + validateAdminSession: () => false, + getSession: async () => ({ user: { id: "selected-user-container" } }) + }); + const res = createRes(); + + await loaded.mod.default(adminRepsRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assertNoProxyWork(loaded.calls); +}); + +test("admin getrepsblobproxy preserves cross-container signed path and response", async () => { + const loaded = loadAdminRepsProxy(); + const res = createRes(); + + await loaded.mod.default(adminRepsRequest(), res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.calls.token, 1); + assert.deepStrictEqual(loaded.calls.hash, [ + "/api/file/getrepsblob?container=selected-user-container" + ]); + assert.strictEqual( + loaded.calls.axios[0][0], + "http://example.com/api/file/getrepsblob?container=selected-user-container&hash=abc" + ); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(res.state.jsonBody)), + loaded.payload + ); +}); + +test("admin storage refresh uses dedicated POST proxy while ordinary callers remain unchanged", () => { + const rootDir = path.resolve(__dirname, "..", ".."); + const adminSource = fs.readFileSync( + path.join(rootDir, "components/admin/tabs/storage.js"), + "utf8" + ); + const topThreeSource = fs.readFileSync( + path.join(rootDir, "components/myportal/topthree_reps.js"), + "utf8" + ); + const viewAllSource = fs.readFileSync( + path.join(rootDir, "components/myportal/viewall.js"), + "utf8" + ); + + assert.match(adminSource, /fetch\("\/api\/admin\/getrepsblobproxy"/); + assert.match( + adminSource, + /fetch\("\/api\/admin\/getrepsblobproxy"[\s\S]*method:\s*"POST"[\s\S]*JSON\.stringify\(\{ container: containerID \}\)/ + ); + assert.doesNotMatch(adminSource, /getRepsFromBlobProxy/); + assert.match(topThreeSource, /getRepsFromBlobProxy\(containerID\)/); + assert.match(viewAllSource, /getRepsFromBlobProxy\(containerID\)/); }); test("getawaitingsubmissionfromblobproxy dependency failure returns GET_AWAITING_SUBMISSION_PROXY_FAILED", async () => {