ITHC: enforce representation completion ownership and admin access

This commit is contained in:
2026-10-26 17:11:38 +00:00
parent 034b59a981
commit 8b607d8134
4 changed files with 585 additions and 71 deletions
+396 -41
View File
@@ -1106,66 +1106,389 @@ test("uploadsinglefile handler dependency failure returns UPLOAD_SINGLE_FILE_FAI
);
});
test("createrepcompletemessage handler accepts encoded hash variant", async () => {
const loadOrdinaryRepCompletion = (overrides = {}) => {
const calls = { hash: [], queue: [] };
const payload = { messageId: "ordinary-message" };
const mod = loadModule("pages/api/file/createrepcompletemessage_api.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
hashAPIPath: (queryPath) =>
queryPath.includes("repid=rep%201")
? "&hash=expected"
: "&hash=other",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
getSession: async () => ({ user: { id: "user-container" } }),
hashAPIPath: (queryPath) => {
calls.hash.push(queryPath);
return "&hash=expected";
},
createRepCompleteMessage: async (...args) => {
calls.queue.push(args);
return payload;
},
consoleLogger: () => {},
createRepCompleteMessage: async () => ({ ok: true })
...overrides
});
const req = {
query: {
container: "c1",
tempcaseref: "TMP/1",
repid: "rep 1",
hash: "expected"
}
};
const res = createRes();
await mod.default.handler(req, res);
return { mod, calls, payload };
};
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
ok: true
});
const ordinaryRepCompletionRequest = (overrides = {}) => ({
method: "GET",
headers: {},
query: {
container: "user-container",
tempcaseref: "CAS-12345-A1B2C3",
repid: "2026-03-18-10:20:30_-_APP_-_Statement_-_Jones_Rob",
hash: "expected"
},
...overrides
});
test("createrepcompletemessage handler dependency failure returns CREATE_REP_COMPLETE_MESSAGE_FAILED", async () => {
const mod = loadModule("pages/api/file/createrepcompletemessage_api.js", {
nextConnect: createNextConnectMock(),
middleware: () => {},
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
createRepCompleteMessage: async () => {
const assertNoCompletionWork = (calls, { noHash = false } = {}) => {
assert.strictEqual(calls.queue.length, 0);
if (noHash) assert.strictEqual(calls.hash.length, 0);
};
test("createrepcompletemessage rejects unsupported methods before authorization work", async () => {
const loaded = loadOrdinaryRepCompletion();
const res = createRes();
await loaded.mod.default(
ordinaryRepCompletionRequest({ method: "POST" }),
res
);
assert.strictEqual(res.state.statusCode, 405);
assert.strictEqual(res.state.headers.allow, "GET");
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
assertNoCompletionWork(loaded.calls, { noHash: true });
});
for (const inputCase of [
{ name: "missing container", query: { container: undefined } },
{ name: "invalid container type", query: { container: ["user-container"] } },
{ name: "malformed container", query: { container: "Invalid_Container" } },
{ name: "missing case reference", query: { tempcaseref: undefined } },
{ name: "unsafe case-reference path separator", query: { tempcaseref: "CAS/123" } },
{ name: "unsafe case-reference traversal segment", query: { tempcaseref: ".." } },
{ name: "missing representation identifier", query: { repid: undefined } },
{ name: "unsafe representation path separator", query: { repid: "rep\\file" } },
{ name: "unsafe representation control character", query: { repid: "rep\u0000file" } }
]) {
test(`createrepcompletemessage rejects ${inputCase.name} before authorization work`, async () => {
const loaded = loadOrdinaryRepCompletion();
const request = ordinaryRepCompletionRequest();
const res = createRes();
request.query = { ...request.query, ...inputCase.query };
Object.keys(request.query).forEach((key) => {
if (typeof request.query[key] === "undefined") delete request.query[key];
});
await loaded.mod.default(request, res);
assert.strictEqual(res.state.statusCode, 400);
assertNoCompletionWork(loaded.calls, { noHash: true });
});
}
for (const sessionCase of [
{ name: "missing NextAuth 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(`createrepcompletemessage rejects ${sessionCase.name} before HMAC processing`, async () => {
const loaded = loadOrdinaryRepCompletion({
getSession: async () => sessionCase.session
});
const res = createRes();
await loaded.mod.default(ordinaryRepCompletionRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assertNoCompletionWork(loaded.calls, { noHash: true });
});
}
test("createrepcompletemessage rejects cross-container ownership before HMAC processing", async () => {
const loaded = loadOrdinaryRepCompletion({
getSession: async () => ({ user: { id: "different-container" } })
});
const res = createRes();
await loaded.mod.default(ordinaryRepCompletionRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assertNoCompletionWork(loaded.calls, { noHash: true });
});
test("administrative cookie alone cannot authorize ordinary representation completion", async () => {
const loaded = loadOrdinaryRepCompletion({ getSession: async () => null });
const res = createRes();
await loaded.mod.default(
ordinaryRepCompletionRequest({
headers: { cookie: "pedw_admin_session=valid-admin-token" }
}),
res
);
assert.strictEqual(res.state.statusCode, 403);
assertNoCompletionWork(loaded.calls, { noHash: true });
});
test("previously valid HMAC cannot bypass a missing current ordinary session", async () => {
const loaded = loadOrdinaryRepCompletion({ getSession: async () => null });
const res = createRes();
await loaded.mod.default(ordinaryRepCompletionRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assertNoCompletionWork(loaded.calls, { noHash: true });
});
for (const hashCase of [
{ name: "missing HMAC", hash: undefined },
{ name: "invalid HMAC", hash: "wrong" }
]) {
test(`createrepcompletemessage rejects ${hashCase.name} without queue work`, async () => {
const loaded = loadOrdinaryRepCompletion();
const request = ordinaryRepCompletionRequest();
const res = createRes();
if (typeof hashCase.hash === "undefined") delete request.query.hash;
else request.query.hash = hashCase.hash;
await loaded.mod.default(request, res);
assert.strictEqual(res.state.statusCode, 400);
assertNoCompletionWork(loaded.calls);
});
}
test("createrepcompletemessage accepts punctuation and preserves exact identifiers and response", async () => {
const loaded = loadOrdinaryRepCompletion();
const request = ordinaryRepCompletionRequest();
const res = createRes();
await loaded.mod.default(request, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(loaded.calls.queue, [
[
request.query.container,
request.query.tempcaseref,
request.query.repid
]
]);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(res.state.jsonBody)),
loaded.payload
);
});
test("createrepcompletemessage preserves encoded HMAC compatibility", async () => {
const loaded = loadOrdinaryRepCompletion({
hashAPIPath: (queryPath) => {
loaded.calls.hash.push(queryPath);
return queryPath.includes("repid=rep%20name%20%26%20notes.json")
? "&hash=expected"
: "&hash=other";
}
});
const request = ordinaryRepCompletionRequest();
request.query.repid = "rep name & notes.json";
const res = createRes();
await loaded.mod.default(request, res);
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(loaded.calls.queue.length, 1);
assert.strictEqual(loaded.calls.hash.length, 2);
});
test("createrepcompletemessage dependency failure returns CREATE_REP_COMPLETE_MESSAGE_FAILED", async () => {
const loaded = loadOrdinaryRepCompletion({
createRepCompleteMessage: async (...args) => {
loaded.calls.queue.push(args);
throw new Error("rep message failed");
}
});
const req = {
query: {
container: "c1",
tempcaseref: "TMP1",
repid: "rep1",
hash: "expected"
}
};
const res = createRes();
await mod.default.handler(req, res);
await loaded.mod.default(ordinaryRepCompletionRequest(), res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(
res.state.jsonBody.error.code,
"CREATE_REP_COMPLETE_MESSAGE_FAILED"
);
assert.strictEqual(loaded.calls.queue.length, 1);
});
const loadAdminRepCompletion = (overrides = {}) => {
const calls = { noStore: 0, readSession: 0, validate: 0, origin: 0, queue: [] };
const payload = { messageId: "admin-message" };
const mod = loadModule("pages/api/admin/createrepcompletemessage.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
setAdminAuthNoStore: () => {
calls.noStore += 1;
},
readAdminSessionCookie: () => {
calls.readSession += 1;
return "admin-token";
},
validateAdminSession: () => {
calls.validate += 1;
return true;
},
hasTrustedSameOrigin: () => {
calls.origin += 1;
return true;
},
createRepCompleteMessage: async (...args) => {
calls.queue.push(args);
return payload;
},
consoleLogger: () => {},
...overrides
});
return { mod, calls, payload };
};
const adminRepCompletionRequest = (overrides = {}) => ({
method: "POST",
headers: {
origin: "https://portal.example.test",
cookie: "pedw_admin_session=admin-token"
},
body: {
container: "selected-user-container",
tempcaseref: "CAS-12345-A1B2C3",
repid: "2026-03-18-10:20:30_-_IP_-_Statement_-_Smith_Alex_rep.json"
},
...overrides
});
test("admin representation completion rejects unsupported methods before authentication", async () => {
const loaded = loadAdminRepCompletion();
const res = createRes();
await loaded.mod.default(adminRepCompletionRequest({ method: "GET" }), res);
assert.strictEqual(res.state.statusCode, 405);
assert.strictEqual(res.state.headers.allow, "POST");
assert.strictEqual(loaded.calls.noStore, 1);
assert.strictEqual(loaded.calls.readSession, 0);
assertNoCompletionWork(loaded.calls);
});
for (const sessionCase of [
{ name: "missing administrative session", token: null },
{ name: "invalid administrative session", token: "invalid-token" },
{ name: "expired administrative session", token: "expired-token" }
]) {
test(`admin representation completion rejects ${sessionCase.name}`, async () => {
const loaded = loadAdminRepCompletion({
readAdminSessionCookie: () => sessionCase.token,
validateAdminSession: () => false
});
const res = createRes();
await loaded.mod.default(adminRepCompletionRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(loaded.calls.origin, 0);
assertNoCompletionWork(loaded.calls);
});
}
for (const originCase of [
{ name: "missing Origin", headers: { cookie: "pedw_admin_session=admin-token" } },
{
name: "cross-origin request",
headers: {
origin: "https://attacker.example.test",
cookie: "pedw_admin_session=admin-token"
}
}
]) {
test(`admin representation completion rejects ${originCase.name}`, async () => {
const loaded = loadAdminRepCompletion({ hasTrustedSameOrigin: () => false });
const res = createRes();
await loaded.mod.default(
adminRepCompletionRequest({ headers: originCase.headers }),
res
);
assert.strictEqual(res.state.statusCode, 403);
assertNoCompletionWork(loaded.calls);
});
}
for (const inputCase of [
{ name: "missing container", body: { container: undefined } },
{ name: "malformed container", body: { container: "Invalid_Container" } },
{ name: "missing case reference", body: { tempcaseref: undefined } },
{ name: "unsafe case-reference segment", body: { tempcaseref: "CAS/123" } },
{ name: "missing representation identifier", body: { repid: undefined } },
{ name: "unsafe representation segment", body: { repid: "../rep.json" } }
]) {
test(`admin representation completion rejects ${inputCase.name} without queue work`, async () => {
const loaded = loadAdminRepCompletion();
const request = adminRepCompletionRequest();
const res = createRes();
request.body = { ...request.body, ...inputCase.body };
Object.keys(request.body).forEach((key) => {
if (typeof request.body[key] === "undefined") delete request.body[key];
});
await loaded.mod.default(request, res);
assert.strictEqual(res.state.statusCode, 400);
assertNoCompletionWork(loaded.calls);
});
}
test("ordinary NextAuth session alone cannot authorize admin representation completion", async () => {
const loaded = loadAdminRepCompletion({
readAdminSessionCookie: () => null,
validateAdminSession: () => false,
getSession: async () => ({ user: { id: "selected-user-container" } })
});
const res = createRes();
await loaded.mod.default(adminRepCompletionRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assertNoCompletionWork(loaded.calls);
});
test("admin representation completion preserves cross-container identifiers and response", async () => {
const loaded = loadAdminRepCompletion();
const request = adminRepCompletionRequest();
const res = createRes();
await loaded.mod.default(request, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(loaded.calls.queue, [
[request.body.container, request.body.tempcaseref, request.body.repid]
]);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(res.state.jsonBody)),
loaded.payload
);
});
test("admin representation completion accepts legitimate filename punctuation", async () => {
const loaded = loadAdminRepCompletion();
const request = adminRepCompletionRequest();
request.body.repid = "rep name_-_notes.v2 (final)!.json";
const res = createRes();
await loaded.mod.default(request, res);
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(loaded.calls.queue.length, 1);
});
test("getbloblistproxy success returns proxied payload", async () => {
@@ -1543,6 +1866,38 @@ test("admin storage refresh uses dedicated POST proxy while ordinary callers rem
assert.match(viewAllSource, /getRepsFromBlobProxy\(containerID\)/);
});
test("admin storage completion uses dedicated POST route while ordinary completion remains unchanged", () => {
const rootDir = path.resolve(__dirname, "..", "..");
const adminSource = fs.readFileSync(
path.join(rootDir, "components/admin/tabs/storage.js"),
"utf8"
);
const ordinarySource = fs.readFileSync(
path.join(
rootDir,
"components/case/representation/representationComplete.js"
),
"utf8"
);
assert.match(
adminSource,
/fetch\(\s*"\/api\/admin\/createrepcompletemessage",\s*\{\s*method:\s*"POST"/
);
assert.match(adminSource, /container:\s*c\.container/);
assert.match(
adminSource,
/tempcaseref:\s*folder\.repJsonBlob\.name\.split\(\s*"\/"\s*\)\[0\]/
);
assert.match(
adminSource,
/repid:\s*folder\.repJsonBlob\.name\.split\(\s*"\/"\s*\)\[1\]/
);
assert.match(adminSource, /if \(!response\.ok\)/);
assert.doesNotMatch(adminSource, /sendRepCompleteMessage/);
assert.match(ordinarySource, /sendRepCompleteMessage\(/);
});
test("getawaitingsubmissionfromblobproxy dependency failure returns GET_AWAITING_SUBMISSION_PROXY_FAILED", async () => {
const mod = loadModule(
"pages/api/file/getawaitingsubmissionfromblobproxy.js",