fix(security): harden Contact Us PET notifications
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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([
|
||||
"petEmail",
|
||||
"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(requestBody.petEmail);
|
||||
const contactEmail = sanitizeString(requestBody.contactEmail);
|
||||
const contactSubject = sanitizeString(requestBody.contactSubject);
|
||||
const contactBody = requestBody.contactbody;
|
||||
|
||||
if (
|
||||
typeof requestBody.petEmail !== "string" ||
|
||||
!isNonEmptyString(petEmail) ||
|
||||
petEmail.length > 200 ||
|
||||
!emailRegex.test(petEmail)
|
||||
) {
|
||||
return validationError(
|
||||
res,
|
||||
"INVALID_PET_EMAIL",
|
||||
"petEmail must be a valid email address of 200 characters or fewer"
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user