ITHC: protect administrative representation unlock endpoint

This commit is contained in:
2026-10-26 15:27:10 +00:00
parent 0daeb783b9
commit 37aeb03509
5 changed files with 378 additions and 98 deletions
+1 -1
View File
@@ -186,7 +186,7 @@ export default function StorageTab({ data, repCompleteCount }) {
onClick={async () => {
const response =
await fetch(
"/api/file/editRepJson",
"/api/admin/editRepJson",
{
method: "POST",
headers:
+9 -5
View File
@@ -70,16 +70,14 @@ independent administrative-session authorization. A later authorised stage
must enforce the administrative session on privileged API routes and account
for both browser calls and the existing server-side bootstrap callers.
Storage download, representation editing, and queue actions retain their
existing authorization behaviour. The other administrative API and storage
limitations described in this section remain open.
Storage download and queue actions retain their existing authorization
behaviour. The other administrative API and storage limitations described in
this section remain open.
Directly observed storage routes include:
- `/api/file/getrepsblobproxy`, which forwards a caller-supplied container using
server relay credentials and path signing.
- `/api/file/editRepJson`, which performs a direct blob mutation and has no
visible administrative-session or HMAC check.
- `/api/file/downloadblob` and `/api/file/createrepcompletemessage_api`, which
enforce existing path-HMAC checks but not administrative authorization.
@@ -97,6 +95,12 @@ 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 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.
+126
View File
@@ -0,0 +1,126 @@
import { DefaultAzureCredential } from "@azure/identity";
import { BlobServiceClient } from "@azure/storage-blob";
import { consoleLogger } from "../../../actions/core/logger";
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 isValidBlobName = (value) =>
typeof value === "string" &&
value.length > 0 &&
value.length <= 1024 &&
value === value.trim() &&
!/[\\\u0000-\u001f\u007f]/.test(value) &&
value.split("/").every((segment) => segment && segment !== "." && segment !== "..");
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, blobName } = req.body ?? {};
if (!isValidContainer(container) || !isValidBlobName(blobName)) {
return respondError(res, {
status: 400,
code: "INVALID_REQUEST",
message: "Invalid request"
});
}
try {
const accountName = process.env.AZURE_STORAGE_ACCOUNT_NAME;
const creds = new DefaultAzureCredential();
const blobServiceClient = new BlobServiceClient(
`https://${accountName}.blob.core.windows.net`,
creds
);
const containerClient = blobServiceClient.getContainerClient(container);
const blockBlobClient = containerClient.getBlockBlobClient(blobName);
const downloadResponse = await blockBlobClient.download(0);
const downloaded = await streamToString(
downloadResponse.readableStreamBody
);
const json = JSON.parse(downloaded);
if ("repComplete" in json) {
delete json.repComplete;
}
const tags = {
containerid: json.containerID,
caseID: json.ticketnumber,
blobType: "Representation"
};
const updatedContent = JSON.stringify(json, null, 2);
await blockBlobClient.upload(
updatedContent,
Buffer.byteLength(updatedContent),
{
blobHTTPHeaders: { blobContentType: "application/json" }
}
);
await blockBlobClient.setTags(tags);
await blockBlobClient.setMetadata(tags);
return respondSuccess(res, {
message: "repComplete removed successfully"
});
} catch (error) {
consoleLogger(error);
return respondError(res, {
status: 500,
code: "EDIT_REP_JSON_FAILED",
message: "Failed to edit rep.json"
});
}
}
async function streamToString(readableStream) {
return new Promise((resolve, reject) => {
const chunks = [];
readableStream.on("data", (data) => {
chunks.push(data.toString());
});
readableStream.on("end", () => {
resolve(chunks.join(""));
});
readableStream.on("error", reject);
});
}
+5 -92
View File
@@ -1,96 +1,9 @@
import { DefaultAzureCredential } from "@azure/identity";
import { BlobServiceClient } from "@azure/storage-blob";
import { consoleLogger } from "../../../actions/core/logger";
import { respondError, respondSuccess } from "../middleware/apiResponse";
import { respondError } from "../middleware/apiResponse";
export default async function handler(req, res) {
if (req.method !== "POST") {
return respondError(res, {
status: 405,
code: "METHOD_NOT_ALLOWED",
message: "Method not allowed"
});
}
const { container, blobName } = req.body;
if (!container || !blobName) {
return respondError(res, {
status: 400,
code: "MISSING_REQUIRED_BODY",
message: "Missing container or blobName"
});
}
try {
const accountName = process.env.AZURE_STORAGE_ACCOUNT_NAME;
const creds = new DefaultAzureCredential();
const blobServiceClient = new BlobServiceClient(
`https://${accountName}.blob.core.windows.net`,
creds
);
const containerClient = blobServiceClient.getContainerClient(container);
const blockBlobClient = containerClient.getBlockBlobClient(blobName);
// Download blob content
const downloadResponse = await blockBlobClient.download(0);
const downloaded = await streamToString(
downloadResponse.readableStreamBody
);
// Parse JSON
const json = JSON.parse(downloaded);
// Remove repComplete element if it exists
if ("repComplete" in json) {
delete json.repComplete;
}
const tags = {
containerid: json.containerID,
caseID: json.ticketnumber,
blobType: "Representation"
};
// Convert back to string
const updatedContent = JSON.stringify(json, null, 2);
// Upload updated content (overwrite)
await blockBlobClient.upload(
updatedContent,
Buffer.byteLength(updatedContent),
{
blobHTTPHeaders: { blobContentType: "application/json" }
}
);
const withTags = await blockBlobClient.setTags(tags);
const withMeta = await blockBlobClient.setMetadata(tags);
return respondSuccess(res, {
message: "repComplete removed successfully"
});
} catch (error) {
consoleLogger(error);
return respondError(res, {
status: 500,
code: "EDIT_REP_JSON_FAILED",
message: "Failed to edit rep.json"
});
}
}
// Helper function to read stream to string
async function streamToString(readableStream) {
return new Promise((resolve, reject) => {
const chunks = [];
readableStream.on("data", (data) => {
chunks.push(data.toString());
});
readableStream.on("end", () => {
resolve(chunks.join(""));
});
readableStream.on("error", reject);
return respondError(res, {
status: 410,
code: "ENDPOINT_RETIRED",
message: "Endpoint retired"
});
}
@@ -573,6 +573,243 @@ test("admin storage caller uses dedicated POST deletion while ordinary caller re
);
});
const loadAdminRepJsonEdit = (overrides = {}) => {
let azureClientConstructions = 0;
const storageCalls = [];
const sourceJson = {
containerID: "ck-user-container",
ticketnumber: "CASE-1",
repComplete: true,
representationType: "Support",
nested: { retained: true }
};
const uploaded = {};
class DefaultAzureCredentialMock {}
class BlobServiceClientMock {
constructor(url, credential) {
azureClientConstructions += 1;
storageCalls.push(["construct", url, credential]);
}
getContainerClient(container) {
storageCalls.push(["container", container]);
return {
getBlockBlobClient(blobName) {
storageCalls.push(["blob", blobName]);
return {
async download(offset) {
storageCalls.push(["download", offset]);
const { Readable } = require("stream");
return {
readableStreamBody: Readable.from([
JSON.stringify(sourceJson)
])
};
},
async upload(content, length, options) {
uploaded.content = content;
uploaded.length = length;
uploaded.options = options;
storageCalls.push(["upload"]);
},
async setTags(tags) {
uploaded.tags = tags;
storageCalls.push(["tags"]);
},
async setMetadata(metadata) {
uploaded.metadata = metadata;
storageCalls.push(["metadata"]);
}
};
}
};
}
}
const mod = loadModule("pages/api/admin/editRepJson.js", {
Buffer,
DefaultAzureCredential: DefaultAzureCredentialMock,
BlobServiceClient: BlobServiceClientMock,
consoleLogger: () => {},
readAdminSessionCookie: () => "admin-token",
validateAdminSession: () => true,
hasTrustedSameOrigin: () => true,
setAdminAuthNoStore: () => {},
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
...overrides
});
return {
mod,
getAzureClientConstructions: () => azureClientConstructions,
storageCalls,
sourceJson,
uploaded
};
};
const validAdminRepJsonEditRequest = () => ({
method: "POST",
headers: { origin: "https://portal.example.test" },
body: {
container: "ck-user-container",
blobName: "CASE-1/representation-1/representation_rep.json"
}
});
test("admin representation JSON edit forbids a missing administrative session before Azure access", async () => {
const loaded = loadAdminRepJsonEdit({
readAdminSessionCookie: () => null,
validateAdminSession: () => false
});
const res = createRes();
await loaded.mod.default(validAdminRepJsonEditRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
assert.strictEqual(loaded.getAzureClientConstructions(), 0);
});
test("admin representation JSON edit forbids an invalid administrative session before Azure access", async () => {
const loaded = loadAdminRepJsonEdit({ validateAdminSession: () => false });
const res = createRes();
await loaded.mod.default(validAdminRepJsonEditRequest(), res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(loaded.getAzureClientConstructions(), 0);
});
test("admin representation JSON edit forbids cross-origin requests before Azure access", async () => {
const loaded = loadAdminRepJsonEdit({ hasTrustedSameOrigin: () => false });
const req = validAdminRepJsonEditRequest();
req.headers.origin = "https://attacker.example";
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(loaded.getAzureClientConstructions(), 0);
});
test("admin representation JSON edit rejects unsupported methods before Azure access", async () => {
const loaded = loadAdminRepJsonEdit();
const req = validAdminRepJsonEditRequest();
req.method = "GET";
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 405);
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
assert.strictEqual(res.state.headers.allow, "POST");
assert.strictEqual(loaded.getAzureClientConstructions(), 0);
});
test("admin representation JSON edit rejects malformed identifiers before Azure access", async () => {
for (const malformedBody of [
{ container: "UPPERCASE" },
{ container: "ab" },
{ blobName: "../representation_rep.json" },
{ blobName: "CASE-1//representation_rep.json" },
{ blobName: "CASE-1\\representation_rep.json" },
{ blobName: " representation_rep.json" }
]) {
const loaded = loadAdminRepJsonEdit();
const req = validAdminRepJsonEditRequest();
req.body = { ...req.body, ...malformedBody };
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
assert.strictEqual(loaded.getAzureClientConstructions(), 0);
}
});
test("valid administrative representation JSON edit preserves the existing mutation contract", async () => {
const loaded = loadAdminRepJsonEdit();
const res = createRes();
await loaded.mod.default(validAdminRepJsonEditRequest(), res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
message: "repComplete removed successfully"
});
assert.strictEqual(loaded.getAzureClientConstructions(), 1);
assert.deepStrictEqual(loaded.storageCalls.slice(1, 4), [
["container", "ck-user-container"],
["blob", "CASE-1/representation-1/representation_rep.json"],
["download", 0]
]);
const updatedJson = JSON.parse(loaded.uploaded.content);
assert.strictEqual(Object.hasOwn(updatedJson, "repComplete"), false);
assert.strictEqual(updatedJson.containerID, loaded.sourceJson.containerID);
assert.strictEqual(updatedJson.ticketnumber, loaded.sourceJson.ticketnumber);
assert.strictEqual(
updatedJson.representationType,
loaded.sourceJson.representationType
);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(updatedJson.nested)),
loaded.sourceJson.nested
);
assert.strictEqual(
loaded.uploaded.length,
Buffer.byteLength(loaded.uploaded.content)
);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(loaded.uploaded.options)),
{ blobHTTPHeaders: { blobContentType: "application/json" } }
);
assert.deepStrictEqual(JSON.parse(JSON.stringify(loaded.uploaded.tags)), {
containerid: "ck-user-container",
caseID: "CASE-1",
blobType: "Representation"
});
assert.deepStrictEqual(
JSON.parse(JSON.stringify(loaded.uploaded.metadata)),
JSON.parse(JSON.stringify(loaded.uploaded.tags))
);
});
test("retired file representation JSON endpoint cannot mutate storage", async () => {
let storageCalls = 0;
const mod = loadModule("pages/api/file/editRepJson.js", {
BlobServiceClient: class {
constructor() {
storageCalls += 1;
}
},
respondError: respondErrorMock
});
const res = createRes();
await mod.default(validAdminRepJsonEditRequest(), res);
assert.strictEqual(res.state.statusCode, 410);
assert.strictEqual(res.state.jsonBody.error.code, "ENDPOINT_RETIRED");
assert.strictEqual(storageCalls, 0);
});
test("admin storage caller uses the protected representation JSON edit endpoint", () => {
const rootDir = path.resolve(__dirname, "..", "..");
const adminSource = fs.readFileSync(
path.join(rootDir, "components/admin/tabs/storage.js"),
"utf8"
);
assert.match(adminSource, /fetch\(\s*"\/api\/admin\/editRepJson"/);
assert.doesNotMatch(adminSource, /fetch\(\s*"\/api\/file\/editRepJson"/);
});
test("deleteawaitingsubmissionfromblob accepts legacy deleteblob hash variant", async () => {
let deletedPath = null;
const mod = loadModule(