ITHC: enforce administrative case deletion authorization

This commit is contained in:
2026-10-27 05:40:50 +00:00
parent 8b607d8134
commit 5e98d6e8c2
4 changed files with 333 additions and 20 deletions
+44 -13
View File
@@ -2,7 +2,6 @@
import { useState } from "react";
import Link from "next/link";
import { bytesToSize } from "../../../components/utils";
import { deleteAwaitingSubmissionsFromBlob } from "../../../actions/services/documentService";
import { consoleLogger } from "../../../actions/core/logger";
import { formatBlobDates } from "../utils/adminHelper";
@@ -136,20 +135,52 @@ export default function StorageTab({ data, repCompleteCount }) {
{folder.hasTmpFile && (
<>
<button
onClick={() => {
confirm(
"Do you want to remove this unsubmitted appeal " +
folder.folderPath +
"?\n"
) &&
(deleteCaseItem(
c.container,
folder.folderPath,
setData
),
onClick={async () => {
if (
!confirm(
"Do you want to remove this unsubmitted appeal " +
folder.folderPath +
"?\n"
)
) {
return;
}
try {
const response =
await fetch(
"/api/admin/deleteblobcase",
{
method: "POST",
headers:
{
"Content-Type":
"application/json"
},
body: JSON.stringify(
{
container:
c.container,
casefolderID:
folder.folderPath
}
)
}
);
if (!response.ok) {
throw new Error(
"Unable to delete case"
);
}
alert(
"Temp case Deleted!"
));
);
window.location.reload();
} catch (error) {
consoleLogger(error);
}
}}
>
Delete this TMP appeal
+17 -7
View File
@@ -95,20 +95,30 @@ the administrative session and requires a trusted same-origin request. It also
validates the requested container, case-folder segment, and representation-file
segment before performing deletion.
Administrative cross-container case deletion uses the dedicated
`POST /api/admin/deleteblobcase` route. That endpoint independently validates
the administrative session, requires a trusted same-origin request, and
validates the requested container and case-folder segment before performing
deletion. It invokes the same existing `deleteBlobCase(container,
casefolderID)` operation as the ordinary endpoint. That operation lists and
deletes blobs in the selected container whose names begin with the supplied
case-folder prefix; it does not delete the Azure container or broaden deletion
to unrelated containers.
Administrative representation JSON unlocking uses the dedicated
`POST /api/admin/editRepJson` route. That endpoint independently validates the
administrative session, requires a trusted same-origin request, and validates
the requested container and blob path before removing `repComplete`. The former
`/api/file/editRepJson` route is retired and no longer performs storage access.
Administrative case deletion was not investigated or remediated as part of
Stage 3 and remains a separate, unverified security boundary. The other
administrative API and storage limitations documented above also remain open.
Automated contract verification for the implemented deletion controls has
passed. Live Azure behaviour, browser-level behaviour, and deployment
configuration have not been verified by that automated coverage. These changes
do not establish closure of the overall ITHC finding.
passed, including ordinary ownership/HMAC rejection, administrative method,
session, origin and input rejection, zero deletion calls on rejected requests,
and exact cross-container helper arguments on success. Live Azure behaviour,
browser-level behaviour, and deployment configuration have not been verified
by that automated coverage. The other administrative API and storage
limitations documented above remain open, and these changes do not establish
closure of the overall ITHC finding.
## Deployment requirements before production use
+71
View File
@@ -0,0 +1,71 @@
import { deleteBlobCase } from "../../../actions/azurestorage";
import {
hasTrustedSameOrigin,
readAdminSessionCookie,
setAdminAuthNoStore,
validateAdminSession
} from "../../../lib/admin/adminAuth";
import { respondError, respondSuccess } from "../middleware/apiResponse";
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);
const isValidPathSegment = (value) =>
typeof value === "string" &&
value.length > 0 &&
value.length <= 255 &&
value === value.trim() &&
value !== "." &&
value !== ".." &&
!/[\\/\u0000-\u001f\u007f]/.test(value);
export default async function handler(req, res) {
setAdminAuthNoStore(res);
res.setHeader("Allow", "POST");
if (req.method !== "POST") {
return respondError(res, {
status: 405,
code: "METHOD_NOT_ALLOWED",
message: "Method not allowed"
});
}
const adminSession = readAdminSessionCookie(req);
if (
!validateAdminSession(adminSession) ||
!hasTrustedSameOrigin(req)
) {
return forbidden(res);
}
const { container, casefolderID } = req.body ?? {};
if (!isValidContainer(container) || !isValidPathSegment(casefolderID)) {
return respondError(res, {
status: 400,
code: "INVALID_REQUEST",
message: "Invalid request"
});
}
try {
const data = await deleteBlobCase(container, casefolderID);
return respondSuccess(res, { data });
} catch (_error) {
return respondError(res, {
status: 400,
code: "DELETE_BLOB_CASE_FAILED",
message: "Unable to delete blob case"
});
}
}
@@ -573,6 +573,207 @@ test("admin storage caller uses dedicated POST deletion while ordinary caller re
);
});
const loadAdminCaseDeletion = (overrides = {}) => {
const calls = {
noStore: 0,
readSession: 0,
validateSession: 0,
origin: 0,
deletion: []
};
const payload = { deleted: "TMP-CASE-1" };
const mod = loadModule("pages/api/admin/deleteblobcase.js", {
deleteBlobCase: async (...args) => {
calls.deletion.push(args);
return payload;
},
readAdminSessionCookie: () => {
calls.readSession += 1;
return "admin-token";
},
validateAdminSession: () => {
calls.validateSession += 1;
return true;
},
hasTrustedSameOrigin: () => {
calls.origin += 1;
return true;
},
setAdminAuthNoStore: () => {
calls.noStore += 1;
},
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
...overrides
});
return { mod, calls, payload };
};
const validAdminCaseDeletionRequest = (overrides = {}) => ({
method: "POST",
headers: {
origin: "https://portal.example.test",
cookie: "pedw_admin_session=admin-token"
},
body: {
container: "selected-user-container",
casefolderID: "TMP-CASE-1"
},
...overrides
});
for (const method of ["GET", "DELETE"]) {
test(`admin case deletion rejects ${method} with 405 before authorization or deletion`, async () => {
const loaded = loadAdminCaseDeletion();
const res = createRes();
await loaded.mod.default(
validAdminCaseDeletionRequest({ method }),
res
);
assert.strictEqual(res.state.statusCode, 405);
assert.strictEqual(res.state.headers.allow, "POST");
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
assert.strictEqual(loaded.calls.noStore, 1);
assert.strictEqual(loaded.calls.readSession, 0);
assert.strictEqual(loaded.calls.deletion.length, 0);
});
}
for (const sessionCase of [
{ name: "missing", token: null },
{ name: "invalid", token: "invalid-token" },
{ name: "expired", token: "expired-token" }
]) {
test(`admin case deletion rejects ${sessionCase.name} administrative session before deletion`, async () => {
const loaded = loadAdminCaseDeletion({
readAdminSessionCookie: () => sessionCase.token,
validateAdminSession: () => false
});
const res = createRes();
await loaded.mod.default(validAdminCaseDeletionRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assert.strictEqual(loaded.calls.origin, 0);
assert.strictEqual(loaded.calls.deletion.length, 0);
});
}
for (const originCase of [
{ name: "missing", headers: { cookie: "pedw_admin_session=admin-token" } },
{
name: "cross-origin",
headers: {
origin: "https://attacker.example.test",
cookie: "pedw_admin_session=admin-token"
}
}
]) {
test(`admin case deletion rejects ${originCase.name} Origin before deletion`, async () => {
const loaded = loadAdminCaseDeletion({
hasTrustedSameOrigin: () => false
});
const res = createRes();
await loaded.mod.default(
validAdminCaseDeletionRequest({ headers: originCase.headers }),
res
);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(loaded.calls.deletion.length, 0);
});
}
for (const inputCase of [
{ name: "missing container", body: { container: undefined } },
{ name: "malformed container", body: { container: "Invalid_Container" } },
{ name: "missing case identifier", body: { casefolderID: undefined } },
{ name: "case identifier path separator", body: { casefolderID: "TMP/CASE-1" } },
{ name: "case identifier traversal segment", body: { casefolderID: ".." } },
{ name: "case identifier control character", body: { casefolderID: "TMP\u0000CASE" } }
]) {
test(`admin case deletion rejects ${inputCase.name} without deletion`, async () => {
const loaded = loadAdminCaseDeletion();
const request = validAdminCaseDeletionRequest();
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);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
assert.strictEqual(loaded.calls.deletion.length, 0);
});
}
test("valid administrator can delete a cross-container case prefix with the existing helper", async () => {
const loaded = loadAdminCaseDeletion();
const request = validAdminCaseDeletionRequest();
const res = createRes();
await loaded.mod.default(request, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(loaded.calls.deletion, [
[request.body.container, request.body.casefolderID]
]);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(res.state.jsonBody)),
{ data: loaded.payload }
);
});
test("admin case deletion dependency failure returns DELETE_BLOB_CASE_FAILED", async () => {
const loaded = loadAdminCaseDeletion({
deleteBlobCase: async (...args) => {
loaded.calls.deletion.push(args);
throw new Error("delete case failed");
}
});
const res = createRes();
await loaded.mod.default(validAdminCaseDeletionRequest(), res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(
res.state.jsonBody.error.code,
"DELETE_BLOB_CASE_FAILED"
);
assert.strictEqual(loaded.calls.deletion.length, 1);
});
test("admin storage case deletion uses dedicated POST route while ordinary callers remain signed GET", () => {
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, "actions/services/documentDirectService.js"),
"utf8"
);
assert.match(adminSource, /fetch\(\s*"\/api\/admin\/deleteblobcase"/);
assert.match(
adminSource,
/"\/api\/admin\/deleteblobcase"[\s\S]*method:\s*"POST"[\s\S]*container:\s*c\.container[\s\S]*casefolderID:\s*folder\.folderPath/
);
assert.match(adminSource, /if \(!response\.ok\)/);
assert.doesNotMatch(adminSource, /deleteAwaitingSubmissionsFromBlob/);
assert.match(
ordinarySource,
/buildFileQuery\("\/api\/file\/deleteblobcase"[\s\S]*getSignedFileJson\(queryUrl\)/
);
});
const loadAdminRepJsonEdit = (overrides = {}) => {
let azureClientConstructions = 0;
const storageCalls = [];