ITHC: protect administrative representation unlock endpoint
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user