ITHC: enforce administrative case deletion authorization
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user