fix: enforce representation blob ownership and secure SSR access

This commit is contained in:
2026-11-06 17:05:40 +00:00
parent 946d361a67
commit 1377aebc89
15 changed files with 447 additions and 23 deletions
+1
View File
@@ -50,6 +50,7 @@ const loadModule = (relativePath, injected = {}) => {
exports: {},
require,
process,
URL,
console: {
log: () => {},
info: () => {},
+67 -3
View File
@@ -1554,6 +1554,10 @@ test("getrepsblob handler success returns representations payload", async () =>
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
getServerSession: async () => ({ user: { id: "c1" } }),
authOptions: () => ({}),
readAdminSessionCookie: () => null,
validateAdminSession: () => false,
getRepsBlobs: async () => [
{ path: "c/ref_rep.json", name: "ref_rep.json" }
],
@@ -1612,6 +1616,10 @@ test("getrepsblob handler rejects invalid HMAC before Azure access", async () =>
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
getServerSession: async () => ({ user: { id: "c1" } }),
authOptions: () => ({}),
readAdminSessionCookie: () => null,
validateAdminSession: () => false,
getRepsBlobs: async () => {
azureCalls += 1;
return [];
@@ -1817,6 +1825,10 @@ test("getrepsblob handler dependency failure returns GET_REPS_BLOB_FAILED", asyn
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
getServerSession: async () => ({ user: { id: "c1" } }),
authOptions: () => ({}),
readAdminSessionCookie: () => null,
validateAdminSession: () => false,
getRepsBlobs: async () => {
throw new Error("storage failure");
},
@@ -1844,6 +1856,10 @@ test("getrepsblob handler accepts encoded container hash variant", async () => {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
getServerSession: async () => ({ user: { id: "cont/1" } }),
authOptions: () => ({}),
readAdminSessionCookie: () => null,
validateAdminSession: () => false,
getRepsBlobs: async () => [{ path: "cont/rep1.json" }],
downloadAllRepsFiles: async () => ({ value: [{ id: "r-encoded" }] })
});
@@ -2869,7 +2885,13 @@ const loadOrdinaryRepsProxy = (overrides = {}) => {
return "&hash=abc";
},
consoleLogger: () => {},
process: { env: { API_ROOT: "http://example.com" } },
BASE_URL: "http://example.com",
process: {
env: {
API_ROOT: "http://example.com",
NEXTAUTH_URL: "http://example.com"
}
},
axios: {
get: async (...args) => {
calls.axios.push(args);
@@ -2979,7 +3001,9 @@ for (const containerCase of [
test("getrepsblobproxy preserves signed path and response for owning user", async () => {
const loaded = loadOrdinaryRepsProxy();
const req = ordinaryRepsRequest();
const req = ordinaryRepsRequest({
headers: { cookie: "next-auth.session-token=test-session" }
});
const res = createRes();
await loaded.mod.default(req, res);
@@ -2995,6 +3019,16 @@ test("getrepsblobproxy preserves signed path and response for owning user", asyn
loaded.calls.axios[0][0],
"http://example.com/api/file/getrepsblob?container=user-container&hash=abc"
);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(loaded.calls.axios[0][1])),
{
headers: {
Authorization: "Bearer tok",
Cookie: "next-auth.session-token=test-session"
},
maxRedirects: 0
}
);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(res.state.jsonBody)),
loaded.payload
@@ -3030,6 +3064,7 @@ const loadAdminRepsProxy = (overrides = {}) => {
const mod = loadModule("pages/api/admin/getrepsblobproxy.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
ADMIN_SESSION_COOKIE: "pedw_admin_session",
readAdminSessionCookie: () => "admin-token",
validateAdminSession: () => true,
hasTrustedSameOrigin: () => true,
@@ -3044,7 +3079,13 @@ const loadAdminRepsProxy = (overrides = {}) => {
return "&hash=abc";
},
consoleLogger: () => {},
process: { env: { API_ROOT: "http://example.com" } },
BASE_URL: "http://example.com",
process: {
env: {
API_ROOT: "http://example.com",
NEXTAUTH_URL: "http://example.com"
}
},
axios: {
get: async (...args) => {
calls.axios.push(args);
@@ -3171,6 +3212,16 @@ test("admin getrepsblobproxy preserves cross-container signed path and response"
loaded.calls.axios[0][0],
"http://example.com/api/file/getrepsblob?container=selected-user-container&hash=abc"
);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(loaded.calls.axios[0][1])),
{
headers: {
Authorization: "Bearer tok",
Cookie: "pedw_admin_session=admin-token"
},
maxRedirects: 0
}
);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(res.state.jsonBody)),
loaded.payload
@@ -3202,6 +3253,19 @@ test("admin storage refresh uses dedicated POST proxy while ordinary callers rem
assert.match(viewAllSource, /getRepsFromBlobProxy\(containerID\)/);
});
test("admin getrepsblobproxy rejects untrusted internal destination before proxy work", async () => {
const loaded = loadAdminRepsProxy({
BASE_URL: "https://attacker.example"
});
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);
});
test("admin storage completion uses dedicated POST route while ordinary completion remains unchanged", () => {
const rootDir = path.resolve(__dirname, "..", "..");
const adminSource = fs.readFileSync(
@@ -129,7 +129,7 @@ test("excluded route families remain outside this myportal slice", async () => {
const pageSource = read("pages/myportal/index.js");
assert.match(portalSource, /\/api\/subscriptions\/get-watched-cases/);
assert.match(pageSource, /getRepsFromBlob\(thisSession.user.id\)/);
assert.match(pageSource, /getRepsFromBlob\(thisSession.user.id,\s*\{/);
assert.match(
pageSource,
/getAwaitingSubmissionFromBlob\(thisSession.user.id\)/
@@ -0,0 +1,157 @@
const assert = require("assert");
const {
loadModule,
createRes,
createNextConnectMock,
respondSuccessMock,
respondErrorMock
} = require("../phase21/_shared.cjs");
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
const loadRepresentationList = ({ session = null, administrator = false } = {}) => {
const calls = { list: [], download: [] };
const mod = loadModule("pages/api/file/getrepsblob.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getServerSession: async () => session,
authOptions: () => ({}),
readAdminSessionCookie: () =>
administrator ? "valid-admin-session" : null,
validateAdminSession: () => administrator,
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
getRepsBlobs: async (...args) => {
calls.list.push(args);
return [{ path: "CASE/REP_rep.json" }];
},
downloadAllRepsFiles: async (...args) => {
calls.download.push(args);
return { value: [{ id: "rep-1" }] };
}
});
return { mod, calls };
};
const representationListRequest = (query = {}) => ({
query: {
container: "organisation-container",
hash: "expected",
...query
},
headers: {}
});
test("representation listing rejects a missing session before Azure access", async () => {
const loaded = loadRepresentationList();
const res = createRes();
await loaded.mod.default.handler(representationListRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assert.strictEqual(loaded.calls.list.length, 0);
assert.strictEqual(loaded.calls.download.length, 0);
});
test("representation listing rejects cross-user container access before Azure access", async () => {
const loaded = loadRepresentationList({
session: { user: { id: "different-container" } }
});
const res = createRes();
await loaded.mod.default.handler(representationListRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(loaded.calls.list.length, 0);
assert.strictEqual(loaded.calls.download.length, 0);
});
test("representation listing preserves authorised organisation-container access", async () => {
const loaded = loadRepresentationList({
session: { user: { id: "organisation-container" } }
});
const res = createRes();
await loaded.mod.default.handler(representationListRequest(), res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(loaded.calls.list, [["organisation-container"]]);
assert.strictEqual(loaded.calls.download.length, 1);
});
test("representation listing preserves existing administrative access", async () => {
const loaded = loadRepresentationList({ administrator: true });
const res = createRes();
await loaded.mod.default.handler(representationListRequest(), res);
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(loaded.calls.list.length, 1);
});
test("representation listing rejects a missing identifier before authentication or Azure access", async () => {
let sessionCalls = 0;
const loaded = loadRepresentationList({
session: { user: { id: "organisation-container" } }
});
loaded.mod = loadModule("pages/api/file/getrepsblob.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
getServerSession: async () => {
sessionCalls += 1;
return { user: { id: "organisation-container" } };
},
authOptions: () => ({}),
readAdminSessionCookie: () => null,
validateAdminSession: () => false,
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
getRepsBlobs: async () => {
loaded.calls.list.push([]);
return [];
},
downloadAllRepsFiles: async () => {
loaded.calls.download.push([]);
return {};
}
});
const res = createRes();
await loaded.mod.default.handler(
representationListRequest({ container: "" }),
res
);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY");
assert.strictEqual(sessionCalls, 0);
assert.strictEqual(loaded.calls.list.length, 0);
assert.strictEqual(loaded.calls.download.length, 0);
});
const run = async () => {
let passed = 0;
for (const currentTest of tests) {
await currentTest.fn();
passed += 1;
}
console.log(
`Representation listing ownership tests passed (${passed}/${tests.length}).`
);
};
module.exports = run;
if (require.main === module) {
run().catch((error) => {
console.error(error);
process.exit(1);
});
}
+87
View File
@@ -65,6 +65,93 @@ test("document/getAwaitingSubmissionFromBlob logs and returns undefined on failu
assert.strictEqual(logger.calls.length, 1);
});
test("document/getRepsFromBlob forwards cookies only to trusted origin without redirects", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();
axios.getHandler = async () => ({
data: { value: ["representation"] }
});
const document = loadServiceModule("documentDirectService.js", {
axios,
BASE_URL: "http://example.local",
consoleLogger: logger.consoleLogger,
hashAPIPath: () => "&hash=abc123"
});
const result = await document.getRepsFromBlob("container-1", {
headers: {
Cookie: "next-auth.session-token=test-session"
}
});
assert.deepStrictEqual(normalize(result), {
value: ["representation"]
});
assert.strictEqual(axios.calls.length, 1);
assert.strictEqual(
axios.calls[0].url,
"http://example.local/api/file/getrepsblob?container=container-1&hash=abc123"
);
assert.strictEqual(
axios.calls[0].config.headers.Cookie,
"next-auth.session-token=test-session"
);
assert.strictEqual(axios.calls[0].config.maxRedirects, 0);
assert.strictEqual(logger.calls.length, 0);
});
test("document/getRepsFromBlob rejects untrusted origin before HTTP request", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();
const document = loadServiceModule("documentDirectService.js", {
axios,
BASE_URL: "https://untrusted.example",
consoleLogger: logger.consoleLogger,
hashAPIPath: () => "&hash=abc123"
});
await assert.rejects(
document.getRepsFromBlob("container-1", {
headers: {
Cookie: "next-auth.session-token=test-session"
}
}),
/Untrusted representation request destination/
);
assert.strictEqual(axios.calls.length, 0);
assert.strictEqual(logger.calls.length, 0);
});
test("document/getRepsFromBlob preserves requests without forwarded cookies", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();
axios.getHandler = async () => ({
data: { value: ["representation"] }
});
const document = loadServiceModule("documentDirectService.js", {
axios,
BASE_URL: "http://example.local",
consoleLogger: logger.consoleLogger,
hashAPIPath: () => "&hash=abc123"
});
const result = await document.getRepsFromBlob("container-1");
assert.deepStrictEqual(normalize(result), {
value: ["representation"]
});
assert.strictEqual(axios.calls.length, 1);
assert.strictEqual(axios.calls[0].config, undefined);
assert.strictEqual(logger.calls.length, 0);
});
test("portal/deleteWatchedCases uses signer hash and returns data", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();
+8 -2
View File
@@ -89,8 +89,8 @@ const loadServiceModule = (fileName, injected = {}) => {
return injected.axios(config).then((response) => response.data);
};
const defaultGetFileJson = (url) => {
return defaultGetJson(url);
const defaultGetFileJson = (url, config) => {
return defaultGetJson(url, config);
};
const defaultBuildHashedQueryUrl = async (queryUrl) => {
@@ -207,8 +207,14 @@ const loadServiceModule = (fileName, injected = {}) => {
module: { exports: {} },
exports: {},
require,
URL,
URLSearchParams,
encodeURIComponent,
process: injected.process || {
env: {
NEXTAUTH_URL: "http://example.local"
}
},
FormData: global.FormData,
console: {
log: () => {},