fix(security): harden Contact Us PET notifications

This commit is contained in:
2026-11-09 13:41:31 +00:00
parent 3c0a9ef721
commit f4f8075af3
6 changed files with 740 additions and 47 deletions
+391 -23
View File
@@ -356,7 +356,10 @@ const contactSubjects = {
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" },
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"
@@ -408,7 +411,8 @@ const loadContactConfirmationEndpoint = ({ sendEmail } = {}) => {
const mod = loadModule("pages/api/email/contactus-confirmation.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
sanitizeString: (value) => (typeof value === "string" ? value.trim() : ""),
sanitizeString: (value) =>
typeof value === "string" ? value.trim() : "",
isNonEmptyString: (value) =>
typeof value === "string" && value.trim().length > 0,
removeContactUsEmailLinks,
@@ -505,7 +509,11 @@ test("Contact Us confirmation is POST-only with Allow header", async () => {
});
});
for (const contentType of [undefined, "text/plain", "application/x-www-form-urlencoded"]) {
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: {
@@ -521,7 +529,12 @@ for (const contentType of [undefined, "text/plain", "application/x-www-form-urle
});
}
for (const origin of [undefined, "null", "https://attacker.example", `${trustedContactOrigin}/path`]) {
for (const origin of [
undefined,
"null",
"https://attacker.example",
`${trustedContactOrigin}/path`
]) {
test(`Contact Us confirmation rejects origin ${String(origin)}`, async () => {
const req = validContactConfirmationRequest({
headers: {
@@ -529,13 +542,20 @@ for (const origin of [undefined, "null", "https://attacker.example", `${trustedC
"content-type": "application/json"
}
});
await assertContactConfirmationRejected({ req, status: 403, code: "FORBIDDEN" });
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]) {
for (const origin of [
trustedContactOrigin,
trustedWelshContactOrigin
]) {
const loaded = loadContactConfirmationEndpoint();
const req = validContactConfirmationRequest({
headers: { origin, "content-type": "application/json" }
@@ -566,7 +586,10 @@ for (const bodyOverride of [
{ contactSubject: undefined }
]) {
test(`Contact Us confirmation enforces exact fields ${Object.keys(bodyOverride).join(",")}`, async () => {
const body = { ...validContactConfirmationRequest().body, ...bodyOverride };
const body = {
...validContactConfirmationRequest().body,
...bodyOverride
};
if (Object.values(bodyOverride).includes(undefined)) {
for (const key of Object.keys(bodyOverride)) delete body[key];
}
@@ -578,11 +601,18 @@ for (const bodyOverride of [
});
}
for (const contactEmail of [undefined, "invalid", `${"a".repeat(190)}@example.com`]) {
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 }
body: {
...validContactConfirmationRequest().body,
contactEmail
}
}),
status: 400,
code: "INVALID_CONTACT_EMAIL"
@@ -594,7 +624,10 @@ for (const typeOfContact of [undefined, "unsupported"]) {
test(`Contact Us confirmation rejects category ${String(typeOfContact)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: { ...validContactConfirmationRequest().body, typeOfContact }
body: {
...validContactConfirmationRequest().body,
typeOfContact
}
}),
status: 400,
code: "INVALID_CONTACT_TYPE"
@@ -656,18 +689,24 @@ test("Contact Us confirmation selects English template and exact safe personalis
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(loaded.notifyCalls.length, 1);
assert.strictEqual(loaded.notifyCalls[0][0], contactConfirmationTemplateEn);
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(
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);
});
});
@@ -690,8 +729,14 @@ test("Contact Us confirmation selects Welsh template and supports an 800-charact
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);
assert.strictEqual(
loaded.notifyCalls[0][0],
contactConfirmationTemplateCy
);
assert.strictEqual(
loaded.notifyCalls[0][2].personalisation.contactbody,
message
);
});
});
@@ -717,6 +762,329 @@ test("Contact Us confirmation returns a bounded error when Notify fails", async
});
});
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: {
petEmail: "pet@example.com",
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"
});
});
for (const petEmail of [
undefined,
"invalid",
`${"a".repeat(190)}@example.com`
]) {
test(`Contact Us PET notification rejects recipient ${String(petEmail).slice(0, 30)}`, async () => {
await assertContactPetRejected({
req: validContactPetRequest({
body: { ...validContactPetRequest().body, petEmail }
}),
status: 400,
code: "INVALID_PET_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) {
+105
View File
@@ -1,4 +1,6 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const {
createAxiosMock,
createLoggerMock,
@@ -460,6 +462,109 @@ test("notify/sendContactUsConfirmation logs and rethrows on failure", async () =
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,
"pet@example.com"
);
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), {
petEmail: "pet@example.com",
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"
},
"pet@example.com"
),
(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, props.petEmail);"
);
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, petEmail)"
),
true
);
assert.strictEqual(
source.includes("sendContactUsConfirmation("),
true
);
assert.strictEqual(source.includes("sendEmail(templateId,"), false);
}
});
test("integration/createCRMTask posts payload and returns data", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();