diff --git a/components/admin/tabs/storage.js b/components/admin/tabs/storage.js index 30ca3412..eadd487f 100644 --- a/components/admin/tabs/storage.js +++ b/components/admin/tabs/storage.js @@ -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: diff --git a/docs/administrative-authentication.md b/docs/administrative-authentication.md index bc212df9..44a941c0 100644 --- a/docs/administrative-authentication.md +++ b/docs/administrative-authentication.md @@ -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. diff --git a/pages/api/admin/editRepJson.js b/pages/api/admin/editRepJson.js new file mode 100644 index 00000000..4306cd81 --- /dev/null +++ b/pages/api/admin/editRepJson.js @@ -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); + }); +} \ No newline at end of file diff --git a/pages/api/file/editRepJson.js b/pages/api/file/editRepJson.js index e301ade4..ad7b3dd1 100644 --- a/pages/api/file/editRepJson.js +++ b/pages/api/file/editRepJson.js @@ -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" }); } diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index 1eeab609..1d40d659 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -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(