From 056dace56304341022185ae8dff3980f95067bff Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 4 Nov 2026 13:45:56 +0000 Subject: [PATCH] fix(security): enforce ownership on draft blob listing proxy --- pages/api/file/getbloblistproxy.js | 42 ++++ tests/phase21/file-handler-contract.test.cjs | 212 ++++++++++++++++--- 2 files changed, 228 insertions(+), 26 deletions(-) diff --git a/pages/api/file/getbloblistproxy.js b/pages/api/file/getbloblistproxy.js index 6dd29acf..7881099a 100644 --- a/pages/api/file/getbloblistproxy.js +++ b/pages/api/file/getbloblistproxy.js @@ -4,13 +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; const casefolderID = req.query.casefolderID; @@ -22,6 +47,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 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 = diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index 5632e6b8..be84d5c8 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -2189,59 +2189,219 @@ test("admin representation completion accepts legitimate filename punctuation", assert.strictEqual(loaded.calls.queue.length, 1); }); -test("getbloblistproxy success returns proxied payload", async () => { +const loadBlobListProxy = ({ session, ...overrides } = {}) => { + const calls = { + session: [], + options: [], + token: 0, + hash: [], + headers: [], + axios: [] + }; + const options = { id: "pedw-blob-list-options" }; + const payload = { value: [{ id: "blob-1" }] }; + const resolvedSession = + typeof session === "undefined" + ? { user: { id: "user-container" } } + : session; const mod = loadModule("pages/api/file/getbloblistproxy.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - getToken: async () => ({ access_token: "tok" }), - azureHeaders: () => ({ headers: { Authorization: "Bearer tok" } }), - hashAPIPath: () => "&hash=abc", + authOptions: (req, res) => { + calls.options.push([req, res]); + return options; + }, + getServerSession: async (...args) => { + calls.session.push(args); + return resolvedSession; + }, + getToken: async () => { + calls.token += 1; + return { access_token: "synthetic-token" }; + }, + azureHeaders: (accessToken) => { + calls.headers.push(accessToken); + return { + headers: { Authorization: "Bearer synthetic-token" } + }; + }, + hashAPIPath: (queryPath) => { + calls.hash.push(queryPath); + return "&hash=synthetic-hash"; + }, consoleLogger: () => {}, process: { env: { API_ROOT: "http://example.com" } }, axios: { - get: async () => ({ data: { value: [{ id: "b1" }] } }) - } + get: async (...args) => { + calls.axios.push(args); + return { data: payload }; + } + }, + ...overrides }); - const req = { - query: { container: "cont/1", casefolderID: "CASE/1" } - }; + return { mod, calls, options, payload }; +}; + +const blobListProxyRequest = (overrides = {}) => ({ + method: "GET", + query: { container: "user-container", casefolderID: "CASE/1" }, + headers: {}, + ...overrides +}); + +const assertNoBlobListProxyWork = (calls) => { + assert.strictEqual(calls.token, 0); + assert.strictEqual(calls.hash.length, 0); + assert.strictEqual(calls.headers.length, 0); + assert.strictEqual(calls.axios.length, 0); +}; + +test("getbloblistproxy rejects unsupported methods before authentication or proxy work", async () => { + const loaded = loadBlobListProxy(); 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: "b1" }] + await loaded.mod.default(blobListProxyRequest({ method: "POST" }), res); + + assert.strictEqual(res.state.statusCode, 405); + assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED"); + assert.strictEqual(loaded.calls.options.length, 0); + assert.strictEqual(loaded.calls.session.length, 0); + assertNoBlobListProxyWork(loaded.calls); +}); + +test("getbloblistproxy rejects a missing container before authentication or proxy work", async () => { + const loaded = loadBlobListProxy(); + const res = createRes(); + + await loaded.mod.default( + blobListProxyRequest({ query: { casefolderID: "CASE/1" } }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY"); + assert.strictEqual(loaded.calls.options.length, 0); + assert.strictEqual(loaded.calls.session.length, 0); + assertNoBlobListProxyWork(loaded.calls); +}); + +test("getbloblistproxy rejects a malformed container before authentication or proxy work", async () => { + const loaded = loadBlobListProxy(); + const res = createRes(); + + await loaded.mod.default( + blobListProxyRequest({ + query: { container: "Invalid_Container", casefolderID: "CASE/1" } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST"); + assert.strictEqual(loaded.calls.options.length, 0); + assert.strictEqual(loaded.calls.session.length, 0); + assertNoBlobListProxyWork(loaded.calls); +}); + +test("getbloblistproxy rejects a missing casefolderID before authentication or proxy work", async () => { + const loaded = loadBlobListProxy(); + const res = createRes(); + + await loaded.mod.default( + blobListProxyRequest({ query: { container: "user-container" } }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY"); + assert.strictEqual(loaded.calls.options.length, 0); + assert.strictEqual(loaded.calls.session.length, 0); + assertNoBlobListProxyWork(loaded.calls); +}); + +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(`getbloblistproxy rejects ${sessionCase.name} before proxy work`, async () => { + const loaded = loadBlobListProxy({ session: sessionCase.session }); + const res = createRes(); + + await loaded.mod.default(blobListProxyRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assertNoBlobListProxyWork(loaded.calls); }); +} + +test("getbloblistproxy rejects cross-container access before proxy work", async () => { + const loaded = loadBlobListProxy({ + session: { user: { id: "different-container" } } + }); + const res = createRes(); + + await loaded.mod.default(blobListProxyRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assertNoBlobListProxyWork(loaded.calls); +}); + +test("getbloblistproxy preserves signed path, bearer header and response for owning user", async () => { + const loaded = loadBlobListProxy(); + const req = blobListProxyRequest(); + const res = createRes(); + + await loaded.mod.default(req, res); + + const expectedPath = + "/api/file/getbloblist?container=user-container&casefolderID=CASE%2F1"; + 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, [expectedPath]); + assert.deepStrictEqual(loaded.calls.headers, ["synthetic-token"]); + assert.strictEqual(loaded.calls.axios.length, 1); + assert.strictEqual( + loaded.calls.axios[0][0], + `http://example.com${expectedPath}&hash=synthetic-hash` + ); + assert.deepStrictEqual(loaded.calls.axios[0][1], { + headers: { Authorization: "Bearer synthetic-token" } + }); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(res.state.jsonBody)), + loaded.payload + ); }); test("getbloblistproxy dependency failure returns GET_BLOB_LIST_PROXY_FAILED", async () => { - const mod = loadModule("pages/api/file/getbloblistproxy.js", { - respondError: respondErrorMock, - respondSuccess: respondSuccessMock, - getToken: async () => ({ access_token: "tok" }), - azureHeaders: () => ({ headers: {} }), - hashAPIPath: () => "&hash=abc", - consoleLogger: () => {}, - process: { env: { API_ROOT: "http://example.com" } }, + const loaded = loadBlobListProxy({ axios: { - get: async () => { + get: async (...args) => { + loaded.calls.axios.push(args); throw new Error("proxy failed"); } } }); - const req = { - query: { container: "c1", casefolderID: "f1" } - }; const res = createRes(); - await mod.default(req, res); + await loaded.mod.default(blobListProxyRequest(), res); assert.strictEqual(res.state.statusCode, 400); assert.strictEqual( res.state.jsonBody.error.code, "GET_BLOB_LIST_PROXY_FAILED" ); + assert.strictEqual(loaded.calls.token, 1); + assert.strictEqual(loaded.calls.hash.length, 1); + assert.strictEqual(loaded.calls.headers.length, 1); + assert.strictEqual(loaded.calls.axios.length, 1); }); const loadOrdinaryRepsProxy = (overrides = {}) => {