From 9d6d3b9f71e5192b54ac1a949fbea1dc35cb061d Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 4 Nov 2026 13:23:51 +0000 Subject: [PATCH] fix(security): enforce ownership on appeal and representation blob proxies --- .../getawaitingsubmissionfromblobproxy.js | 48 +++- pages/api/file/getrepsblobproxy.js | 5 +- tests/phase21/file-handler-contract.test.cjs | 209 ++++++++++++++++-- 3 files changed, 238 insertions(+), 24 deletions(-) diff --git a/pages/api/file/getawaitingsubmissionfromblobproxy.js b/pages/api/file/getawaitingsubmissionfromblobproxy.js index 3dcda590..8b2ceb6e 100644 --- a/pages/api/file/getawaitingsubmissionfromblobproxy.js +++ b/pages/api/file/getawaitingsubmissionfromblobproxy.js @@ -4,16 +4,38 @@ import { consoleLogger } from "../../../actions/core/logger"; import { hashAPIPath } from "../../../actions/core/hash"; import axios from "axios"; import { respondError, respondSuccess } from "../middleware/apiResponse"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; 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,10 +43,28 @@ export default async function ApiProxy(req, res) { }); } + if (!isValidContainer(containerName)) { + 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); + } + const token = await getToken(); const queryUrl = - "/api/file/getawaitingsubmissionfromblob?container=" + containerName; + "/api/file/getawaitingsubmissionfromblob?container=" + + encodeURIComponent(containerName); try { const { data } = await axios.get( diff --git a/pages/api/file/getrepsblobproxy.js b/pages/api/file/getrepsblobproxy.js index 228fedf7..cf279da2 100644 --- a/pages/api/file/getrepsblobproxy.js +++ b/pages/api/file/getrepsblobproxy.js @@ -4,7 +4,8 @@ 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"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; const BASE_URL = process.env.API_ROOT || `http://localhost:${port}`; @@ -50,7 +51,7 @@ export default async function ApiProxy(req, res) { }); } - const session = await getSession({ req }); + const session = await getServerSession(req, res, authOptions(req, res)); if ( typeof session?.user?.id !== "string" || session.user.id.length === 0 || diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index b3d5e0f5..5632e6b8 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -2245,12 +2245,20 @@ test("getbloblistproxy dependency failure returns GET_BLOB_LIST_PROXY_FAILED", a }); const loadOrdinaryRepsProxy = (overrides = {}) => { - const calls = { token: 0, hash: [], axios: [] }; + const calls = { session: [], options: [], token: 0, hash: [], axios: [] }; + const options = { id: "pedw-representation-options" }; const payload = { "@odata.count": 1, value: [{ id: "r1" }] }; const mod = loadModule("pages/api/file/getrepsblobproxy.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - getSession: async () => ({ user: { id: "user-container" } }), + authOptions: (req, res) => { + calls.options.push([req, res]); + return options; + }, + getServerSession: async (...args) => { + calls.session.push(args); + return { user: { id: "user-container" } }; + }, getToken: async () => { calls.token += 1; return { access_token: "tok" }; @@ -2271,7 +2279,7 @@ const loadOrdinaryRepsProxy = (overrides = {}) => { ...overrides }); - return { mod, calls, payload }; + return { mod, calls, options, payload }; }; const ordinaryRepsRequest = (overrides = {}) => ({ @@ -2295,7 +2303,7 @@ for (const sessionCase of [ ]) { test(`getrepsblobproxy rejects ${sessionCase.name} before proxy work`, async () => { const loaded = loadOrdinaryRepsProxy({ - getSession: async () => sessionCase.session + getServerSession: async () => sessionCase.session }); const res = createRes(); @@ -2309,7 +2317,7 @@ for (const sessionCase of [ test("getrepsblobproxy rejects cross-container access before proxy work", async () => { const loaded = loadOrdinaryRepsProxy({ - getSession: async () => ({ user: { id: "different-container" } }) + getServerSession: async () => ({ user: { id: "different-container" } }) }); const res = createRes(); @@ -2321,7 +2329,9 @@ test("getrepsblobproxy rejects cross-container access before proxy work", async }); test("administrative cookie alone cannot authorize getrepsblobproxy", async () => { - const loaded = loadOrdinaryRepsProxy({ getSession: async () => null }); + const loaded = loadOrdinaryRepsProxy({ + getServerSession: async () => null + }); const res = createRes(); await loaded.mod.default( @@ -2369,11 +2379,14 @@ for (const containerCase of [ test("getrepsblobproxy preserves signed path and response for owning user", async () => { const loaded = loadOrdinaryRepsProxy(); + const req = ordinaryRepsRequest(); const res = createRes(); - await loaded.mod.default(ordinaryRepsRequest(), res); + await loaded.mod.default(req, res); assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(loaded.calls.options, [[req, res]]); + assert.deepStrictEqual(loaded.calls.session, [[req, res, loaded.options]]); assert.strictEqual(loaded.calls.token, 1); assert.deepStrictEqual(loaded.calls.hash, [ "/api/file/getrepsblob?container=user-container" @@ -2388,6 +2401,29 @@ test("getrepsblobproxy preserves signed path and response for owning user", asyn ); }); +test("getrepsblobproxy dependency failure returns GET_REPS_BLOB_PROXY_FAILED", async () => { + const loaded = loadOrdinaryRepsProxy({ + axios: { + get: async (...args) => { + loaded.calls.axios.push(args); + throw new Error("representation proxy failed"); + } + } + }); + const res = createRes(); + + await loaded.mod.default(ordinaryRepsRequest(), res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "GET_REPS_BLOB_PROXY_FAILED" + ); + assert.strictEqual(loaded.calls.token, 1); + assert.strictEqual(loaded.calls.hash.length, 1); + assert.strictEqual(loaded.calls.axios.length, 1); +}); + const loadAdminRepsProxy = (overrides = {}) => { const calls = { token: 0, hash: [], axios: [] }; const payload = { "@odata.count": 1, value: [{ id: "admin-r1" }] }; @@ -2596,36 +2632,173 @@ test("admin storage completion uses dedicated POST route while ordinary completi assert.match(ordinarySource, /sendRepCompleteMessage\(/); }); -test("getawaitingsubmissionfromblobproxy dependency failure returns GET_AWAITING_SUBMISSION_PROXY_FAILED", async () => { +const loadAwaitingSubmissionProxy = (overrides = {}) => { + const calls = { session: [], options: [], token: 0, hash: [], axios: [] }; + const options = { id: "pedw-awaiting-submission-options" }; + const payload = { + "@odata.count": 1, + value: [{ pinswg_name: "TMP-SYNTHETIC" }], + case: [{ ticketnumber: "TMP-SYNTHETIC" }] + }; const mod = loadModule( "pages/api/file/getawaitingsubmissionfromblobproxy.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - getToken: async () => ({ access_token: "tok" }), - azureHeaders: () => ({ headers: {} }), - hashAPIPath: () => "&hash=abc", + authOptions: (req, res) => { + calls.options.push([req, res]); + return options; + }, + getServerSession: async (...args) => { + calls.session.push(args); + return { 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 () => { - throw new Error("awaiting failed"); + get: async (...args) => { + calls.axios.push(args); + return { data: payload }; } - } + }, + ...overrides } ); - const req = { - query: { container: "c1" } - }; + return { mod, calls, options, payload }; +}; + +const awaitingSubmissionRequest = (overrides = {}) => ({ + method: "GET", + query: { container: "user-container" }, + headers: {}, + ...overrides +}); + +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(`getawaitingsubmissionfromblobproxy rejects ${sessionCase.name} before proxy work`, async () => { + const loaded = loadAwaitingSubmissionProxy({ + getServerSession: async () => sessionCase.session + }); + const res = createRes(); + + await loaded.mod.default(awaitingSubmissionRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assertNoProxyWork(loaded.calls); + }); +} + +test("getawaitingsubmissionfromblobproxy rejects cross-container access before proxy work", async () => { + const loaded = loadAwaitingSubmissionProxy({ + getServerSession: async () => ({ user: { id: "different-container" } }) + }); const res = createRes(); - await mod.default(req, res); + + await loaded.mod.default(awaitingSubmissionRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assertNoProxyWork(loaded.calls); +}); + +test("getawaitingsubmissionfromblobproxy rejects unsupported methods before proxy work", async () => { + const loaded = loadAwaitingSubmissionProxy(); + const res = createRes(); + + await loaded.mod.default( + awaitingSubmissionRequest({ 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(`getawaitingsubmissionfromblobproxy rejects ${containerCase.name} before proxy work`, async () => { + const loaded = loadAwaitingSubmissionProxy(); + const res = createRes(); + + await loaded.mod.default( + awaitingSubmissionRequest({ query: containerCase.query }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assertNoProxyWork(loaded.calls); + }); +} + +test("getawaitingsubmissionfromblobproxy preserves signed path and response for owning user", async () => { + const loaded = loadAwaitingSubmissionProxy(); + const req = awaitingSubmissionRequest(); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(loaded.calls.options, [[req, res]]); + assert.deepStrictEqual(loaded.calls.session, [[req, res, loaded.options]]); + assert.strictEqual(loaded.calls.token, 1); + assert.deepStrictEqual(loaded.calls.hash, [ + "/api/file/getawaitingsubmissionfromblob?container=user-container" + ]); + assert.strictEqual( + loaded.calls.axios[0][0], + "http://example.com/api/file/getawaitingsubmissionfromblob?container=user-container&hash=abc" + ); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(res.state.jsonBody)), + loaded.payload + ); +}); + +test("getawaitingsubmissionfromblobproxy dependency failure returns GET_AWAITING_SUBMISSION_PROXY_FAILED", async () => { + const loaded = loadAwaitingSubmissionProxy({ + axios: { + get: async (...args) => { + loaded.calls.axios.push(args); + throw new Error("awaiting failed"); + } + } + }); + const res = createRes(); + + await loaded.mod.default(awaitingSubmissionRequest(), res); assert.strictEqual(res.state.statusCode, 400); assert.strictEqual( res.state.jsonBody.error.code, "GET_AWAITING_SUBMISSION_PROXY_FAILED" ); + assert.strictEqual(loaded.calls.token, 1); + assert.strictEqual(loaded.calls.hash.length, 1); + assert.strictEqual(loaded.calls.axios.length, 1); }); test("createappealcompletemessageproxy dependency failure returns CREATE_APPEAL_COMPLETE_MESSAGE_PROXY_FAILED", async () => {