fix(security): enforce ownership on appeal and representation blob proxies

This commit is contained in:
2026-11-05 12:53:24 +00:00
parent 0a115a8808
commit 9d6d3b9f71
3 changed files with 238 additions and 24 deletions
@@ -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(
+3 -2
View File
@@ -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 ||
+191 -18
View File
@@ -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 () => {