diff --git a/actions/azurestorage.js b/actions/azurestorage.js index c4fe4db9..cc10eed4 100644 --- a/actions/azurestorage.js +++ b/actions/azurestorage.js @@ -525,6 +525,31 @@ export const deleteBlob = async (containerName, blobName) => { return { "deleted": blobName }; }; +const isSafeDeletionPathSegment = (value) => + typeof value === "string" && + value.length > 0 && + value.length <= 255 && + value === value.trim() && + value !== "." && + value !== ".." && + !/[\\/\u0000-\u001f\u007f]/.test(value); + +const buildDeletionFolderPrefix = (resourcePath, expectedSegmentCount) => { + if (typeof resourcePath !== "string") { + throw new Error("Invalid deletion resource path"); + } + + const segments = resourcePath.split("/"); + if ( + segments.length !== expectedSegmentCount || + !segments.every(isSafeDeletionPathSegment) + ) { + throw new Error("Invalid deletion resource path"); + } + + return segments.join("/") + "/"; +}; + export const deleteBlobCase = async (containerName, blobName) => { const creds = new DefaultAzureCredential(); @@ -538,13 +563,15 @@ export const deleteBlobCase = async (containerName, blobName) => { //conLog"deleteBlobCase "); - console.log("blob to delete:", blobName); + const folderPrefix = buildDeletionFolderPrefix(blobName, 1); + + console.log("blob to delete:", folderPrefix); for await (const blob of containerClient.listBlobsFlat({ - prefix: blobName + prefix: folderPrefix })) { console.log(" ------ :", blob.name); - containerClient.deleteBlob(blob.name); + await containerClient.deleteBlob(blob.name); } //containerClient.deleteBlob(blobName); @@ -566,17 +593,19 @@ export const deleteBlobRep = async (containerName, blobName) => { const sasUrl = `${STORAGE_PATH}/${containerName}?${containerToken}`; const containerClient = new ContainerClient(sasUrl); - console.log("blob to delete:", blobName); + const folderPrefix = buildDeletionFolderPrefix(blobName, 2); + + console.log("blob to delete:", folderPrefix); for await (const blob of containerClient.listBlobsFlat({ - prefix: blobName + prefix: folderPrefix })) { console.log("------ :", blob.name); const blockBlobClient = containerClient.getBlockBlobClient(blob.name); console.log("=======:", await blockBlobClient.exists()); (await blockBlobClient.exists()) && - containerClient.deleteBlob(blob.name); + (await containerClient.deleteBlob(blob.name)); //containerClient.deleteIfExists(); //containerClient.deleteBlob(blob.name); diff --git a/actions/services/documentDirectService.js b/actions/services/documentDirectService.js index 77ae6479..db6dd0b4 100644 --- a/actions/services/documentDirectService.js +++ b/actions/services/documentDirectService.js @@ -118,7 +118,7 @@ export const deleteAwaitingSubmissionsFromBlob = ( casefolderID }); - return getSignedFileJson(queryUrl).catch((error) => { + return postSignedFileJson(queryUrl).catch((error) => { consoleLogger(error); }); }; @@ -134,7 +134,7 @@ export const deleteMyRepresentationsFromBlob = ( repfile }); - return getSignedFileJson(queryUrl).catch((error) => { + return postSignedFileJson(queryUrl).catch((error) => { consoleLogger(error); }); }; diff --git a/actions/services/notifyDirectService.js b/actions/services/notifyDirectService.js index b36b64de..8f1f70ea 100644 --- a/actions/services/notifyDirectService.js +++ b/actions/services/notifyDirectService.js @@ -25,3 +25,44 @@ export const sendEmail = async ( throw error; } }; + +export const sendContactUsConfirmation = async (formValues, language) => { + const mailData = { + "contactEmail": formValues.contactEmail, + "contactSubject": formValues.contactSubject, + "contactbody": formValues.contactbody, + "typeOfContact": formValues.typeOfContact, + "language": language + }; + + try { + return await requestJson({ + method: "post", + url: "/api/email/contactus-confirmation", + data: mailData + }); + } catch (error) { + consoleLogger(error); + throw error; + } +}; + +export const sendContactUsPETNotification = async (formValues) => { + const mailData = { + "contactEmail": formValues.contactEmail, + "contactSubject": formValues.contactSubject, + "contactbody": formValues.contactbody, + "typeOfContact": formValues.typeOfContact + }; + + try { + return await requestJson({ + method: "post", + url: "/api/email/contactus-pet-notification", + data: mailData + }); + } catch (error) { + consoleLogger(error); + throw error; + } +}; diff --git a/components/myportal/awaitingsubmissionfromblob.js b/components/myportal/awaitingsubmissionfromblob.js index ce22c602..93f8b5e2 100644 --- a/components/myportal/awaitingsubmissionfromblob.js +++ b/components/myportal/awaitingsubmissionfromblob.js @@ -70,7 +70,7 @@ const AwaitingSubmissionFromBlob = (props) => { const deleteCaseItem = (containerID, caseID) => { let cookies = parseCookies(); - console.log("sssss", containerID, caseID); + //console.log("sssss", containerID, caseID); deleteAwaitingSubmissionsFromBlob(containerID, caseID) .then((data) => { diff --git a/lib/security/contactus-email-links.js b/lib/security/contactus-email-links.js new file mode 100644 index 00000000..cc49475a --- /dev/null +++ b/lib/security/contactus-email-links.js @@ -0,0 +1,81 @@ +const LINK_REMOVED = "[web link removed]"; +const OBFUSCATING_SPACE = "[\\s\\u0000-\\u001f\\u007f\\u200b-\\u200d\\ufeff]*"; +const SCHEME = `h${OBFUSCATING_SPACE}t${OBFUSCATING_SPACE}t${OBFUSCATING_SPACE}p(?:${OBFUSCATING_SPACE}s)?${OBFUSCATING_SPACE}:${OBFUSCATING_SPACE}\\/${OBFUSCATING_SPACE}\\/`; +const DOMAIN = "(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}"; +const URL_TAIL = "[^\\s<>\\\"']*"; + +const explicitUrlPattern = () => new RegExp(`${SCHEME}${DOMAIN}${URL_TAIL}`, "gi"); +const wwwUrlPattern = () => new RegExp(`\\bwww\\.${DOMAIN}${URL_TAIL}`, "gi"); +const bareDomainWithPathPattern = () => + new RegExp(`(^|[^a-z0-9@._-])(${DOMAIN}\\/[^\\s<>\\\"']*)`, "gi"); + +const stripTrailingPunctuation = (value) => { + let destination = value; + let punctuation = ""; + + while (/[.,!;:]$/.test(destination)) { + punctuation = destination.slice(-1) + punctuation; + destination = destination.slice(0, -1); + } + + while (/[)\]}]$/.test(destination)) { + punctuation = destination.slice(-1) + punctuation; + destination = destination.slice(0, -1); + } + + return { destination, punctuation }; +}; + +const replaceUrlMatch = (value) => { + const { destination, punctuation } = stripTrailingPunctuation(value); + return destination ? LINK_REMOVED + punctuation : value; +}; + +const containsWebDestination = (value) => { + explicitUrlPattern().lastIndex = 0; + wwwUrlPattern().lastIndex = 0; + bareDomainWithPathPattern().lastIndex = 0; + + return ( + explicitUrlPattern().test(value) || + wwwUrlPattern().test(value) || + bareDomainWithPathPattern().test(value) + ); +}; + +export default function removeContactUsEmailLinks(message) { + if (typeof message !== "string") { + return message; + } + + let transformed = message; + + transformed = transformed.replace( + /]*>([\s\S]*?)<\/a\s*>/gi, + (match, linkText) => + containsWebDestination(match) + ? `${linkText}${linkText ? " " : ""}${LINK_REMOVED}` + : match + ); + + transformed = transformed.replace(/<[^>]+>/gi, (tag) => + containsWebDestination(tag) ? LINK_REMOVED : tag + ); + + transformed = transformed.replace( + /\[([^\]]*)\]\(([^)]*)\)/gi, + (match, linkText, destination) => + containsWebDestination(destination) + ? `${linkText}${linkText ? " " : ""}${LINK_REMOVED}` + : match + ); + + transformed = transformed.replace(explicitUrlPattern(), replaceUrlMatch); + transformed = transformed.replace(wwwUrlPattern(), replaceUrlMatch); + transformed = transformed.replace( + bareDomainWithPathPattern(), + (match, prefix, destination) => prefix + replaceUrlMatch(destination) + ); + + return transformed; +} diff --git a/middleware.js b/middleware.js index 185b9205..77e18ca9 100644 --- a/middleware.js +++ b/middleware.js @@ -63,7 +63,7 @@ export function middleware(request) { ); // XSS protection (legacy) - response.headers.set("X-XSS-Protection", "1; mode=block"); + response.headers.set("X-XSS-Protection", "0"); // Prevent MIME type sniffing response.headers.set("X-Content-Type-Options", "nosniff"); diff --git a/next.config.js b/next.config.js index 2d7ee446..90d209ea 100644 --- a/next.config.js +++ b/next.config.js @@ -46,13 +46,19 @@ const securityHeaders = [ }, { key: "X-XSS-Protection", - value: "1; mode=block" + value: "0" } // { // key: "Content-Security-Policy", // value: "frame-src 'self'; child-src 'self';object-src 'none'; script-src 'self' 'https://www.googletagmanager.com' unsafe-inline", // }, ]; +const noStoreHeaders = [ + { + key: "Cache-Control", + value: "no-store" + } +]; //const buildId = nextBuildId.sync({ dir: __dirname }); const buildId = Date.now().toString(); @@ -77,6 +83,30 @@ module.exports = { // Apply these headers to all routes in your application. source: "/(.*)?", headers: securityHeaders + }, + { + source: + "/api/:family(admin|email|file|myportal|subscriptions)/:path*", + headers: noStoreHeaders + }, + { + source: "/api/auth/resolve-locale", + headers: noStoreHeaders + }, + { + source: + "/api/endpoint/:route(createaccount_api|getaccounts_api|getemailaccountcheck_api|getpersonalaccount_api|getportallogin_api|getportalloginproxy_api|getpreferredlanguage_api|updateaccount_api|gethash_api|createcrmtask_api)", + headers: noStoreHeaders + }, + { + source: + "/api/endpoint/:route(getmycases_api|getmylpacases_api|getmyrepresentations_api|getmyrepresentationsproxy_api|getawaitingsubmission_api|getawaitingsubmissionproxy_api|getwatchedcases_api|getwatchedcasesproxy_api|createwatchedcases_api|deletewatchedcases_api|deletewatchedcasesproxy_api|deleteawaitingsubmissions_api)", + headers: noStoreHeaders + }, + { + source: + "/api/endpoint/:route(getbasicpartsaveddetails_api|getpartsavedappeal_api|getappealid_api|getappealpdfdocuments_api|getportalmoduledetails_api|getportalmoduledetailsproxy_api|patchcase_api|updatecase_api)", + headers: noStoreHeaders } ]; }, diff --git a/pages/api/email/contactus-confirmation.js b/pages/api/email/contactus-confirmation.js new file mode 100644 index 00000000..3ebe8393 --- /dev/null +++ b/pages/api/email/contactus-confirmation.js @@ -0,0 +1,246 @@ +import { isNonEmptyString, sanitizeString } from "../../../actions/core/guards"; +import { consoleLogger } from "../../../actions/core/logger"; +import removeContactUsEmailLinks from "../../../lib/security/contactus-email-links"; +import { respondError, respondSuccess } from "../middleware/apiResponse"; + +const TEMPLATE_IDS = { + en: "69228c5f-8500-40ff-a6c5-95a9434c3ec8", + cy: "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3" +}; + +const REFERENCE = "PEDW-CONTACT-US"; + +const allowedSubjects = { + plq: [ + "Questions about Planning legislation or policy", + "Planning legislation query", + "Ymholiad deddfwriaeth gynllunio" + ], + ipq: [ + "Questions or comments about applications for major infrastructure projects", + "Infrastructure procedure query", + "Ymholiad gweithdrefn seilwaith" + ], + pcpq: [ + "Questions or comments about planning and environmental appeals", + "Planning casework procedure query", + "Ymholiad gweithdrefn gwaith achos cynllunio" + ], + pi: ["Query about using the Casework Portal", "Portal Issues", "Materion Porth"], + appadvice: [ + "Advice How to submit an appeal", + "Appeal submission advice", + "Cyngor cyflwyno apêl" + ], + repadvice: [ + "Advice How to comment on an appeal or application", + "Representation submission advice", + "Cyngor cyflwyno sylwadau" + ], + ceq: [ + "Queries about forthcoming Hearing or Inquiry event", + "Casework Events Query", + "Ymholiad Digwyddiadau Gwaith Achos" + ], + other: ["Other enquiry for PEDW", "Any Other Enquiries", "Unrhyw Ymholiadau Eraill"], + postdec: [ + "Issues or questions about a case after the decision is issued", + "Post Decision", + "Ar ôl Penderfyniad" + ], + comp: [ + "General Complaints of Compliments", + "Complaints/Complements", + "Cwynion/Canmoliaethau" + ] +}; + +const allowedFields = new Set([ + "contactEmail", + "contactSubject", + "contactbody", + "typeOfContact", + "language" +]); + +const emailRegex = + /^(?!.*(?:.+\.)?(?:burpcollaborator\.net|interactsh\.com|mailinator\.com|10minutemail\.com|yopmail\.com|trashmail\.com)$)(?:[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|(?:\[(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-zA-Z0-9-]*[a-zA-Z0-9]:.+)])$/; + +// Origin checking is browser defence in depth only. It is not authentication and +// does not prevent a direct HTTP client from attempting abuse. +const hasTrustedOrigin = (req) => { + const requestOrigin = req?.headers?.origin; + + if (typeof requestOrigin !== "string" || requestOrigin === "null") { + return false; + } + + try { + const requestUrl = new URL(requestOrigin); + if (requestOrigin !== requestUrl.origin) { + return false; + } + + const trustedOrigins = [process.env.NEXTAUTH_URL, process.env.CY_API_ROOT] + .filter( + (configuredUrl) => + typeof configuredUrl === "string" && configuredUrl.length > 0 + ) + .map((configuredUrl) => new URL(configuredUrl).origin); + + return trustedOrigins.includes(requestUrl.origin); + } catch (_error) { + return false; + } +}; + +const validationError = (res, code, message) => + respondError(res, { status: 400, code, message }); + +const isPlainObject = (value) => { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype?.constructor?.name === "Object"; +}; + +export default async function ApiProxy(req, res) { + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }); + } + + const contentType = req.headers?.["content-type"]; + if ( + typeof contentType !== "string" || + contentType.split(";", 1)[0].trim().toLowerCase() !== "application/json" + ) { + return respondError(res, { + status: 415, + code: "UNSUPPORTED_MEDIA_TYPE", + message: "Content-Type must be application/json" + }); + } + + if (!hasTrustedOrigin(req)) { + return respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + } + + const requestBody = req.body; + if (!isPlainObject(requestBody)) { + return validationError( + res, + "INVALID_REQUEST_BODY", + "Request body must be a JSON object" + ); + } + + const requestFields = Object.keys(requestBody); + if ( + requestFields.length !== allowedFields.size || + requestFields.some((field) => !allowedFields.has(field)) + ) { + return validationError( + res, + "INVALID_REQUEST_FIELDS", + "Request must contain exactly the supported fields" + ); + } + + const contactEmail = sanitizeString(requestBody.contactEmail); + const contactSubject = sanitizeString(requestBody.contactSubject); + const contactBody = sanitizeString(requestBody.contactbody); + const typeOfContact = requestBody.typeOfContact; + const language = requestBody.language; + + if ( + typeof requestBody.contactEmail !== "string" || + !isNonEmptyString(contactEmail) || + contactEmail.length > 200 || + !emailRegex.test(contactEmail) + ) { + return validationError( + res, + "INVALID_CONTACT_EMAIL", + "contactEmail must be a valid email address of 200 characters or fewer" + ); + } + + if ( + typeof typeOfContact !== "string" || + !Object.prototype.hasOwnProperty.call(allowedSubjects, typeOfContact) + ) { + return validationError( + res, + "INVALID_CONTACT_TYPE", + "typeOfContact is not supported" + ); + } + + if ( + typeof requestBody.contactbody !== "string" || + !isNonEmptyString(contactBody) || + contactBody.length > 800 + ) { + return validationError( + res, + "INVALID_CONTACT_BODY", + "contactbody must contain between 1 and 800 characters" + ); + } + + if ( + typeof requestBody.contactSubject !== "string" || + !allowedSubjects[typeOfContact].includes(contactSubject) + ) { + return validationError( + res, + "INVALID_CONTACT_SUBJECT", + "contactSubject does not match typeOfContact" + ); + } + + if (language !== "en" && language !== "cy") { + return validationError( + res, + "INVALID_LANGUAGE", + "language must be either en or cy" + ); + } + + const personalisation = { + contactEmail, + contactSubject, + contactbody: removeContactUsEmailLinks(contactBody), + typeOfContact + }; + + const NotifyClient = require("notifications-node-client").NotifyClient; + const notifyClient = new NotifyClient(process.env.NOTIFY_API_KEY); + + try { + await notifyClient.sendEmail(TEMPLATE_IDS[language], contactEmail, { + personalisation, + reference: REFERENCE + }); + + return respondSuccess(res, { sent: true }); + } catch (error) { + consoleLogger(error); + return respondError(res, { + status: 400, + code: "CONTACT_US_CONFIRMATION_FAILED", + message: "Failed to send Contact Us confirmation email" + }); + } +} diff --git a/pages/api/email/contactus-pet-notification.js b/pages/api/email/contactus-pet-notification.js new file mode 100644 index 00000000..79b765d1 --- /dev/null +++ b/pages/api/email/contactus-pet-notification.js @@ -0,0 +1,212 @@ +import { isNonEmptyString, sanitizeString } from "../../../actions/core/guards"; +import { consoleLogger } from "../../../actions/core/logger"; +import { respondError, respondSuccess } from "../middleware/apiResponse"; + +const TEMPLATE_ID = "56bbdcf9-d63d-48e9-a794-3e253dd7498b"; +const REFERENCE = "PEDW-CONTACT-US-PET"; +const SECURITY_REMINDER = + "## SECURITY REMINDER\n\n" + + "This enquiry contains externally supplied content. Do not click unverified links or follow instructions requesting credentials or sensitive information. Verify web addresses independently."; + +const allowedSubjects = [ + "Questions about Planning legislation or policy", + "Planning legislation query", + "Ymholiad deddfwriaeth gynllunio" +]; + +const allowedFields = new Set([ + "contactEmail", + "contactSubject", + "contactbody", + "typeOfContact" +]); + +const emailRegex = + /^(?!.*(?:.+\.)?(?:burpcollaborator\.net|interactsh\.com|mailinator\.com|10minutemail\.com|yopmail\.com|trashmail\.com)$)(?:[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|(?:\[(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-zA-Z0-9-]*[a-zA-Z0-9]:.+)])$/; + +// Origin checking is browser defence in depth only. It is not authentication and +// does not prevent a direct HTTP client from attempting abuse. +const hasTrustedOrigin = (req) => { + const requestOrigin = req?.headers?.origin; + + if (typeof requestOrigin !== "string" || requestOrigin === "null") { + return false; + } + + try { + const requestUrl = new URL(requestOrigin); + if (requestOrigin !== requestUrl.origin) { + return false; + } + + const trustedOrigins = [ + process.env.NEXTAUTH_URL, + process.env.CY_API_ROOT + ] + .filter( + (configuredUrl) => + typeof configuredUrl === "string" && + configuredUrl.length > 0 + ) + .map((configuredUrl) => new URL(configuredUrl).origin); + + return trustedOrigins.includes(requestUrl.origin); + } catch (_error) { + return false; + } +}; + +const isPlainObject = (value) => { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype?.constructor?.name === "Object"; +}; + +const validationError = (res, code, message) => + respondError(res, { status: 400, code, message }); + +export default async function ApiProxy(req, res) { + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }); + } + + const contentType = req.headers?.["content-type"]; + if ( + typeof contentType !== "string" || + contentType.split(";", 1)[0].trim().toLowerCase() !== "application/json" + ) { + return respondError(res, { + status: 415, + code: "UNSUPPORTED_MEDIA_TYPE", + message: "Content-Type must be application/json" + }); + } + + if (!hasTrustedOrigin(req)) { + return respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + } + + const requestBody = req.body; + if (!isPlainObject(requestBody)) { + return validationError( + res, + "INVALID_REQUEST_BODY", + "Request body must be a JSON object" + ); + } + + const requestFields = Object.keys(requestBody); + if ( + requestFields.length !== allowedFields.size || + requestFields.some((field) => !allowedFields.has(field)) + ) { + return validationError( + res, + "INVALID_REQUEST_FIELDS", + "Request must contain exactly the supported fields" + ); + } + + const petEmail = sanitizeString(process.env.PETEMAIL); + const contactEmail = sanitizeString(requestBody.contactEmail); + const contactSubject = sanitizeString(requestBody.contactSubject); + const contactBody = requestBody.contactbody; + + if ( + typeof process.env.PETEMAIL !== "string" || + !isNonEmptyString(petEmail) || + petEmail.length > 200 || + !emailRegex.test(petEmail) + ) { + return respondError(res, { + status: 500, + code: "PET_RECIPIENT_NOT_CONFIGURED", + message: "PET notification recipient is not configured" + }); + } + + if ( + typeof requestBody.contactEmail !== "string" || + !isNonEmptyString(contactEmail) || + contactEmail.length > 200 || + !emailRegex.test(contactEmail) + ) { + return validationError( + res, + "INVALID_CONTACT_EMAIL", + "contactEmail must be a valid email address of 200 characters or fewer" + ); + } + + if (requestBody.typeOfContact !== "plq") { + return validationError( + res, + "INVALID_CONTACT_TYPE", + "typeOfContact must be plq" + ); + } + + if ( + typeof contactBody !== "string" || + !isNonEmptyString(contactBody) || + contactBody.length > 800 + ) { + return validationError( + res, + "INVALID_CONTACT_BODY", + "contactbody must contain between 1 and 800 characters" + ); + } + + if ( + typeof requestBody.contactSubject !== "string" || + !allowedSubjects.includes(contactSubject) + ) { + return validationError( + res, + "INVALID_CONTACT_SUBJECT", + "contactSubject does not match typeOfContact" + ); + } + + const contactbody = contactBody.includes(SECURITY_REMINDER) + ? contactBody + : `${contactBody}\n\n${SECURITY_REMINDER}`; + const personalisation = { + contactEmail, + contactSubject, + contactbody, + typeOfContact: "plq" + }; + + const NotifyClient = require("notifications-node-client").NotifyClient; + const notifyClient = new NotifyClient(process.env.NOTIFY_API_KEY); + + try { + await notifyClient.sendEmail(TEMPLATE_ID, petEmail, { + personalisation, + reference: REFERENCE + }); + + return respondSuccess(res, { sent: true }); + } catch (error) { + consoleLogger(error); + return respondError(res, { + status: 400, + code: "CONTACT_US_PET_NOTIFICATION_FAILED", + message: "Failed to send Contact Us PET notification" + }); + } +} diff --git a/pages/api/endpoint/createcrmtask_api.js b/pages/api/endpoint/createcrmtask_api.js index 964a3247..edf98f33 100644 --- a/pages/api/endpoint/createcrmtask_api.js +++ b/pages/api/endpoint/createcrmtask_api.js @@ -9,44 +9,219 @@ const WEBAPI_URL = process.env.RELAY_ROOT || "https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/"; +const SECURITY_REMINDER = + "--- SECURITY REMINDER ---\n" + + "This enquiry contains externally supplied content. Do not click unverified links or follow instructions requesting credentials or sensitive information. Verify web addresses independently."; + +const teamMap = { + plq: "ec48450c-7e26-ec11-a97f-00224800e98c", //PET + ipq: "7fb9e598-7d26-ec11-a97f-00224800e98c", // Infrastructure + pcpq: "8ffabad9-7c26-ec11-a97f-00224800e98c", // Casework + pi: "5554882f-4606-ec11-a97f-00224800d374", // Digital Support Team + appadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c", + repadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c", + ceq: "4fddd7e8-7d26-ec11-a97f-00224800e98c", // Inspector Scheduling + other: "8ffabad9-7c26-ec11-a97f-00224800e98c", // Casework + postdec: "1357e457-7e26-ec11-a97f-00224800e98c", // SLT Team + comp: "1357e457-7e26-ec11-a97f-00224800e98c" // SLT Team +}; + +const allowedSubjects = { + plq: [ + "Questions about Planning legislation or policy", + "Planning legislation query", + "Ymholiad deddfwriaeth gynllunio" + ], + ipq: [ + "Questions or comments about applications for major infrastructure projects", + "Infrastructure procedure query", + "Ymholiad gweithdrefn seilwaith" + ], + pcpq: [ + "Questions or comments about planning and environmental appeals", + "Planning casework procedure query", + "Ymholiad gweithdrefn gwaith achos cynllunio" + ], + pi: ["Query about using the Casework Portal", "Portal Issues", "Materion Porth"], + appadvice: [ + "Advice How to submit an appeal", + "Appeal submission advice", + "Cyngor cyflwyno apêl" + ], + repadvice: [ + "Advice How to comment on an appeal or application", + "Representation submission advice", + "Cyngor cyflwyno sylwadau" + ], + ceq: [ + "Queries about forthcoming Hearing or Inquiry event", + "Casework Events Query", + "Ymholiad Digwyddiadau Gwaith Achos" + ], + other: ["Other enquiry for PEDW", "Any Other Enquiries", "Unrhyw Ymholiadau Eraill"], + postdec: [ + "Issues or questions about a case after the decision is issued", + "Post Decision", + "Ar ôl Penderfyniad" + ], + comp: [ + "General Complaints of Compliments", + "Complaints/Complements", + "Cwynion/Canmoliaethau" + ] +}; + +const allowedFields = new Set([ + "contactEmail", + "contactSubject", + "contactbody", + "typeOfContact" +]); + +const emailRegex = + /^(?!.*(?:.+\.)?(?:burpcollaborator\.net|interactsh\.com|mailinator\.com|10minutemail\.com|yopmail\.com|trashmail\.com)$)(?:[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|(?:\[(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-zA-Z0-9-]*[a-zA-Z0-9]:.+)])$/; + +const hasTrustedOrigin = (req) => { + const requestOrigin = req?.headers?.origin; + + if (typeof requestOrigin !== "string" || requestOrigin === "null") { + return false; + } + + try { + const requestUrl = new URL(requestOrigin); + if (requestOrigin !== requestUrl.origin) { + return false; + } + + const trustedOrigins = [process.env.NEXTAUTH_URL, process.env.CY_API_ROOT] + .filter( + (configuredUrl) => + typeof configuredUrl === "string" && configuredUrl.length > 0 + ) + .map((configuredUrl) => new URL(configuredUrl).origin); + + return trustedOrigins.includes(requestUrl.origin); + } catch (_error) { + return false; + } +}; + +const validationError = (res, code, message) => + respondError(res, { status: 400, code, message }); + export default async function ApiProxy(req, res) { + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }); + } + + const contentType = req.headers?.["content-type"]; + if ( + typeof contentType !== "string" || + contentType.split(";", 1)[0].trim().toLowerCase() !== "application/json" + ) { + return respondError(res, { + status: 415, + code: "UNSUPPORTED_MEDIA_TYPE", + message: "Content-Type must be application/json" + }); + } + + if (!hasTrustedOrigin(req)) { + return respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + } + const createTaskBody = req.body; + if ( + createTaskBody === null || + typeof createTaskBody !== "object" || + Array.isArray(createTaskBody) + ) { + return validationError( + res, + "INVALID_REQUEST_BODY", + "Request body must be a JSON object" + ); + } + + if (Object.keys(createTaskBody).some((field) => !allowedFields.has(field))) { + return validationError( + res, + "UNSUPPORTED_REQUEST_FIELD", + "Request contains an unsupported field" + ); + } + const contactEmail = sanitizeString(createTaskBody?.contactEmail); const contactSubject = sanitizeString(createTaskBody?.contactSubject); const contactBody = sanitizeString(createTaskBody?.contactbody); - if (!isNonEmptyString(contactEmail) || !isNonEmptyString(contactSubject)) { - return respondError(res, { - status: 400, - code: "CONTACT_EMAIL_AND_SUBJECT_REQUIRED", - message: "contactEmail and contactSubject are required" - }); + if ( + typeof createTaskBody.contactEmail !== "string" || + !isNonEmptyString(contactEmail) || + contactEmail.length > 200 || + !emailRegex.test(contactEmail) + ) { + return validationError( + res, + "INVALID_CONTACT_EMAIL", + "contactEmail must be a valid email address of 200 characters or fewer" + ); + } + + if ( + typeof createTaskBody.typeOfContact !== "string" || + !Object.prototype.hasOwnProperty.call(teamMap, createTaskBody.typeOfContact) + ) { + return validationError( + res, + "INVALID_CONTACT_TYPE", + "typeOfContact is not supported" + ); + } + + if ( + typeof createTaskBody.contactbody !== "string" || + !isNonEmptyString(contactBody) || + contactBody.length > 800 + ) { + return validationError( + res, + "INVALID_CONTACT_BODY", + "contactbody must contain between 1 and 800 characters" + ); + } + + const contactValue = createTaskBody.typeOfContact; + if ( + typeof createTaskBody.contactSubject !== "string" || + !allowedSubjects[contactValue].includes(contactSubject) + ) { + return validationError( + res, + "INVALID_CONTACT_SUBJECT", + "contactSubject does not match typeOfContact" + ); } const queryUrl = "tasks"; const token = await getToken(); - const contactValue = createTaskBody.typeOfContact; - - const teamMap = { - plq: "ec48450c-7e26-ec11-a97f-00224800e98c", //PET - ipq: "7fb9e598-7d26-ec11-a97f-00224800e98c", // Infrastructure - pcpq: "8ffabad9-7c26-ec11-a97f-00224800e98c", // Casework - pi: "5554882f-4606-ec11-a97f-00224800d374", // Digital Support Team - appadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c", - repadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c", - ceq: "4fddd7e8-7d26-ec11-a97f-00224800e98c", // Inspector Scheduling - other: "8ffabad9-7c26-ec11-a97f-00224800e98c", // Casework - postdec: "1357e457-7e26-ec11-a97f-00224800e98c", // SLT Team - comp: "1357e457-7e26-ec11-a97f-00224800e98c" // SLT Team - }; - let teamId = teamMap[contactValue]; const payload = { "subject": `Contact Us Enquiry - ${contactSubject}`, - "description": `From: ${contactEmail}\n\n${contactBody}`, + "description": `From: ${contactEmail}\n\n${contactBody}\n\n${SECURITY_REMINDER}`, "scheduledstart": new Date().toISOString(), "scheduledend": new Date( new Date().getTime() + 60 * 60000 * 24 diff --git a/pages/api/file/deleteblobcase.js b/pages/api/file/deleteblobcase.js index 002068a4..e79cdb5a 100644 --- a/pages/api/file/deleteblobcase.js +++ b/pages/api/file/deleteblobcase.js @@ -5,10 +5,17 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "../auth/[...nextauth]"; import nextConnect from "next-connect"; -import middleware from "../middleware/middleware"; -const ApiProxy = nextConnect(); -ApiProxy.use(middleware); +const methodNotAllowed = (req, res) => { + res.setHeader("Allow", "POST"); + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }); +}; + +const ApiProxy = nextConnect({ onNoMatch: methodNotAllowed }); const forbidden = (res) => respondError(res, { @@ -17,7 +24,53 @@ const forbidden = (res) => message: "Request not permitted" }); -ApiProxy.get(async (req, res) => { +const isValidPathSegment = (value) => + typeof value === "string" && + value.length > 0 && + value.length <= 255 && + value === value.trim() && + value !== "." && + value !== ".." && + !/[\\/\u0000-\u001f\u007f]/.test(value); + +const hasTrustedOrigin = (req) => { + const requestOrigin = req?.headers?.origin; + + if (typeof requestOrigin !== "string" || requestOrigin === "null") { + return false; + } + + try { + const requestUrl = new URL(requestOrigin); + if (requestOrigin !== requestUrl.origin) { + return false; + } + + const normalizedRequestOrigin = requestUrl.origin; + const trustedOrigins = [ + process.env.NEXTAUTH_URL, + process.env.CY_API_ROOT + ] + .filter((configuredUrl) => + typeof configuredUrl === "string" && configuredUrl.length > 0 + ) + .map((configuredUrl) => new URL(configuredUrl).origin); + + return trustedOrigins.includes(normalizedRequestOrigin); + } catch (_error) { + return false; + } +}; + +ApiProxy.post(async (req, res) => { + if (req.method !== "POST") { + return methodNotAllowed(req, res); + } + + if (!hasTrustedOrigin(req)) { + return forbidden(res); + } + const containerName = req.query.container; const casefolderID = req.query.casefolderID; const checkHash = req.query.hash; @@ -39,6 +92,14 @@ ApiProxy.get(async (req, res) => { const casefolderIDTrimmed = casefolderID.trim(); + if (!isValidPathSegment(casefolderIDTrimmed)) { + return respondError(res, { + status: 400, + code: "INVALID_REQUEST", + message: "Invalid request" + }); + } + const session = await getServerSession(req, res, authOptions(req, res)); if ( typeof session?.user?.id !== "string" || diff --git a/pages/api/file/deleteblobrep.js b/pages/api/file/deleteblobrep.js index d6c7f3f0..0ad7c394 100644 --- a/pages/api/file/deleteblobrep.js +++ b/pages/api/file/deleteblobrep.js @@ -5,10 +5,17 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "../auth/[...nextauth]"; import nextConnect from "next-connect"; -import middleware from "../middleware/middleware"; -const ApiProxy = nextConnect(); -ApiProxy.use(middleware); +const methodNotAllowed = (req, res) => { + res.setHeader("Allow", "POST"); + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Method not allowed" + }); +}; + +const ApiProxy = nextConnect({ onNoMatch: methodNotAllowed }); const forbidden = (res) => respondError(res, { @@ -17,7 +24,53 @@ const forbidden = (res) => message: "Request not permitted" }); -ApiProxy.get(async (req, res) => { +const isValidPathSegment = (value) => + typeof value === "string" && + value.length > 0 && + value.length <= 255 && + value === value.trim() && + value !== "." && + value !== ".." && + !/[\\/\u0000-\u001f\u007f]/.test(value); + +const hasTrustedOrigin = (req) => { + const requestOrigin = req?.headers?.origin; + + if (typeof requestOrigin !== "string" || requestOrigin === "null") { + return false; + } + + try { + const requestUrl = new URL(requestOrigin); + if (requestOrigin !== requestUrl.origin) { + return false; + } + + const normalizedRequestOrigin = requestUrl.origin; + const trustedOrigins = [ + process.env.NEXTAUTH_URL, + process.env.CY_API_ROOT + ] + .filter((configuredUrl) => + typeof configuredUrl === "string" && configuredUrl.length > 0 + ) + .map((configuredUrl) => new URL(configuredUrl).origin); + + return trustedOrigins.includes(normalizedRequestOrigin); + } catch (_error) { + return false; + } +}; + +ApiProxy.post(async (req, res) => { + if (req.method !== "POST") { + return methodNotAllowed(req, res); + } + + if (!hasTrustedOrigin(req)) { + return forbidden(res); + } + const containerName = req.query.container; const casefolderID = req.query.casefolderID; const repfile = req.query.repfile; @@ -43,6 +96,17 @@ ApiProxy.get(async (req, res) => { const casefolderIDTrimmed = casefolderID.trim(); const repfileTrimmed = repfile.trim(); + if ( + !isValidPathSegment(casefolderIDTrimmed) || + !isValidPathSegment(repfileTrimmed) + ) { + return respondError(res, { + status: 400, + code: "INVALID_REQUEST", + message: "Invalid request" + }); + } + const session = await getServerSession(req, res, authOptions(req, res)); if ( typeof session?.user?.id !== "string" || diff --git a/pages/contactus.js b/pages/contactus.js index e877ceb9..7c91a7ab 100644 --- a/pages/contactus.js +++ b/pages/contactus.js @@ -17,8 +17,10 @@ import { } from "redux-form"; import { connect } from "react-redux"; import { createCRMTask } from "../actions/services/integrationService"; -import { sendEmail } from "../actions/services/notifyService"; -import { wrapper } from "../store/store"; +import { + sendContactUsConfirmation, + sendContactUsPETNotification +} from "../actions/services/notifyDirectService"; import { useState } from "react"; @@ -319,7 +321,7 @@ let ContactUs = (props) => { sendContactEmail(formValues); createCRMTask(formValues); formValues.typeOfContact == "plq" && - sendPETEmail(formValues, props.petEmail); + sendPETEmail(formValues); console.log("Form submitted:", formValues); }; @@ -329,16 +331,10 @@ let ContactUs = (props) => { .children }); - const reference = "PEDW-CONTACT-US"; - const templateId = "69228c5f-8500-40ff-a6c5-95a9434c3ec8"; - const templateIdCY = "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3"; - try { - const res = await sendEmail( - router.locale !== "en" ? templateIdCY : templateId, - formValues.contactEmail, + const res = await sendContactUsConfirmation( formValues, - reference + router.locale !== "en" ? "cy" : "en" ); console.log("Email sent successfully."); @@ -353,20 +349,9 @@ let ContactUs = (props) => { } }; - const sendPETEmail = async (formValues, petEmail) => { - const reference = "PEDW-CONTACT-US-PET"; - const templateId = "56bbdcf9-d63d-48e9-a794-3e253dd7498b"; - const templateIdCY = "00b794d3-29aa-434d-b696-0ef0b0337d79"; - - console.log(petEmail); - + const sendPETEmail = async (formValues) => { try { - const res = await sendEmail( - templateId, - petEmail, - formValues, - reference - ); + const res = await sendContactUsPETNotification(formValues); console.log("Email sent successfully."); setFormSubmitted(true); @@ -531,17 +516,6 @@ const mapStateToProps = (state) => { }; }; -export const getServerSideProps = wrapper.getServerSideProps( - (store) => async (ctx) => { - const { req, locale } = ctx; - return { - props: { - petEmail: process.env.PETEMAIL - } - }; - } -); - export default connect( mapStateToProps, mapDispatchToProps diff --git a/pages/myportal/contactus.js b/pages/myportal/contactus.js index 05ea94b7..4334fc25 100644 --- a/pages/myportal/contactus.js +++ b/pages/myportal/contactus.js @@ -17,8 +17,10 @@ import { } from "redux-form"; import { connect } from "react-redux"; import { createCRMTask } from "../../actions/services/integrationService"; -import { sendEmail } from "../../actions/services/notifyService"; -import { wrapper } from "../../store/store"; +import { + sendContactUsConfirmation, + sendContactUsPETNotification +} from "../../actions/services/notifyDirectService"; import { useState } from "react"; @@ -319,7 +321,7 @@ let ContactUs = (props) => { sendContactEmail(formValues); createCRMTask(formValues); formValues.typeOfContact == "plq" && - sendPETEmail(formValues, props.petEmail); + sendPETEmail(formValues); console.log("Form submitted:", formValues); }; @@ -329,16 +331,10 @@ let ContactUs = (props) => { .children }); - const reference = "PEDW-CONTACT-US"; - const templateId = "69228c5f-8500-40ff-a6c5-95a9434c3ec8"; - const templateIdCY = "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3"; - try { - const res = await sendEmail( - router.locale !== "en" ? templateIdCY : templateId, - formValues.contactEmail, + const res = await sendContactUsConfirmation( formValues, - reference + router.locale !== "en" ? "cy" : "en" ); console.log("Email sent successfully."); @@ -353,20 +349,9 @@ let ContactUs = (props) => { } }; - const sendPETEmail = async (formValues, petEmail) => { - const reference = "PEDW-CONTACT-US-PET"; - const templateId = "56bbdcf9-d63d-48e9-a794-3e253dd7498b"; - const templateIdCY = "00b794d3-29aa-434d-b696-0ef0b0337d79"; - - console.log(petEmail); - + const sendPETEmail = async (formValues) => { try { - const res = await sendEmail( - templateId, - petEmail, - formValues, - reference - ); + const res = await sendContactUsPETNotification(formValues); console.log("Email sent successfully."); setFormSubmitted(true); @@ -532,17 +517,6 @@ const mapStateToProps = (state) => { }; }; -export const getServerSideProps = wrapper.getServerSideProps( - (store) => async (ctx) => { - const { req, locale } = ctx; - return { - props: { - petEmail: process.env.PETEMAIL - } - }; - } -); - export default connect( mapStateToProps, mapDispatchToProps diff --git a/tests/phase21/email-handler-contract.test.cjs b/tests/phase21/email-handler-contract.test.cjs index c648325c..da6d4537 100644 --- a/tests/phase21/email-handler-contract.test.cjs +++ b/tests/phase21/email-handler-contract.test.cjs @@ -337,6 +337,823 @@ test("notify send failure returns EMAIL_NOTIFY_FAILED", async () => { assert.strictEqual(res.state.jsonBody.error.code, "EMAIL_NOTIFY_FAILED"); }); +const trustedContactOrigin = "https://portal.example.test"; +const trustedWelshContactOrigin = "https://porth.example.test"; +const contactConfirmationTemplateEn = "69228c5f-8500-40ff-a6c5-95a9434c3ec8"; +const contactConfirmationTemplateCy = "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3"; + +const contactSubjects = { + plq: { + en: "Questions about Planning legislation or policy", + cy: "Ymholiad deddfwriaeth gynllunio" + }, + ipq: { + en: "Questions or comments about applications for major infrastructure projects", + cy: "Ymholiad gweithdrefn seilwaith" + }, + pcpq: { + en: "Questions or comments about planning and environmental appeals", + cy: "Ymholiad gweithdrefn gwaith achos cynllunio" + }, + pi: { en: "Query about using the Casework Portal", cy: "Materion Porth" }, + appadvice: { + en: "Advice How to submit an appeal", + cy: "Cyngor cyflwyno apêl" + }, + repadvice: { + en: "Advice How to comment on an appeal or application", + cy: "Cyngor cyflwyno sylwadau" + }, + ceq: { + en: "Queries about forthcoming Hearing or Inquiry event", + cy: "Ymholiad Digwyddiadau Gwaith Achos" + }, + other: { en: "Other enquiry for PEDW", cy: "Unrhyw Ymholiadau Eraill" }, + postdec: { + en: "Issues or questions about a case after the decision is issued", + cy: "Ar ôl Penderfyniad" + }, + comp: { + en: "General Complaints of Compliments", + cy: "Cwynion/Canmoliaethau" + } +}; + +const loadContactLinkUtility = () => + loadModule("lib/security/contactus-email-links.js").default; + +const validContactConfirmationRequest = (overrides = {}) => ({ + method: "POST", + headers: { + origin: trustedContactOrigin, + "content-type": "application/json; charset=utf-8" + }, + body: { + contactEmail: "applicant@example.com", + contactSubject: contactSubjects.ipq.en, + contactbody: "Please help with my enquiry.", + typeOfContact: "ipq", + language: "en" + }, + ...overrides +}); + +const loadContactConfirmationEndpoint = ({ sendEmail } = {}) => { + const notifyCalls = []; + const removeContactUsEmailLinks = loadContactLinkUtility(); + const notifySend = + sendEmail || + (async (...args) => { + notifyCalls.push(args); + return { id: "notify-1" }; + }); + + const mod = loadModule("pages/api/email/contactus-confirmation.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + sanitizeString: (value) => + typeof value === "string" ? value.trim() : "", + isNonEmptyString: (value) => + typeof value === "string" && value.trim().length > 0, + removeContactUsEmailLinks, + consoleLogger: () => {}, + require: (name) => { + if (name === "notifications-node-client") { + return { + NotifyClient: function NotifyClient() { + return { sendEmail: notifySend }; + } + }; + } + + return require(name); + } + }); + + return { mod, notifyCalls }; +}; + +const withContactOrigins = async (fn) => { + const previousNextAuthUrl = process.env.NEXTAUTH_URL; + const previousCyApiRoot = process.env.CY_API_ROOT; + const previousPetEmail = process.env.PETEMAIL; + process.env.NEXTAUTH_URL = trustedContactOrigin; + process.env.CY_API_ROOT = trustedWelshContactOrigin; + process.env.PETEMAIL = "pet@example.com"; + + try { + await fn(); + } finally { + if (previousNextAuthUrl === undefined) delete process.env.NEXTAUTH_URL; + else process.env.NEXTAUTH_URL = previousNextAuthUrl; + + if (previousCyApiRoot === undefined) delete process.env.CY_API_ROOT; + else process.env.CY_API_ROOT = previousCyApiRoot; + + if (previousPetEmail === undefined) delete process.env.PETEMAIL; + else process.env.PETEMAIL = previousPetEmail; + } +}; + +const assertContactConfirmationRejected = async ({ req, status, code }) => { + await withContactOrigins(async () => { + const loaded = loadContactConfirmationEndpoint(); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, status); + assert.strictEqual(res.state.jsonBody.error.code, code); + assert.strictEqual(loaded.notifyCalls.length, 0); + }); +}; + +test("Contact Us link removal preserves text while removing recognised destinations", async () => { + const removeLinks = loadContactLinkUtility(); + const input = [ + "Please review https://example.invalid/path?token=abc#details.", + "Also HTTP://OTHER.INVALID/login and www.third.invalid/help.", + "Bare planning.example.invalid/application/123 remains represented safely.", + "Email applicant@example.com and reference APP/123456 must remain.", + "Markdown [review this](https://markdown.invalid/login).", + 'HTML review this.', + 'Tag remains non-actionable.', + "Obfuscated h\u200btt\u200bps://obfuscated.invalid/login is removed." + ].join("\n"); + + const output = removeLinks(input); + + assert.strictEqual( + output, + [ + "Please review [web link removed].", + "Also [web link removed] and [web link removed].", + "Bare [web link removed] remains represented safely.", + "Email applicant@example.com and reference APP/123456 must remain.", + "Markdown review this [web link removed].", + "HTML review this [web link removed].", + "Tag [web link removed] remains non-actionable.", + "Obfuscated [web link removed] is removed." + ].join("\n") + ); + assert.strictEqual(input.includes("https://example.invalid"), true); +}); + +test("Contact Us confirmation is POST-only with Allow header", async () => { + const req = validContactConfirmationRequest({ method: "GET" }); + await withContactOrigins(async () => { + const loaded = loadContactConfirmationEndpoint(); + 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.notifyCalls.length, 0); + }); +}); + +for (const contentType of [ + undefined, + "text/plain", + "application/x-www-form-urlencoded" +]) { + test(`Contact Us confirmation rejects content type ${String(contentType)}`, async () => { + const req = validContactConfirmationRequest({ + headers: { + origin: trustedContactOrigin, + ...(contentType ? { "content-type": contentType } : {}) + } + }); + await assertContactConfirmationRejected({ + req, + status: 415, + code: "UNSUPPORTED_MEDIA_TYPE" + }); + }); +} + +for (const origin of [ + undefined, + "null", + "https://attacker.example", + `${trustedContactOrigin}/path` +]) { + test(`Contact Us confirmation rejects origin ${String(origin)}`, async () => { + const req = validContactConfirmationRequest({ + headers: { + ...(origin ? { origin } : {}), + "content-type": "application/json" + } + }); + await assertContactConfirmationRejected({ + req, + status: 403, + code: "FORBIDDEN" + }); + }); +} + +test("Contact Us confirmation accepts each configured exact origin", async () => { + await withContactOrigins(async () => { + for (const origin of [ + trustedContactOrigin, + trustedWelshContactOrigin + ]) { + const loaded = loadContactConfirmationEndpoint(); + const req = validContactConfirmationRequest({ + headers: { origin, "content-type": "application/json" } + }); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.notifyCalls.length, 1); + } + }); +}); + +for (const body of [null, [], "not-an-object", new Date()]) { + test(`Contact Us confirmation rejects non-plain body ${Object.prototype.toString.call(body)}`, async () => { + await assertContactConfirmationRejected({ + req: validContactConfirmationRequest({ body }), + status: 400, + code: "INVALID_REQUEST_BODY" + }); + }); +} + +for (const bodyOverride of [ + { extra: "field" }, + { language: undefined }, + { contactSubject: undefined } +]) { + test(`Contact Us confirmation enforces exact fields ${Object.keys(bodyOverride).join(",")}`, async () => { + const body = { + ...validContactConfirmationRequest().body, + ...bodyOverride + }; + if (Object.values(bodyOverride).includes(undefined)) { + for (const key of Object.keys(bodyOverride)) delete body[key]; + } + await assertContactConfirmationRejected({ + req: validContactConfirmationRequest({ body }), + status: 400, + code: "INVALID_REQUEST_FIELDS" + }); + }); +} + +for (const contactEmail of [ + undefined, + "invalid", + `${"a".repeat(190)}@example.com` +]) { + test(`Contact Us confirmation rejects invalid email ${String(contactEmail).slice(0, 30)}`, async () => { + await assertContactConfirmationRejected({ + req: validContactConfirmationRequest({ + body: { + ...validContactConfirmationRequest().body, + contactEmail + } + }), + status: 400, + code: "INVALID_CONTACT_EMAIL" + }); + }); +} + +for (const typeOfContact of [undefined, "unsupported"]) { + test(`Contact Us confirmation rejects category ${String(typeOfContact)}`, async () => { + await assertContactConfirmationRejected({ + req: validContactConfirmationRequest({ + body: { + ...validContactConfirmationRequest().body, + typeOfContact + } + }), + status: 400, + code: "INVALID_CONTACT_TYPE" + }); + }); +} + +for (const contactbody of [undefined, " ", "a".repeat(801)]) { + test(`Contact Us confirmation rejects message ${String(contactbody).slice(0, 20)}`, async () => { + await assertContactConfirmationRejected({ + req: validContactConfirmationRequest({ + body: { ...validContactConfirmationRequest().body, contactbody } + }), + status: 400, + code: "INVALID_CONTACT_BODY" + }); + }); +} + +test("Contact Us confirmation rejects a subject not matching its category", async () => { + await assertContactConfirmationRejected({ + req: validContactConfirmationRequest({ + body: { + ...validContactConfirmationRequest().body, + contactSubject: contactSubjects.plq.en + } + }), + status: 400, + code: "INVALID_CONTACT_SUBJECT" + }); +}); + +for (const language of [undefined, "CY", "fr", ""]) { + test(`Contact Us confirmation rejects language ${String(language)}`, async () => { + await assertContactConfirmationRejected({ + req: validContactConfirmationRequest({ + body: { ...validContactConfirmationRequest().body, language } + }), + status: 400, + code: "INVALID_LANGUAGE" + }); + }); +} + +test("Contact Us confirmation selects English template and exact safe personalisation", async () => { + await withContactOrigins(async () => { + const loaded = loadContactConfirmationEndpoint(); + const req = validContactConfirmationRequest({ + body: { + ...validContactConfirmationRequest().body, + contactbody: + "Please review (https://example.invalid/path?q=1#part), then email applicant@example.com about APP/123456." + } + }); + const originalBody = JSON.parse(JSON.stringify(req.body)); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.notifyCalls.length, 1); + assert.strictEqual( + loaded.notifyCalls[0][0], + contactConfirmationTemplateEn + ); + assert.strictEqual(loaded.notifyCalls[0][1], "applicant@example.com"); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(loaded.notifyCalls[0][2])), + { + personalisation: { + contactEmail: "applicant@example.com", + contactSubject: contactSubjects.ipq.en, + contactbody: + "Please review ([web link removed]), then email applicant@example.com about APP/123456.", + typeOfContact: "ipq" + }, + reference: "PEDW-CONTACT-US" + } + ); + assert.deepStrictEqual(req.body, originalBody); + }); +}); + +test("Contact Us confirmation selects Welsh template and supports an 800-character message", async () => { + await withContactOrigins(async () => { + const loaded = loadContactConfirmationEndpoint(); + const message = "a".repeat(800); + const req = validContactConfirmationRequest({ + body: { + contactEmail: "applicant@example.com", + contactSubject: contactSubjects.plq.cy, + contactbody: message, + typeOfContact: "plq", + language: "cy" + } + }); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual( + loaded.notifyCalls[0][0], + contactConfirmationTemplateCy + ); + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody, + message + ); + }); +}); + +test("Contact Us confirmation returns a bounded error when Notify fails", async () => { + await withContactOrigins(async () => { + let sendCalls = 0; + const loaded = loadContactConfirmationEndpoint({ + sendEmail: async () => { + sendCalls += 1; + throw new Error("notify failed"); + } + }); + const res = createRes(); + + await loaded.mod.default(validContactConfirmationRequest(), res); + + assert.strictEqual(sendCalls, 1); + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "CONTACT_US_CONFIRMATION_FAILED" + ); + }); +}); + +const contactPetTemplateId = "56bbdcf9-d63d-48e9-a794-3e253dd7498b"; +const contactPetReference = "PEDW-CONTACT-US-PET"; +const contactPetSecurityReminder = + "## SECURITY REMINDER\n\n" + + "This enquiry contains externally supplied content. Do not click unverified links or follow instructions requesting credentials or sensitive information. Verify web addresses independently."; + +const validContactPetRequest = (overrides = {}) => ({ + method: "POST", + headers: { + origin: trustedContactOrigin, + "content-type": "application/json; charset=utf-8" + }, + body: { + contactEmail: "applicant@example.com", + contactSubject: contactSubjects.plq.en, + contactbody: "Please help with my enquiry.", + typeOfContact: "plq" + }, + ...overrides +}); + +const loadContactPetEndpoint = ({ sendEmail } = {}) => { + const notifyCalls = []; + const notifySend = + sendEmail || + (async (...args) => { + notifyCalls.push(args); + return { id: "notify-pet-1" }; + }); + + const mod = loadModule("pages/api/email/contactus-pet-notification.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + sanitizeString: (value) => + typeof value === "string" ? value.trim() : "", + isNonEmptyString: (value) => + typeof value === "string" && value.trim().length > 0, + consoleLogger: () => {}, + require: (name) => { + if (name === "notifications-node-client") { + return { + NotifyClient: function NotifyClient() { + return { sendEmail: notifySend }; + } + }; + } + + return require(name); + } + }); + + return { mod, notifyCalls }; +}; + +const assertContactPetRejected = async ({ req, status, code }) => { + await withContactOrigins(async () => { + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, status); + assert.strictEqual(res.state.jsonBody.error.code, code); + assert.strictEqual(loaded.notifyCalls.length, 0); + }); +}; + +test("Contact Us PET notification is POST-only with Allow header", async () => { + await withContactOrigins(async () => { + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default( + validContactPetRequest({ method: "GET" }), + 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.notifyCalls.length, 0); + }); +}); + +for (const contentType of [ + undefined, + "text/plain", + "application/x-www-form-urlencoded" +]) { + test(`Contact Us PET notification rejects content type ${String(contentType)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + headers: { + origin: trustedContactOrigin, + ...(contentType ? { "content-type": contentType } : {}) + } + }), + status: 415, + code: "UNSUPPORTED_MEDIA_TYPE" + }); + }); +} + +for (const origin of [ + undefined, + "null", + "https://attacker.example", + `${trustedContactOrigin}/path` +]) { + test(`Contact Us PET notification rejects origin ${String(origin)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + headers: { + ...(origin ? { origin } : {}), + "content-type": "application/json" + } + }), + status: 403, + code: "FORBIDDEN" + }); + }); +} + +test("Contact Us PET notification accepts each configured exact origin", async () => { + await withContactOrigins(async () => { + for (const origin of [ + trustedContactOrigin, + trustedWelshContactOrigin + ]) { + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default( + validContactPetRequest({ + headers: { origin, "content-type": "application/json" } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.notifyCalls.length, 1); + } + }); +}); + +for (const body of [null, [], "not-an-object", new Date()]) { + test(`Contact Us PET notification rejects non-plain body ${Object.prototype.toString.call(body)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ body }), + status: 400, + code: "INVALID_REQUEST_BODY" + }); + }); +} + +test("Contact Us PET notification enforces its exact field set", async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + body: { + ...validContactPetRequest().body, + templateId: "caller-controlled" + } + }), + status: 400, + code: "INVALID_REQUEST_FIELDS" + }); +}); + +test("Contact Us PET notification rejects obsolete caller-supplied recipient", async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + body: { + ...validContactPetRequest().body, + petEmail: "pet@example.com" + } + }), + status: 400, + code: "INVALID_REQUEST_FIELDS" + }); +}); + +for (const configuredRecipient of [undefined, "invalid"]) { + test(`Contact Us PET notification fails safely for configured recipient ${String(configuredRecipient)}`, async () => { + await withContactOrigins(async () => { + if (configuredRecipient === undefined) delete process.env.PETEMAIL; + else process.env.PETEMAIL = configuredRecipient; + + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default(validContactPetRequest(), res); + + assert.strictEqual(res.state.statusCode, 500); + assert.strictEqual( + res.state.jsonBody.error.code, + "PET_RECIPIENT_NOT_CONFIGURED" + ); + assert.strictEqual(loaded.notifyCalls.length, 0); + assert.strictEqual( + JSON.stringify(res.state.jsonBody).includes( + String(configuredRecipient) + ), + false + ); + }); + }); +} + +test("Contact Us PET notification rejects caller recipient before configured recipient validation", async () => { + await withContactOrigins(async () => { + delete process.env.PETEMAIL; + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default( + validContactPetRequest({ + body: { + ...validContactPetRequest().body, + petEmail: "attacker@example.com" + } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "INVALID_REQUEST_FIELDS" + ); + assert.strictEqual(loaded.notifyCalls.length, 0); + }); +}); + +for (const contactEmail of [ + undefined, + "invalid", + `${"a".repeat(190)}@example.com` +]) { + test(`Contact Us PET notification rejects contact email ${String(contactEmail).slice(0, 30)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + body: { ...validContactPetRequest().body, contactEmail } + }), + status: 400, + code: "INVALID_CONTACT_EMAIL" + }); + }); +} + +for (const typeOfContact of [undefined, "ipq", "other"]) { + test(`Contact Us PET notification rejects category ${String(typeOfContact)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + body: { ...validContactPetRequest().body, typeOfContact } + }), + status: 400, + code: "INVALID_CONTACT_TYPE" + }); + }); +} + +for (const contactbody of [undefined, " ", "a".repeat(801)]) { + test(`Contact Us PET notification rejects message ${String(contactbody).slice(0, 20)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + body: { ...validContactPetRequest().body, contactbody } + }), + status: 400, + code: "INVALID_CONTACT_BODY" + }); + }); +} + +test("Contact Us PET notification preserves URLs and appends the reminder exactly once", async () => { + await withContactOrigins(async () => { + const contactbody = + "Please review https://example.invalid/application/123?token=abc#details"; + const req = validContactPetRequest({ + body: { ...validContactPetRequest().body, contactbody } + }); + const originalBody = JSON.parse(JSON.stringify(req.body)); + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.notifyCalls.length, 1); + assert.strictEqual(loaded.notifyCalls[0][0], contactPetTemplateId); + assert.strictEqual(loaded.notifyCalls[0][1], "pet@example.com"); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(loaded.notifyCalls[0][2])), + { + personalisation: { + contactEmail: "applicant@example.com", + contactSubject: contactSubjects.plq.en, + contactbody: `${contactbody}\n\n${contactPetSecurityReminder}`, + typeOfContact: "plq" + }, + reference: contactPetReference + } + ); + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody.includes( + "https://example.invalid/application/123?token=abc#details" + ), + true + ); + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody.split( + contactPetSecurityReminder + ).length - 1, + 1 + ); + assert.deepStrictEqual(req.body, originalBody); + }); +}); + +test("Contact Us PET notification does not duplicate an existing exact reminder", async () => { + await withContactOrigins(async () => { + const contactbody = `Original message\n\n${contactPetSecurityReminder}`; + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default( + validContactPetRequest({ + body: { ...validContactPetRequest().body, contactbody } + }), + res + ); + + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody, + contactbody + ); + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody.split( + contactPetSecurityReminder + ).length - 1, + 1 + ); + }); +}); + +test("Contact Us PET notification supports an 800-character original message", async () => { + await withContactOrigins(async () => { + const contactbody = "a".repeat(800); + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default( + validContactPetRequest({ + body: { ...validContactPetRequest().body, contactbody } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody, + `${contactbody}\n\n${contactPetSecurityReminder}` + ); + }); +}); + +test("Contact Us PET notification returns a bounded error when Notify fails", async () => { + await withContactOrigins(async () => { + let sendCalls = 0; + const loaded = loadContactPetEndpoint({ + sendEmail: async () => { + sendCalls += 1; + throw new Error("notify failed"); + } + }); + const res = createRes(); + + await loaded.mod.default(validContactPetRequest(), res); + + assert.strictEqual(sendCalls, 1); + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "CONTACT_US_PET_NOTIFICATION_FAILED" + ); + }); +}); + const run = async () => { let passed = 0; for (const currentTest of tests) { diff --git a/tests/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs index 45078ec2..647747ab 100644 --- a/tests/phase21/endpoint-handler-contract.test.cjs +++ b/tests/phase21/endpoint-handler-contract.test.cjs @@ -4866,56 +4866,486 @@ test("patchcase catch path returns CASE_PATCH_FAILED", async () => { assert.strictEqual(res.state.jsonBody.error.code, "CASE_PATCH_FAILED"); }); -test("createcrmtask returns CONTACT_EMAIL_AND_SUBJECT_REQUIRED when required fields missing", async () => { +const trustedContactOrigin = "https://portal.example.test"; +const trustedWelshContactOrigin = "https://porth.example.test"; +const contactTaskSecurityReminder = + "--- SECURITY REMINDER ---\n" + + "This enquiry contains externally supplied content. Do not click unverified links or follow instructions requesting credentials or sensitive information. Verify web addresses independently."; + +const contactSubjects = { + plq: { + en: "Questions about Planning legislation or policy", + cy: "Ymholiad deddfwriaeth gynllunio" + }, + ipq: { + en: "Questions or comments about applications for major infrastructure projects", + cy: "Ymholiad gweithdrefn seilwaith" + }, + pcpq: { + en: "Questions or comments about planning and environmental appeals", + cy: "Ymholiad gweithdrefn gwaith achos cynllunio" + }, + pi: { en: "Query about using the Casework Portal", cy: "Materion Porth" }, + appadvice: { en: "Advice How to submit an appeal", cy: "Cyngor cyflwyno apêl" }, + repadvice: { + en: "Advice How to comment on an appeal or application", + cy: "Cyngor cyflwyno sylwadau" + }, + ceq: { + en: "Queries about forthcoming Hearing or Inquiry event", + cy: "Ymholiad Digwyddiadau Gwaith Achos" + }, + other: { en: "Other enquiry for PEDW", cy: "Unrhyw Ymholiadau Eraill" }, + postdec: { + en: "Issues or questions about a case after the decision is issued", + cy: "Ar ôl Penderfyniad" + }, + comp: { + en: "General Complaints of Compliments", + cy: "Cwynion/Canmoliaethau" + } +}; + +const contactTeamIds = { + plq: "ec48450c-7e26-ec11-a97f-00224800e98c", + ipq: "7fb9e598-7d26-ec11-a97f-00224800e98c", + pcpq: "8ffabad9-7c26-ec11-a97f-00224800e98c", + pi: "5554882f-4606-ec11-a97f-00224800d374", + appadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c", + repadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c", + ceq: "4fddd7e8-7d26-ec11-a97f-00224800e98c", + other: "8ffabad9-7c26-ec11-a97f-00224800e98c", + postdec: "1357e457-7e26-ec11-a97f-00224800e98c", + comp: "1357e457-7e26-ec11-a97f-00224800e98c" +}; + +const validContactTaskRequest = (overrides = {}) => ({ + method: "POST", + headers: { + origin: trustedContactOrigin, + "content-type": "application/json; charset=utf-8" + }, + body: { + contactEmail: "user@example.com", + contactSubject: contactSubjects.plq.en, + contactbody: "Please help with my enquiry.", + typeOfContact: "plq" + }, + ...overrides +}); + +const loadContactTaskEndpoint = (overrides = {}) => { + let tokenCalls = 0; + const relayCalls = []; + const axios = + overrides.axios || + (async (config) => { + relayCalls.push(config); + return { data: { taskId: "crm-1" } }; + }); + const mod = loadModule("pages/api/endpoint/createcrmtask_api.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - isNonEmptyString: () => false, - sanitizeString: (v) => v, - getToken: async () => ({ access_token: "token" }), - hashAPIPath: () => "&hash=expected", - axios: async () => ({ data: {} }), - consoleLogger: () => {} + isNonEmptyString: (value) => + typeof value === "string" && value.trim().length > 0, + sanitizeString: (value) => + typeof value === "string" ? value.trim() : "", + getToken: async () => { + tokenCalls += 1; + return { access_token: "token" }; + }, + hashAPIPath: () => "?hash=expected", + axios, + consoleLogger: () => {}, + process: { + env: { + NEXTAUTH_URL: trustedContactOrigin, + CY_API_ROOT: trustedWelshContactOrigin, + RELAY_ROOT: "https://relay.example/" + } + }, + ...overrides }); - const req = { body: {} }; - const res = createRes(); - await mod.default(req, res); + return { + mod, + getTokenCalls: () => tokenCalls, + getRelayCalls: () => relayCalls + }; +}; - assert.strictEqual(res.state.statusCode, 400); +const assertContactTaskRejectedBeforeRelay = async ({ req, status, code }) => { + const loaded = loadContactTaskEndpoint(); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, status); + assert.strictEqual(res.state.jsonBody.error.code, code); + assert.strictEqual(loaded.getTokenCalls(), 0); + assert.strictEqual(loaded.getRelayCalls().length, 0); +}; + +test("createcrmtask accepts a valid anonymous public Contact Us POST", async () => { + const loaded = loadContactTaskEndpoint(); + const res = createRes(); + + await loaded.mod.default(validContactTaskRequest(), res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.getTokenCalls(), 1); + assert.strictEqual(loaded.getRelayCalls().length, 1); +}); + +test("createcrmtask appends the security reminder after the unchanged enquiry", async () => { + const contactbody = "Please review application reference APP/123456."; + const req = validContactTaskRequest({ + body: { + contactEmail: "applicant@example.com", + contactSubject: contactSubjects.plq.en, + contactbody, + typeOfContact: "plq" + } + }); + const loaded = loadContactTaskEndpoint(); + const res = createRes(); + + await loaded.mod.default(req, res); + + const payload = JSON.parse(loaded.getRelayCalls()[0].data); + const expectedDescription = + "From: applicant@example.com\n\n" + + `${contactbody}\n\n` + + contactTaskSecurityReminder; + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(payload.description, expectedDescription); assert.strictEqual( - res.state.jsonBody.error.code, - "CONTACT_EMAIL_AND_SUBJECT_REQUIRED" + payload.description.startsWith(`From: applicant@example.com\n\n${contactbody}`), + true ); + assert.strictEqual( + payload.subject, + `Contact Us Enquiry - ${contactSubjects.plq.en}` + ); + assert.strictEqual( + payload["ownerid@odata.bind"], + `/teams(${contactTeamIds.plq})` + ); + assert.strictEqual(req.body.contactbody, contactbody); +}); + +test("createcrmtask preserves original URLs before the appended security reminder", async () => { + const contactbody = + "Please review https://example.invalid/application/123"; + const req = validContactTaskRequest({ + body: { + contactEmail: "applicant@example.com", + contactSubject: contactSubjects.plq.en, + contactbody, + typeOfContact: "plq" + } + }); + const loaded = loadContactTaskEndpoint(); + const res = createRes(); + + await loaded.mod.default(req, res); + + const payload = JSON.parse(loaded.getRelayCalls()[0].data); + assert.strictEqual( + payload.description, + `From: applicant@example.com\n\n${contactbody}\n\n${contactTaskSecurityReminder}` + ); + assert.strictEqual( + payload.description.includes( + "https://example.invalid/application/123" + ), + true + ); + assert.strictEqual(payload.description.includes("hxxp"), false); + assert.strictEqual(payload.description.includes("[.]"), false); + assert.strictEqual(req.body.contactbody, contactbody); +}); + +test("createcrmtask preserves a full 800-character message and complete reminder", async () => { + const contactbody = "a".repeat(800); + const req = validContactTaskRequest({ + body: { + contactEmail: "applicant@example.com", + contactSubject: contactSubjects.plq.en, + contactbody, + typeOfContact: "plq" + } + }); + const loaded = loadContactTaskEndpoint(); + const res = createRes(); + + await loaded.mod.default(req, res); + + const payload = JSON.parse(loaded.getRelayCalls()[0].data); + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual( + payload.description, + `From: applicant@example.com\n\n${contactbody}\n\n${contactTaskSecurityReminder}` + ); + assert.strictEqual(req.body.contactbody, contactbody); +}); + +test("createcrmtask accepts a valid portal Contact Us payload", async () => { + const loaded = loadContactTaskEndpoint(); + const req = validContactTaskRequest({ + headers: { + origin: trustedWelshContactOrigin, + "content-type": "application/json" + }, + body: { + contactEmail: "portal.user@example.com", + contactSubject: contactSubjects.repadvice.cy, + contactbody: "Mae angen cymorth arnaf.", + typeOfContact: "repadvice" + } + }); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.getTokenCalls(), 1); + assert.strictEqual(loaded.getRelayCalls().length, 1); +}); + +test("createcrmtask preserves all category-to-team assignments", async () => { + for (const [typeOfContact, teamId] of Object.entries(contactTeamIds)) { + const loaded = loadContactTaskEndpoint(); + const req = validContactTaskRequest({ + body: { + contactEmail: "user@example.com", + contactSubject: contactSubjects[typeOfContact].en, + contactbody: "Please help with my enquiry.", + typeOfContact + } + }); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + const payload = JSON.parse(loaded.getRelayCalls()[0].data); + assert.strictEqual(payload["ownerid@odata.bind"], `/teams(${teamId})`); + } +}); + +for (const method of ["GET", "PUT", "DELETE"]) { + test(`createcrmtask rejects ${method} before CRM processing`, async () => { + const req = validContactTaskRequest({ method }); + const loaded = loadContactTaskEndpoint(); + const res = createRes(); + + await loaded.mod.default(req, 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.getTokenCalls(), 0); + assert.strictEqual(loaded.getRelayCalls().length, 0); + }); +} + +for (const contentType of [ + "application/x-www-form-urlencoded", + "text/plain", + "multipart/form-data; boundary=test" +]) { + test(`createcrmtask rejects ${contentType} before CRM processing`, async () => { + const req = validContactTaskRequest({ + headers: { origin: trustedContactOrigin, "content-type": contentType } + }); + await assertContactTaskRejectedBeforeRelay({ + req, + status: 415, + code: "UNSUPPORTED_MEDIA_TYPE" + }); + }); +} + +test("createcrmtask rejects an untrusted Origin before CRM processing", async () => { + const req = validContactTaskRequest({ + headers: { + origin: "https://attacker.example", + "content-type": "application/json" + } + }); + await assertContactTaskRejectedBeforeRelay({ req, status: 403, code: "FORBIDDEN" }); +}); + +test("createcrmtask accepts each configured trusted Origin", async () => { + for (const origin of [trustedContactOrigin, trustedWelshContactOrigin]) { + const loaded = loadContactTaskEndpoint(); + const req = validContactTaskRequest({ + headers: { origin, "content-type": "application/json" } + }); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.getTokenCalls(), 1); + } +}); + +test("createcrmtask conservatively rejects a missing Origin", async () => { + const req = validContactTaskRequest({ headers: { "content-type": "application/json" } }); + await assertContactTaskRejectedBeforeRelay({ req, status: 403, code: "FORBIDDEN" }); +}); + +for (const body of [null, [], "not-an-object"]) { + test(`createcrmtask rejects malformed body ${JSON.stringify(body)}`, async () => { + const req = validContactTaskRequest({ body }); + await assertContactTaskRejectedBeforeRelay({ + req, + status: 400, + code: "INVALID_REQUEST_BODY" + }); + }); +} + +for (const contactEmail of [undefined, "invalid", `${"a".repeat(190)}@example.com`]) { + test(`createcrmtask rejects invalid email ${String(contactEmail).slice(0, 30)}`, async () => { + const req = validContactTaskRequest({ + body: { + contactEmail, + contactSubject: contactSubjects.plq.en, + contactbody: "Details", + typeOfContact: "plq" + } + }); + await assertContactTaskRejectedBeforeRelay({ + req, + status: 400, + code: "INVALID_CONTACT_EMAIL" + }); + }); +} + +for (const typeOfContact of [undefined, "unsupported"]) { + test(`createcrmtask rejects category ${String(typeOfContact)}`, async () => { + const req = validContactTaskRequest({ + body: { + contactEmail: "user@example.com", + contactSubject: contactSubjects.plq.en, + contactbody: "Details", + typeOfContact + } + }); + await assertContactTaskRejectedBeforeRelay({ + req, + status: 400, + code: "INVALID_CONTACT_TYPE" + }); + }); +} + +for (const contactbody of [undefined, " ", "a".repeat(801)]) { + test(`createcrmtask rejects invalid message ${String(contactbody).slice(0, 20)}`, async () => { + const req = validContactTaskRequest({ + body: { + contactEmail: "user@example.com", + contactSubject: contactSubjects.plq.en, + contactbody, + typeOfContact: "plq" + } + }); + await assertContactTaskRejectedBeforeRelay({ + req, + status: 400, + code: "INVALID_CONTACT_BODY" + }); + }); +} + +test("createcrmtask rejects caller-supplied CRM ownership fields", async () => { + for (const field of ["ownerid", "ownerid@odata.bind", "teamId"]) { + const req = validContactTaskRequest({ + body: { ...validContactTaskRequest().body, [field]: "attacker-owner" } + }); + await assertContactTaskRejectedBeforeRelay({ + req, + status: 400, + code: "UNSUPPORTED_REQUEST_FIELD" + }); + } +}); + +test("createcrmtask accepts existing English and Welsh subjects for every category", async () => { + for (const [typeOfContact, subjects] of Object.entries(contactSubjects)) { + for (const contactSubject of [subjects.en, subjects.cy]) { + const loaded = loadContactTaskEndpoint(); + const req = validContactTaskRequest({ + body: { + contactEmail: "user@example.com", + contactSubject, + contactbody: "Details", + typeOfContact + } + }); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + const payload = JSON.parse(loaded.getRelayCalls()[0].data); + assert.strictEqual(payload.subject, `Contact Us Enquiry - ${contactSubject}`); + } + } +}); + +test("createcrmtask rejects an arbitrary or mismatched subject before CRM processing", async () => { + for (const contactSubject of ["Arbitrary subject", contactSubjects.ipq.en]) { + const req = validContactTaskRequest({ + body: { ...validContactTaskRequest().body, contactSubject } + }); + await assertContactTaskRejectedBeforeRelay({ + req, + status: 400, + code: "INVALID_CONTACT_SUBJECT" + }); + } }); test("createcrmtask catch path returns CRM_TASK_CREATE_FAILED", async () => { + let tokenCalls = 0; const mod = loadModule("pages/api/endpoint/createcrmtask_api.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - isNonEmptyString: () => true, - sanitizeString: (v) => v, - getToken: async () => ({ access_token: "token" }), - hashAPIPath: () => "&hash=expected", + isNonEmptyString: (value) => + typeof value === "string" && value.trim().length > 0, + sanitizeString: (value) => + typeof value === "string" ? value.trim() : "", + getToken: async () => { + tokenCalls += 1; + return { access_token: "token" }; + }, + hashAPIPath: () => "?hash=expected", axios: async () => { throw new Error("create failed"); }, - consoleLogger: () => {} - }); - - const req = { - body: { - contactEmail: "user@test.local", - contactSubject: "Need help", - contactbody: "Details", - typeOfContact: "plq" + consoleLogger: () => {}, + process: { + env: { + NEXTAUTH_URL: trustedContactOrigin, + CY_API_ROOT: trustedWelshContactOrigin, + RELAY_ROOT: "https://relay.example/" + } } - }; + }); const res = createRes(); - await mod.default(req, res); + + await mod.default(validContactTaskRequest(), res); assert.strictEqual(res.state.statusCode, 400); assert.strictEqual(res.state.jsonBody.error.code, "CRM_TASK_CREATE_FAILED"); + assert.strictEqual(tokenCalls, 1); }); test("gethash returns UNAUTHENTICATED when session missing", async () => { diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index 53fcc5cc..d93c8ca5 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -1,6 +1,7 @@ const assert = require("assert"); const fs = require("fs"); const path = require("path"); +const vm = require("vm"); const { loadModule, createRes, @@ -12,6 +13,65 @@ const { const tests = []; const test = (name, fn) => tests.push({ name, fn }); +const TRUSTED_EN_ORIGIN = "https://portal.example.test"; +const TRUSTED_CY_ORIGIN = "https://porth.example.test"; +const deletionTestProcess = { + env: { + NEXTAUTH_URL: TRUSTED_EN_ORIGIN, + CY_API_ROOT: TRUSTED_CY_ORIGIN + } +}; + +const validCaseDeletionRequest = (overrides = {}) => ({ + method: "POST", + headers: { origin: TRUSTED_EN_ORIGIN }, + query: { + container: "c1", + casefolderID: "CASE-SUB", + hash: "expected" + }, + ...overrides +}); + +const validRepDeletionRequest = (overrides = {}) => ({ + method: "POST", + headers: { origin: TRUSTED_EN_ORIGIN }, + query: { + container: "c1", + casefolderID: "CASE-1", + repfile: "representation.json", + hash: "expected" + }, + ...overrides +}); + +const loadDocumentDirectService = (injected = {}) => { + const sourcePath = path.resolve( + __dirname, + "..", + "..", + "actions/services/documentDirectService.js" + ); + let source = fs.readFileSync(sourcePath, "utf8"); + + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + source = source.replace(/export const\s+/g, "const "); + source += ` +module.exports = { + deleteAwaitingSubmissionsFromBlob, + deleteMyRepresentationsFromBlob +};`; + + const context = { + module: { exports: {} }, + exports: {}, + ...injected + }; + + vm.runInNewContext(source, context, { filename: sourcePath }); + return context.module.exports; +}; + test("upload handler returns HASH_REQUIRED for missing hash", async () => { const mod = loadModule("pages/api/file/upload.js", { nextConnect: createNextConnectMock(), @@ -338,7 +398,7 @@ test("deleteblob handler dependency failure returns DELETE_BLOB_FAILED", async ( assert.strictEqual(res.state.jsonBody.error.code, "DELETE_BLOB_FAILED"); }); -test("deleteblobcase handler accepts encoded hash variant", async () => { +test("deleteblobcase handler accepts a valid case folder", async () => { let deletedCaseFolder = null; const mod = loadModule("pages/api/file/deleteblobcase.js", { nextConnect: createNextConnectMock(), @@ -346,29 +406,61 @@ test("deleteblobcase handler accepts encoded hash variant", async () => { getServerSession: async () => ({ user: { id: "c1" } }), authOptions: () => ({}), hashAPIPath: (queryPath) => - queryPath.includes("casefolderID=CASE%2FSUB") + queryPath.includes("casefolderID=CASE-SUB") ? "&hash=expected" : "&hash=other", respondError: respondErrorMock, respondSuccess: respondSuccessMock, + process: deletionTestProcess, deleteBlobCase: async (_container, casefolder) => { deletedCaseFolder = casefolder; return { deleted: true }; } }); - const req = { - query: { - container: "c1", - casefolderID: "CASE/SUB", - hash: "expected" - } - }; + const req = validCaseDeletionRequest(); const res = createRes(); await mod.default.handler(req, res); assert.strictEqual(res.state.statusCode, 200); - assert.strictEqual(deletedCaseFolder, "CASE/SUB"); + assert.strictEqual(deletedCaseFolder, "CASE-SUB"); +}); + +test("deleteblobcase rejects unsafe normalized folder identifiers before deletion", async () => { + for (const casefolderID of [" ", "CASE-1/REP-1", "..", "CASE\\REP"]) { + let deletionCalls = 0; + const mod = loadModule("pages/api/file/deleteblobcase.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + getServerSession: async () => ({ user: { id: "c1" } }), + authOptions: () => ({}), + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + process: deletionTestProcess, + deleteBlobCase: async () => { + deletionCalls += 1; + } + }); + const res = createRes(); + + await mod.default.handler( + { + method: "POST", + headers: { origin: TRUSTED_EN_ORIGIN }, + query: { + container: "c1", + casefolderID, + hash: "expected" + } + }, + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST"); + assert.strictEqual(deletionCalls, 0); + } }); test("deleteblobcase handler dependency failure returns DELETE_BLOB_CASE_FAILED", async () => { @@ -380,12 +472,15 @@ test("deleteblobcase handler dependency failure returns DELETE_BLOB_CASE_FAILED" hashAPIPath: () => "&hash=expected", respondError: respondErrorMock, respondSuccess: respondSuccessMock, + process: deletionTestProcess, deleteBlobCase: async () => { throw new Error("delete case failed"); } }); const req = { + method: "POST", + headers: { origin: TRUSTED_EN_ORIGIN }, query: { container: "c1", casefolderID: "f1", @@ -402,6 +497,94 @@ test("deleteblobcase handler dependency failure returns DELETE_BLOB_CASE_FAILED" ); }); +const loadCaseDeletionSecurity = (overrides = {}) => { + let deletionCalls = 0; + let sessionCalls = 0; + const mod = loadModule("pages/api/file/deleteblobcase.js", { + nextConnect: createNextConnectMock(), + getServerSession: async () => { + sessionCalls += 1; + return { user: { id: "c1" } }; + }, + authOptions: () => ({}), + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + process: deletionTestProcess, + deleteBlobCase: async () => { + deletionCalls += 1; + return { deleted: true }; + }, + ...overrides + }); + + return { + mod, + getDeletionCalls: () => deletionCalls, + getSessionCalls: () => sessionCalls + }; +}; + +for (const method of ["GET", "DELETE"]) { + test(`deleteblobcase rejects ${method} with 405 before deletion`, async () => { + const loaded = loadCaseDeletionSecurity(); + const res = createRes(); + + await loaded.mod.default.handler( + validCaseDeletionRequest({ method }), + 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.getSessionCalls(), 0); + assert.strictEqual(loaded.getDeletionCalls(), 0); + }); +} + +for (const [name, origin] of [ + ["cross-origin", "https://attacker.example.test"], + ["missing Origin", undefined], + ["malformed Origin", "not a valid origin"], + ["Origin containing a path", `${TRUSTED_EN_ORIGIN}/delete`], + ["null Origin", "null"], + ["subdomain spoof", "https://portal.example.test.attacker.test"], + ["hostname suffix spoof", "https://attacker-portal.example.test"] +]) { + test(`deleteblobcase rejects ${name} before session or deletion`, async () => { + const loaded = loadCaseDeletionSecurity(); + const headers = origin === undefined ? {} : { origin }; + const res = createRes(); + + await loaded.mod.default.handler( + validCaseDeletionRequest({ headers }), + res + ); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assert.strictEqual(loaded.getSessionCalls(), 0); + assert.strictEqual(loaded.getDeletionCalls(), 0); + }); +} + +test("deleteblobcase accepts the configured Welsh portal Origin", async () => { + const loaded = loadCaseDeletionSecurity(); + const res = createRes(); + + await loaded.mod.default.handler( + validCaseDeletionRequest({ + headers: { origin: TRUSTED_CY_ORIGIN } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.getSessionCalls(), 1); + assert.strictEqual(loaded.getDeletionCalls(), 1); +}); + test("deleteblobrep handler accepts encoded hash variant", async () => { let deletedRepPath = null; const mod = loadModule("pages/api/file/deleteblobrep.js", { @@ -415,6 +598,7 @@ test("deleteblobrep handler accepts encoded hash variant", async () => { : "&hash=other", respondError: respondErrorMock, respondSuccess: respondSuccessMock, + process: deletionTestProcess, deleteBlobRep: async (_container, repPath) => { deletedRepPath = repPath; return { deleted: true }; @@ -422,6 +606,8 @@ test("deleteblobrep handler accepts encoded hash variant", async () => { }); const req = { + method: "POST", + headers: { origin: TRUSTED_EN_ORIGIN }, query: { container: "c1", casefolderID: "CASE-1", @@ -436,6 +622,49 @@ test("deleteblobrep handler accepts encoded hash variant", async () => { assert.strictEqual(deletedRepPath, "CASE-1/Rep Name"); }); +test("deleteblobrep rejects missing or unsafe folder segments before deletion", async () => { + for (const invalidQuery of [ + { casefolderID: " ", repfile: "REP-1" }, + { casefolderID: "CASE-1", repfile: " " }, + { casefolderID: "CASE-1/REP-1", repfile: "REP-1" }, + { casefolderID: "CASE-1", repfile: "REP-1/FILES" }, + { casefolderID: "CASE-1", repfile: ".." } + ]) { + let deletionCalls = 0; + const mod = loadModule("pages/api/file/deleteblobrep.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + getServerSession: async () => ({ user: { id: "c1" } }), + authOptions: () => ({}), + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + process: deletionTestProcess, + deleteBlobRep: async () => { + deletionCalls += 1; + } + }); + const res = createRes(); + + await mod.default.handler( + { + method: "POST", + headers: { origin: TRUSTED_EN_ORIGIN }, + query: { + container: "c1", + hash: "expected", + ...invalidQuery + } + }, + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST"); + assert.strictEqual(deletionCalls, 0); + } +}); + test("deleteblobrep handler dependency failure returns DELETE_BLOB_REP_FAILED", async () => { const mod = loadModule("pages/api/file/deleteblobrep.js", { nextConnect: createNextConnectMock(), @@ -445,12 +674,15 @@ test("deleteblobrep handler dependency failure returns DELETE_BLOB_REP_FAILED", hashAPIPath: () => "&hash=expected", respondError: respondErrorMock, respondSuccess: respondSuccessMock, + process: deletionTestProcess, deleteBlobRep: async () => { throw new Error("delete rep failed"); } }); const req = { + method: "POST", + headers: { origin: TRUSTED_EN_ORIGIN }, query: { container: "c1", casefolderID: "f1", @@ -465,10 +697,102 @@ test("deleteblobrep handler dependency failure returns DELETE_BLOB_REP_FAILED", assert.strictEqual(res.state.jsonBody.error.code, "DELETE_BLOB_REP_FAILED"); }); +const loadRepDeletionSecurity = (overrides = {}) => { + let deletionCalls = 0; + let sessionCalls = 0; + const mod = loadModule("pages/api/file/deleteblobrep.js", { + nextConnect: createNextConnectMock(), + getServerSession: async () => { + sessionCalls += 1; + return { user: { id: "c1" } }; + }, + authOptions: () => ({}), + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + process: deletionTestProcess, + deleteBlobRep: async () => { + deletionCalls += 1; + return { deleted: true }; + }, + ...overrides + }); + + return { + mod, + getDeletionCalls: () => deletionCalls, + getSessionCalls: () => sessionCalls + }; +}; + +for (const method of ["GET", "DELETE"]) { + test(`deleteblobrep rejects ${method} with 405 before deletion`, async () => { + const loaded = loadRepDeletionSecurity(); + const res = createRes(); + + await loaded.mod.default.handler( + validRepDeletionRequest({ method }), + 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.getSessionCalls(), 0); + assert.strictEqual(loaded.getDeletionCalls(), 0); + }); +} + +for (const [name, origin] of [ + ["cross-origin", "https://attacker.example.test"], + ["missing Origin", undefined], + ["malformed Origin", "not a valid origin"], + ["Origin containing a path", `${TRUSTED_EN_ORIGIN}/delete`], + ["null Origin", "null"], + ["subdomain spoof", "https://portal.example.test.attacker.test"], + ["hostname suffix spoof", "https://attacker-portal.example.test"] +]) { + test(`deleteblobrep rejects ${name} before session or deletion`, async () => { + const loaded = loadRepDeletionSecurity(); + const headers = origin === undefined ? {} : { origin }; + const res = createRes(); + + await loaded.mod.default.handler( + validRepDeletionRequest({ headers }), + res + ); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assert.strictEqual(loaded.getSessionCalls(), 0); + assert.strictEqual(loaded.getDeletionCalls(), 0); + }); +} + +test("deleteblobrep accepts the configured Welsh portal Origin", async () => { + const loaded = loadRepDeletionSecurity(); + const res = createRes(); + + await loaded.mod.default.handler( + validRepDeletionRequest({ + headers: { origin: TRUSTED_CY_ORIGIN } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.getSessionCalls(), 1); + assert.strictEqual(loaded.getDeletionCalls(), 1); +}); + const ordinaryDeletionCases = [ { name: "deleteblobcase", route: "pages/api/file/deleteblobcase.js", + request: { + method: "POST", + headers: { origin: TRUSTED_EN_ORIGIN } + }, query: { container: "user-container", casefolderID: "CASE-1", @@ -479,6 +803,10 @@ const ordinaryDeletionCases = [ { name: "deleteblobrep", route: "pages/api/file/deleteblobrep.js", + request: { + method: "POST", + headers: { origin: TRUSTED_EN_ORIGIN } + }, query: { container: "user-container", casefolderID: "CASE-1", @@ -500,13 +828,17 @@ for (const deletionCase of ordinaryDeletionCases) { hashAPIPath: () => "&hash=expected", respondError: respondErrorMock, respondSuccess: respondSuccessMock, + process: deletionTestProcess, [deletionCase.dependency]: async () => { deletionCalls += 1; } }); const res = createRes(); - await mod.default.handler({ query: deletionCase.query }, res); + await mod.default.handler( + { ...deletionCase.request, query: deletionCase.query }, + res + ); assert.strictEqual(res.state.statusCode, 403); assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); @@ -523,13 +855,17 @@ for (const deletionCase of ordinaryDeletionCases) { hashAPIPath: () => "&hash=expected", respondError: respondErrorMock, respondSuccess: respondSuccessMock, + process: deletionTestProcess, [deletionCase.dependency]: async () => { deletionCalls += 1; } }); const res = createRes(); - await mod.default.handler({ query: deletionCase.query }, res); + await mod.default.handler( + { ...deletionCase.request, query: deletionCase.query }, + res + ); assert.strictEqual(res.state.statusCode, 403); assert.strictEqual(deletionCalls, 0); @@ -549,13 +885,17 @@ for (const deletionCase of ordinaryDeletionCases) { }, respondError: respondErrorMock, respondSuccess: respondSuccessMock, + process: deletionTestProcess, [deletionCase.dependency]: async () => { deletionCalls += 1; } }); const res = createRes(); - await mod.default.handler({ query: deletionCase.query }, res); + await mod.default.handler( + { ...deletionCase.request, query: deletionCase.query }, + res + ); assert.strictEqual(res.state.statusCode, 403); assert.strictEqual(hashCalls, 0); @@ -580,9 +920,11 @@ for (const deletionCase of ordinaryDeletionCases) { hashAPIPath: () => "&hash=expected", respondError: respondErrorMock, respondSuccess: respondSuccessMock, + process: deletionTestProcess, [deletionCase.dependency]: async () => ({ deleted: true }) }); const req = { + ...deletionCase.request, query: deletionCase.query, body: { parsed: ["multipart"] } }; @@ -608,13 +950,17 @@ for (const deletionCase of ordinaryDeletionCases) { hashAPIPath: () => "&hash=different", respondError: respondErrorMock, respondSuccess: respondSuccessMock, + process: deletionTestProcess, [deletionCase.dependency]: async () => { deletionCalls += 1; } }); const res = createRes(); - await mod.default.handler({ query: deletionCase.query }, res); + await mod.default.handler( + { ...deletionCase.request, query: deletionCase.query }, + res + ); assert.strictEqual(res.state.statusCode, 400); assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH"); @@ -631,6 +977,7 @@ for (const deletionCase of ordinaryDeletionCases) { hashAPIPath: () => "&hash=expected", respondError: respondErrorMock, respondSuccess: respondSuccessMock, + process: deletionTestProcess, [deletionCase.dependency]: async (...args) => { deletionCalls.push(args); return { deleted: true }; @@ -638,7 +985,10 @@ for (const deletionCase of ordinaryDeletionCases) { }); const res = createRes(); - await mod.default.handler({ query: deletionCase.query }, res); + await mod.default.handler( + { ...deletionCase.request, query: deletionCase.query }, + res + ); assert.strictEqual(res.state.statusCode, 200); assert.strictEqual(deletionCalls.length, 1); @@ -784,7 +1134,7 @@ test("admin storage caller uses dedicated POST deletion while ordinary caller re assert.doesNotMatch(adminSource, /deleteMyRepresentationsFromBlob/); assert.match( ordinarySource, - /buildFileQuery\("\/api\/file\/deleteblobrep"[\s\S]*getSignedFileJson\(queryUrl\)/ + /buildFileQuery\("\/api\/file\/deleteblobrep"[\s\S]*postSignedFileJson\(queryUrl\)/ ); }); @@ -971,28 +1321,36 @@ test("admin case deletion dependency failure returns DELETE_BLOB_CASE_FAILED", a 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" - ); +test("case and representation deletion use signed POST", async () => { + const calls = []; + const mod = loadDocumentDirectService({ + buildFileQuery: (route, params) => { + const query = Object.entries(params) + .map(([key, value]) => `${key}=${value}`) + .join("&"); + return `${route}?${query}`; + }, + postSignedFileJson: async (...args) => { + calls.push(["post", ...args]); + return { deleted: true }; + }, + getSignedFileJson: async (...args) => { + calls.push(["get", ...args]); + return { deleted: true }; + }, + consoleLogger: () => {} + }); - 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\)/ - ); + await mod.deleteAwaitingSubmissionsFromBlob("c1", "CASE-1"); + await mod.deleteMyRepresentationsFromBlob("c1", "CASE-1", "REP-1"); + + assert.deepStrictEqual(JSON.parse(JSON.stringify(calls)), [ + ["post", "/api/file/deleteblobcase?container=c1&casefolderID=CASE-1"], + [ + "post", + "/api/file/deleteblobrep?container=c1&casefolderID=CASE-1&repfile=REP-1" + ] + ]); }); const loadAdminRepJsonEdit = (overrides = {}) => { @@ -5684,6 +6042,10 @@ test("whole-representation deletion remains cross-user protected with zero Azure await loaded.mod.default.handler( { + method: "POST", + headers: { + origin: "http://localhost:3000" + }, query: { container: "organisation-container", casefolderID: "CASE-1", diff --git a/tests/phase22/azurestorage-helper-behaviour.test.cjs b/tests/phase22/azurestorage-helper-behaviour.test.cjs index e9f68049..1ead7376 100644 --- a/tests/phase22/azurestorage-helper-behaviour.test.cjs +++ b/tests/phase22/azurestorage-helper-behaviour.test.cjs @@ -33,9 +33,87 @@ const loadAzureStorageHelperModule = (injected = {}) => { return context.module.exports; }; +const loadRecursiveDeletionHelpers = ({ + blobNames = [], + deleteBlob = () => Promise.resolve() +} = {}) => { + const filePath = path.join(rootDir, "actions", "azurestorage.js"); + const source = fs.readFileSync(filePath, "utf8"); + const start = source.indexOf("const isSafeDeletionPathSegment ="); + const end = source.indexOf("export const uploadFile ="); + + if (start < 0 || end < 0 || end <= start) { + throw new Error("Unable to isolate recursive deletion helper block"); + } + + const listedPrefixes = []; + const deletedBlobNames = []; + const containerClient = { + async *listBlobsFlat({ prefix }) { + listedPrefixes.push(prefix); + for (const name of blobNames.filter((blobName) => + blobName.startsWith(prefix) + )) { + yield { name }; + } + }, + deleteBlob(name) { + deletedBlobNames.push(name); + return deleteBlob(name); + }, + getBlockBlobClient() { + return { exists: async () => true }; + } + }; + + let helperSource = source + .slice(start, end) + .replace(/export const /g, "const "); + helperSource += + "\nmodule.exports = { deleteBlobCase, deleteBlobRep };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + console: { log: () => {} }, + DefaultAzureCredential: class {}, + createContainerSas: async () => "sas-token", + STORAGE_PATH: "https://storage.example.test", + ContainerClient: class { + constructor() { + return containerClient; + } + } + }; + + vm.runInNewContext(helperSource, context, { filename: filePath }); + + return { + ...context.module.exports, + listedPrefixes, + deletedBlobNames + }; +}; + const tests = []; const test = (name, fn) => tests.push({ name, fn }); +const createDeferred = () => { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +}; + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + test("azurestorage helper builds download path with default encoding", () => { const mod = loadAzureStorageHelperModule(); @@ -103,6 +181,159 @@ test("azurestorage helper builds hash metadata map with stable keys", () => { }); }); +test("case deletion uses a complete folder prefix and excludes lexical siblings", async () => { + const loaded = loadRecursiveDeletionHelpers({ + blobNames: [ + "TMP-ABC/TMP-ABC_appeal.json", + "TMP-ABC/files/evidence.pdf", + "TMP-ABC1/TMP-ABC1_appeal.json", + "TMP-ABCD/files/sibling.pdf" + ] + }); + + await loaded.deleteBlobCase("container-1", "TMP-ABC"); + + assert.deepStrictEqual(loaded.listedPrefixes, ["TMP-ABC/"]); + assert.deepStrictEqual(loaded.deletedBlobNames, [ + "TMP-ABC/TMP-ABC_appeal.json", + "TMP-ABC/files/evidence.pdf" + ]); +}); + +test("representation deletion uses a complete folder prefix and preserves parent and siblings", async () => { + const loaded = loadRecursiveDeletionHelpers({ + blobNames: [ + "CASE-1/CASE-1_case.json", + "CASE-1/REP-1/REP-1_rep.json", + "CASE-1/REP-1/files/evidence.pdf", + "CASE-1/REP-10/REP-10_rep.json", + "CASE-1/REP-2/REP-2_rep.json" + ] + }); + + await loaded.deleteBlobRep("container-1", "CASE-1/REP-1"); + + assert.deepStrictEqual(loaded.listedPrefixes, ["CASE-1/REP-1/"]); + assert.deepStrictEqual(loaded.deletedBlobNames, [ + "CASE-1/REP-1/REP-1_rep.json", + "CASE-1/REP-1/files/evidence.pdf" + ]); +}); + +test("recursive deletion helpers reject empty and unsafe resource paths before listing", async () => { + for (const [helperName, unsafePath] of [ + ["deleteBlobCase", ""], + ["deleteBlobCase", "CASE-1/REP-1"], + ["deleteBlobRep", "CASE-1"], + ["deleteBlobRep", "CASE-1/REP-1/FILES"], + ["deleteBlobRep", "CASE-1/../REP-1"] + ]) { + const loaded = loadRecursiveDeletionHelpers(); + + await assert.rejects( + () => loaded[helperName]("container-1", unsafePath), + /Invalid deletion resource path/ + ); + assert.deepStrictEqual(loaded.listedPrefixes, []); + } +}); + +for (const deletionCase of [ + { + helperName: "deleteBlobCase", + resourcePath: "TMP-ABC", + blobName: "TMP-ABC/files/evidence.pdf" + }, + { + helperName: "deleteBlobRep", + resourcePath: "CASE-1/REP-1", + blobName: "CASE-1/REP-1/files/evidence.pdf" + } +]) { + test(`${deletionCase.helperName} remains pending until Azure deletion completes`, async () => { + const deletion = createDeferred(); + const deletionStarted = createDeferred(); + const loaded = loadRecursiveDeletionHelpers({ + blobNames: [deletionCase.blobName], + deleteBlob: () => { + deletionStarted.resolve(); + return deletion.promise; + } + }); + let settled = false; + + const helperPromise = loaded[ + deletionCase.helperName + ]("container-1", deletionCase.resourcePath).then((result) => { + settled = true; + return result; + }); + + await deletionStarted.promise; + await flushMicrotasks(); + assert.strictEqual(settled, false); + + deletion.resolve(); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(await helperPromise)), + { deleted: deletionCase.resourcePath } + ); + }); + + test(`${deletionCase.helperName} propagates Azure deletion rejection`, async () => { + const deletionError = new Error("controlled Azure deletion failure"); + const rejectedDeletion = Promise.reject(deletionError); + rejectedDeletion.catch(() => {}); + const loaded = loadRecursiveDeletionHelpers({ + blobNames: [deletionCase.blobName], + deleteBlob: () => rejectedDeletion + }); + + await assert.rejects( + () => + loaded[deletionCase.helperName]( + "container-1", + deletionCase.resourcePath + ), + /controlled Azure deletion failure/ + ); + }); +} + +test("recursive deletion does not complete until all selected blob deletions complete", async () => { + const firstDeletion = createDeferred(); + const secondDeletion = createDeferred(); + const deletionPromises = new Map([ + ["TMP-ABC/files/first.pdf", firstDeletion.promise], + ["TMP-ABC/files/second.pdf", secondDeletion.promise] + ]); + const loaded = loadRecursiveDeletionHelpers({ + blobNames: [...deletionPromises.keys()], + deleteBlob: (name) => deletionPromises.get(name) + }); + let settled = false; + + const helperPromise = loaded + .deleteBlobCase("container-1", "TMP-ABC") + .then((result) => { + settled = true; + return result; + }); + + await flushMicrotasks(); + assert.strictEqual(settled, false); + + firstDeletion.resolve(); + await flushMicrotasks(); + assert.strictEqual(settled, false); + + secondDeletion.resolve(); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(await helperPromise)), + { deleted: "TMP-ABC" } + ); +}); + const run = async () => { let passed = 0; for (const currentTest of tests) { diff --git a/tests/phase7/service-behaviour.test.cjs b/tests/phase7/service-behaviour.test.cjs index e53a3071..286ebcb9 100644 --- a/tests/phase7/service-behaviour.test.cjs +++ b/tests/phase7/service-behaviour.test.cjs @@ -1,4 +1,6 @@ const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); const { createAxiosMock, createLoggerMock, @@ -208,7 +210,21 @@ test("document delete blob flows use signer hash", async () => { axios, BASE_URL: "", consoleLogger: logger.consoleLogger, - hashAPIPath: () => "&hash=fallback" + hashAPIPath: () => "&hash=fallback", + postSignedFileJson: async (queryUrl, data, config = {}) => { + const hashResponse = await axios.get( + "/api/endpoint/gethash_api?path=" + encodeURIComponent(queryUrl) + ); + + const response = await axios({ + method: "post", + url: queryUrl + hashResponse.data.hash, + data, + ...config + }); + + return response.data; + } }); const one = await document.deleteAwaitingSubmissionsFromBlob( @@ -384,6 +400,164 @@ test("notify/sendEmail logs and rethrows on failure", async () => { assert.strictEqual(logger.calls.length, 1); }); +test("notify/sendContactUsConfirmation posts only the dedicated payload", async () => { + const axios = createAxiosMock(); + const logger = createLoggerMock(); + + axios.requestHandler = async () => ({ data: { sent: true } }); + + const notify = loadServiceModule("notifyDirectService.js", { + axios, + consoleLogger: logger.consoleLogger + }); + const formValues = { + contactEmail: "person@example.com", + contactSubject: "Questions about Planning legislation or policy", + contactbody: "Please review https://example.invalid", + typeOfContact: "plq", + ignored: "not sent" + }; + + const result = await notify.sendContactUsConfirmation(formValues, "cy"); + + assert.deepStrictEqual(normalize(result), { sent: true }); + assert.strictEqual( + axios.calls[0].config.url, + "/api/email/contactus-confirmation" + ); + assert.strictEqual(axios.calls[0].config.method, "post"); + assert.deepStrictEqual(normalize(axios.calls[0].config.data), { + contactEmail: "person@example.com", + contactSubject: "Questions about Planning legislation or policy", + contactbody: "Please review https://example.invalid", + typeOfContact: "plq", + language: "cy" + }); +}); + +test("notify/sendContactUsConfirmation logs and rethrows on failure", async () => { + const axios = createAxiosMock(); + const logger = createLoggerMock(); + const error = createAxiosError(400, "Bad Request"); + + axios.requestHandler = async () => Promise.reject(error); + + const notify = loadServiceModule("notifyDirectService.js", { + axios, + consoleLogger: logger.consoleLogger + }); + + await assert.rejects( + notify.sendContactUsConfirmation( + { + contactEmail: "person@example.com", + contactSubject: "Other enquiry for PEDW", + contactbody: "Message", + typeOfContact: "other" + }, + "en" + ), + (thrown) => thrown === error + ); + assert.strictEqual(logger.calls.length, 1); +}); + +test("notify/sendContactUsPETNotification posts only the dedicated PET payload", async () => { + const axios = createAxiosMock(); + const logger = createLoggerMock(); + + axios.requestHandler = async () => ({ data: { sent: true } }); + + const notify = loadServiceModule("notifyDirectService.js", { + axios, + consoleLogger: logger.consoleLogger + }); + const formValues = { + contactEmail: "person@example.com", + contactSubject: "Questions about Planning legislation or policy", + contactbody: "Please review https://example.invalid", + typeOfContact: "plq", + ignored: "not sent" + }; + + const result = await notify.sendContactUsPETNotification(formValues); + + assert.deepStrictEqual(normalize(result), { sent: true }); + assert.strictEqual( + axios.calls[0].config.url, + "/api/email/contactus-pet-notification" + ); + assert.strictEqual(axios.calls[0].config.method, "post"); + assert.deepStrictEqual(normalize(axios.calls[0].config.data), { + contactEmail: "person@example.com", + contactSubject: "Questions about Planning legislation or policy", + contactbody: "Please review https://example.invalid", + typeOfContact: "plq" + }); +}); + +test("notify/sendContactUsPETNotification logs and rethrows on failure", async () => { + const axios = createAxiosMock(); + const logger = createLoggerMock(); + const error = createAxiosError(400, "Bad Request"); + + axios.requestHandler = async () => Promise.reject(error); + + const notify = loadServiceModule("notifyDirectService.js", { + axios, + consoleLogger: logger.consoleLogger + }); + + await assert.rejects( + notify.sendContactUsPETNotification({ + contactEmail: "person@example.com", + contactSubject: "Questions about Planning legislation or policy", + contactbody: "Message", + typeOfContact: "plq" + }), + (thrown) => thrown === error + ); + assert.strictEqual(logger.calls.length, 1); +}); + +test("public and portal Contact Us pages preserve PET gating and submission ordering", async () => { + const rootDir = path.resolve(__dirname, "..", ".."); + + for (const relativePath of ["pages/contactus.js", "pages/myportal/contactus.js"]) { + const source = fs.readFileSync(path.join(rootDir, relativePath), "utf8"); + const confirmationCall = source.indexOf("sendContactEmail(formValues);"); + const crmCall = source.indexOf("createCRMTask(formValues);"); + const petCondition = source.indexOf( + 'formValues.typeOfContact == "plq" &&' + ); + const petCall = source.indexOf( + "sendPETEmail(formValues);" + ); + + assert.notStrictEqual(confirmationCall, -1); + assert.notStrictEqual(crmCall, -1); + assert.notStrictEqual(petCondition, -1); + assert.notStrictEqual(petCall, -1); + assert.strictEqual( + confirmationCall < crmCall && + crmCall < petCondition && + petCondition < petCall, + true + ); + assert.strictEqual( + source.includes("sendContactUsPETNotification(formValues)"), + true + ); + assert.strictEqual( + source.includes("sendContactUsConfirmation("), + true + ); + assert.strictEqual(source.includes("sendEmail(templateId,"), false); + assert.strictEqual(source.includes("props.petEmail"), false); + assert.strictEqual(source.includes("process.env.PETEMAIL"), false); + } +}); + test("integration/createCRMTask posts payload and returns data", async () => { const axios = createAxiosMock(); const logger = createLoggerMock(); diff --git a/tests/security/security-headers.test.cjs b/tests/security/security-headers.test.cjs new file mode 100644 index 00000000..cfe41763 --- /dev/null +++ b/tests/security/security-headers.test.cjs @@ -0,0 +1,318 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const { pathToRegexp } = require("next/dist/compiled/path-to-regexp"); +const { + createNextConnectMock, + createRes, + loadModule, + respondErrorMock, + respondSuccessMock +} = require("../phase21/_shared.cjs"); + +const rootDirectory = path.join(__dirname, "..", ".."); + +const toHeaderObject = (headers) => + Object.fromEntries(headers.map(({ key, value }) => [key, value])); + +const getMatchingHeaders = (configuredRules, pathname) => + configuredRules + .filter(({ source }) => pathToRegexp(source).test(pathname)) + .flatMap(({ headers }) => headers); + +const getCacheControl = (configuredRules, pathname) => + getMatchingHeaders(configuredRules, pathname).find( + ({ key }) => key.toLowerCase() === "cache-control" + )?.value; + +const applyConfiguredHeaders = (configuredRules, pathname, res) => { + for (const { key, value } of getMatchingHeaders( + configuredRules, + pathname + )) { + res.setHeader(key, value); + } +}; + +const loadAdminNoStore = () => { + const adminAuthPath = path.join(rootDirectory, "lib", "admin", "adminAuth.js"); + const moduleSource = fs.readFileSync(adminAuthPath, "utf8"); + const declaration = moduleSource.match( + /export const setAdminAuthNoStore = \(res\) => \{[\s\S]*?\n\};/ + ); + + assert.ok(declaration, "Expected to find setAdminAuthNoStore declaration"); + + const source = declaration[0] + .replace("export const", "const") + .concat("\nmodule.exports = { setAdminAuthNoStore };\n"); + const context = { + module: { exports: {} } + }; + + vm.runInNewContext(source, context, { filename: adminAuthPath }); + return context.module.exports.setAdminAuthNoStore; +}; + +const loadMiddleware = () => { + const middlewarePath = path.join(rootDirectory, "middleware.js"); + const source = fs + .readFileSync(middlewarePath, "utf8") + .replace( + 'import { NextResponse } from "next/server";', + 'const { NextResponse } = require("next/server");' + ) + .replace("export function middleware", "function middleware") + .concat("\nmodule.exports = { middleware };\n"); + const context = { + Buffer, + Headers, + module: { exports: {} }, + process: { + env: { + NEXTAUTH_URL: "https://example.test", + NODE_ENV: "production" + } + }, + require: (moduleName) => { + assert.strictEqual(moduleName, "next/server"); + return { + NextResponse: { + next: () => ({ headers: new Headers() }) + } + }; + }, + crypto: { + randomUUID: () => "00000000-0000-4000-8000-000000000000" + } + }; + + vm.runInNewContext(source, context, { filename: middlewarePath }); + return context.module.exports.middleware; +}; + +const run = async () => { + const nextConfig = require(path.join(rootDirectory, "next.config.js")); + const configuredRules = await nextConfig.headers(); + const globalRule = configuredRules.find( + ({ source }) => source === "/(.*)?" + ); + + assert.ok(globalRule, "Expected the global Next.js security-header rule"); + assert.deepStrictEqual(toHeaderObject(globalRule.headers), { + "X-DNS-Prefetch-Control": "on", + "X-Frame-Options": "SAMEORIGIN", + "X-Content-Type-Options": "nosniff", + "X-Permitted-Cross-Domain-Policies": "none", + "Referrer-Policy": "origin-when-cross-origin", + "Strict-Transport-Security": + "max-age=63072000; includeSubDomains; preload", + "X-XSS-Protection": "0" + }); + + const sensitivePaths = [ + "/api/admin/auth/session", + "/api/email/notify", + "/api/file/getprogressobjblob", + "/api/file/nested/private/resource", + "/api/myportal/get-my-cases", + "/api/subscriptions/get-watched-cases", + "/api/auth/resolve-locale", + "/api/endpoint/createaccount_api", + "/api/endpoint/getaccounts_api", + "/api/endpoint/getemailaccountcheck_api", + "/api/endpoint/getpersonalaccount_api", + "/api/endpoint/getportallogin_api", + "/api/endpoint/getportalloginproxy_api", + "/api/endpoint/getpreferredlanguage_api", + "/api/endpoint/updateaccount_api", + "/api/endpoint/gethash_api", + "/api/endpoint/getmycases_api", + "/api/endpoint/getmylpacases_api", + "/api/endpoint/getmyrepresentations_api", + "/api/endpoint/getmyrepresentationsproxy_api", + "/api/endpoint/getawaitingsubmission_api", + "/api/endpoint/getawaitingsubmissionproxy_api", + "/api/endpoint/getwatchedcases_api", + "/api/endpoint/getwatchedcasesproxy_api", + "/api/endpoint/createwatchedcases_api", + "/api/endpoint/deletewatchedcases_api", + "/api/endpoint/deletewatchedcasesproxy_api", + "/api/endpoint/deleteawaitingsubmissions_api", + "/api/endpoint/getbasicpartsaveddetails_api", + "/api/endpoint/getpartsavedappeal_api", + "/api/endpoint/getappealid_api", + "/api/endpoint/getappealpdfdocuments_api", + "/api/endpoint/getportalmoduledetails_api", + "/api/endpoint/getportalmoduledetailsproxy_api", + "/api/endpoint/patchcase_api", + "/api/endpoint/updatecase_api", + "/api/endpoint/createcrmtask_api" + ]; + + for (const pathname of sensitivePaths) { + assert.strictEqual( + getCacheControl(configuredRules, pathname), + "no-store", + `Expected ${pathname} to receive Cache-Control: no-store` + ); + } + + const excludedPaths = [ + "/", + "/search", + "/dnsapplications", + "/_next/static/chunks/example.js", + "/_next/image", + "/api/search/advanced", + "/api/cases/get-incident-by-id", + "/api/documents/download/example", + "/api/notices", + "/api/health", + "/api/auth/session", + "/api/auth/csrf", + "/api/endpoint/getbasicsearch_api", + "/api/endpoint/getrepresentations_api", + "/api/endpoint/getappealtypes_api", + "/api/endpoint/getpicklists_api" + ]; + + for (const pathname of excludedPaths) { + assert.strictEqual( + getCacheControl(configuredRules, pathname), + undefined, + `Expected ${pathname} to remain outside the no-store rules` + ); + } + + const middleware = loadMiddleware(); + const response = middleware({ headers: new Headers() }); + + assert.strictEqual(response.headers.get("X-XSS-Protection"), "0"); + assert.strictEqual(response.headers.get("X-Content-Type-Options"), "nosniff"); + assert.strictEqual(response.headers.get("X-Frame-Options"), "DENY"); + assert.strictEqual( + response.headers.get("Referrer-Policy"), + "strict-origin-when-cross-origin" + ); + assert.strictEqual( + response.headers.get("Permissions-Policy"), + "geolocation=(), camera=(), microphone=(), fullscreen=(self)" + ); + assert.strictEqual( + response.headers.get("Strict-Transport-Security"), + "max-age=63072000; includeSubDomains; preload" + ); + assert.match( + response.headers.get("Content-Security-Policy"), + /default-src 'self'/ + ); + + const ordinaryResponse = createRes(); + applyConfiguredHeaders( + configuredRules, + "/api/endpoint/getpersonalaccount_api", + ordinaryResponse + ); + respondSuccessMock(ordinaryResponse, { ok: true }); + assert.strictEqual( + ordinaryResponse.state.headers["cache-control"], + "no-store" + ); + + const validationErrorResponse = createRes(); + applyConfiguredHeaders( + configuredRules, + "/api/endpoint/getpersonalaccount_api", + validationErrorResponse + ); + respondErrorMock(validationErrorResponse, { + status: 400, + code: "CONTACT_ID_REQUIRED", + message: "contactid is required" + }); + assert.strictEqual( + validationErrorResponse.state.headers["cache-control"], + "no-store" + ); + + const adminResponse = createRes(); + applyConfiguredHeaders( + configuredRules, + "/api/admin/auth/session", + adminResponse + ); + loadAdminNoStore()(adminResponse); + assert.strictEqual( + adminResponse.state.headers["cache-control"], + "no-store, max-age=0" + ); + assert.strictEqual(adminResponse.state.headers.pragma, "no-cache"); + + const fileHandler = loadModule("pages/api/file/getrepsblob.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + getServerSession: async () => ({ user: { id: "owner-container" } }), + authOptions: () => ({}), + readAdminSessionCookie: () => null, + validateAdminSession: () => false, + getRepsBlobs: async () => [{ name: "representation.json" }], + downloadAllRepsFiles: async () => [{ id: "representation" }], + consoleLogger: () => {}, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock + }); + const fileResponse = createRes(); + applyConfiguredHeaders( + configuredRules, + "/api/file/getrepsblob", + fileResponse + ); + await fileHandler.default.handler( + { + query: { + container: "owner-container", + hash: "expected" + } + }, + fileResponse + ); + assert.strictEqual(fileResponse.state.statusCode, 200); + assert.strictEqual( + fileResponse.state.headers["cache-control"], + "no-store" + ); + + const publicCacheDirective = + "public, s-maxage=10, stale-while-revalidate=59"; + for (const relativePath of [ + "pages/dns/index.js", + "pages/dnsapplications.js", + "pages/myportal/dnsapplications.js" + ]) { + const pageSource = fs.readFileSync( + path.join(rootDirectory, relativePath), + "utf8" + ); + assert.match( + pageSource, + new RegExp( + `res\\.setHeader\\(\\s*["']Cache-Control["']\\s*,\\s*["']${publicCacheDirective.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*\\)` + ), + `Expected ${relativePath} to preserve its public cache directive` + ); + } + + console.log("Security-header regression tests passed (10/10)."); +}; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} + +module.exports = run;