Skip to content

Commit 9f75ae4

Browse files
authored
Merge branch 'v2' into kls-magic-link
2 parents d8d6103 + cd5f8e4 commit 9f75ae4

11 files changed

Lines changed: 299 additions & 27 deletions

File tree

model/src/data-model/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ export type ExitOptions = {
188188
format?: "STATE" | "WEBHOOK";
189189
};
190190

191+
export type Analytics = {
192+
gtmId1: string;
193+
gtmId2: string;
194+
matomoId: string;
195+
matomoUrl: string;
196+
};
197+
191198
/**
192199
* `FormDefinition` is a typescript representation of `Schema`
193200
*/
@@ -215,4 +222,10 @@ export type FormDefinition = {
215222
toggle?: boolean | string | undefined;
216223
retryTimeoutSeconds?: number | undefined;
217224
magicLinkConfig?: string | undefined;
225+
allowedDomains?: string[] | undefined;
226+
invalidDomainRedirect?: string | undefined;
227+
analytics?: Analytics;
228+
webhookHmacSharedKey?: string | undefined;
229+
fullStartPage?: string | undefined;
230+
serviceName?: string | undefined;
218231
};

model/src/schema/schema.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,13 @@ const feeSchema = joi.object().keys({
209209
prefix: joi.string().optional(),
210210
});
211211

212+
const analyticsSchema = joi.object().keys({
213+
gtmId1: joi.string().allow("").optional(),
214+
gtmId2: joi.string().allow("").optional(),
215+
matomoId: joi.string().allow("").optional(),
216+
matomoUrl: joi.string().uri().allow("").optional(),
217+
});
218+
212219
const multiApiKeySchema = joi.object({
213220
test: joi.string().optional(),
214221
smoke: joi.string().optional(),
@@ -347,6 +354,12 @@ export const Schema = joi
347354
toggleRedirect: joi.string().optional(),
348355
retryTimeoutSeconds: joi.number().optional(),
349356
magicLinkConfig: joi.string().optional(),
357+
allowedDomains: joi.array().items(joi.string()).optional(),
358+
invalidDomainRedirect: joi.string().optional(),
359+
analytics: analyticsSchema.optional(),
360+
webhookHmacSharedKey: joi.string().optional(),
361+
fullStartPage: joi.string().optional(),
362+
serviceName: joi.string().optional(),
350363
});
351364

352365
/**

runner/src/server/forms/ReportAnOutbreak.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"metadata": {},
33
"authentication": true,
44
"toggle": "${magicLinkToggle}",
5+
"analytics": {
6+
"gtmId1": "GTM-MM6VPCXX"
7+
},
58
"startPage": "/start",
69
"pages": [
710
{

runner/src/server/plugins/engine/pageControllers/MagicLinkSubmissionPageController.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { PageController } from "./PageController";
33
import { redirectTo } from "../helpers";
44
import { HapiRequest, HapiResponseToolkit } from "server/types";
55
import { createHmac } from "src/server/utils/hmac";
6+
import { isAllowedDomain } from "src/server/utils/domain";
67

78
// Shared options for cookie settings
89
const getCookieOptions = (timeRemaining) => ({
@@ -132,6 +133,18 @@ export class MagicLinkSubmissionPageController extends PageController {
132133
return redirectTo(request, h, `/${model.basePath}/start`);
133134
}
134135

136+
const allowedEmailDomains = this.model.def.allowedDomains ?? [];
137+
//hardcoded start page as a fallback if InvalidDomainRedirectPage not added to config
138+
const InvalidDomainRedirectPage =
139+
model.def.invalidDomainRedirect || "/start";
140+
if (!isAllowedDomain(email, allowedEmailDomains)) {
141+
request.logger.warn([
142+
"DomainValidation",
143+
`Email domain '${email.split("@")[1]}' not allowed`,
144+
]);
145+
return redirectTo(request, h, InvalidDomainRedirectPage);
146+
}
147+
135148
const hmacKey = this.model.def.outputs[0].outputConfiguration.hmacKey;
136149
const currentTime = Math.floor(Date.now() / 1000);
137150

runner/src/server/plugins/views.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -61,28 +61,36 @@ export default {
6161
`${path.dirname(resolve.sync("hmpo-components"))}/components`,
6262
],
6363
isCached: !config.isDev,
64-
context: (request: HapiRequest) => ({
65-
appVersion: pkg.version,
66-
assetPath: "/assets",
67-
cookiesPolicy: request?.state?.cookies_policy,
68-
serviceName: capitalize(config.serviceName),
69-
feedbackLink: config.feedbackLink,
70-
pageTitle: config.serviceName + " - GOV.UK",
71-
analyticsAccount: config.analyticsAccount,
72-
gtmId1: config.gtmId1,
73-
gtmId2: config.gtmId2,
74-
location: request?.app.location,
75-
matomoId: config.matomoId,
76-
matomoUrl: config.matomoUrl,
77-
BROWSER_REFRESH_URL: config.browserRefreshUrl,
78-
sessionTimeout: config.sessionTimeout,
79-
skipTimeoutWarning: false,
80-
serviceStartPage: config.serviceStartPage || "#",
81-
privacyPolicyUrl: config.privacyPolicyUrl || "/help/privacy",
82-
phaseTag: config.phaseTag,
83-
navigation: request?.auth.isAuthenticated
84-
? [{ text: "Sign out", href: "/logout" }]
85-
: null,
86-
}),
64+
context: (request: HapiRequest) => {
65+
const id = request.params?.id;
66+
const forms = request.server?.app?.forms;
67+
const model = id && forms?.[id];
68+
const analytics = model?.def?.analytics || {};
69+
70+
return {
71+
appVersion: pkg.version,
72+
assetPath: "/assets",
73+
cookiesPolicy: request?.state?.cookies_policy,
74+
serviceName: capitalize(request.server?.app?.forms?.[request.params?.id]?.def?.serviceName || config.serviceName),
75+
feedbackLink: config.feedbackLink,
76+
pageTitle: (request.server?.app?.forms?.[request.params?.id]?.def?.serviceName || config.serviceName) + " - GOV.UK",
77+
analyticsAccount: config.analyticsAccount,
78+
gtmId1: analytics.gtmId1 || "",
79+
gtmId2: analytics.gtmId2 || "",
80+
location: request?.app.location,
81+
matomoId: analytics.matomoId || "",
82+
matomoUrl: analytics.matomoUrl || "",
83+
BROWSER_REFRESH_URL: config.browserRefreshUrl,
84+
sessionTimeout: config.sessionTimeout,
85+
skipTimeoutWarning: false,
86+
serviceStartPage: request.server?.app?.forms?.[request.params?.id]?.def?.fullStartPage || config.serviceName || "#",
87+
privacyPolicyUrl: config.privacyPolicyUrl || "/help/privacy",
88+
phaseTag: config.phaseTag,
89+
navigation: request?.auth.isAuthenticated
90+
? [{ text: "Sign out", href: "/logout" }]
91+
: null,
92+
};
93+
},
94+
8795
},
8896
};

runner/src/server/services/statusService.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { HapiRequest, HapiServer } from "../types";
2+
import { createHmacRaw } from "../utils/hmac";
23
import {
34
CacheService,
45
NotifyService,
@@ -130,6 +131,29 @@ export class StatusService {
130131

131132
let newReference;
132133

134+
/**
135+
* If the OPTIONAL config contains webhookHmacSharedKey, then we send HMAC Auth headers
136+
* This is used to confirm ONLY X-Gov's backend is sending data to our API
137+
* Everyone else will be Rejected
138+
*/
139+
const id = request.params?.id;
140+
const forms = request.server?.app?.forms;
141+
const model = id && forms?.[id];
142+
const hmacKey = model?.def?.webhookHmacSharedKey;
143+
let customSecurityHeaders: Record<string, string> = {};
144+
145+
if (hmacKey) {
146+
const [hmacSignature, requestTime, hmacExpiryTime] = await createHmacRaw(
147+
request.yar.id,
148+
hmacKey
149+
);
150+
customSecurityHeaders = {
151+
"X-Request-ID": request.yar.id.toString(),
152+
"X-HMAC-Signature": hmacSignature.toString(),
153+
"X-HMAC-Time": requestTime.toString(),
154+
};
155+
}
156+
133157
if (callback) {
134158
this.logger.info(
135159
["StatusService", "outputRequests"],
@@ -153,7 +177,8 @@ export class StatusService {
153177
firstWebhook.outputData.url,
154178
{ ...formData },
155179
"POST",
156-
firstWebhook.outputData.sendAdditionalPayMetadata
180+
firstWebhook.outputData.sendAdditionalPayMetadata,
181+
customSecurityHeaders
157182
);
158183
await this.cacheService.mergeState(request, {
159184
reference: newReference,
@@ -178,7 +203,8 @@ export class StatusService {
178203
...formData,
179204
},
180205
"POST",
181-
sendAdditionalPayMetadata
206+
sendAdditionalPayMetadata,
207+
customSecurityHeaders
182208
)
183209
),
184210
];

runner/src/server/services/webhookService.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,26 @@ export class WebhookService {
2727
url: string,
2828
data: object,
2929
method: "POST" | "PUT" = "POST",
30-
sendAdditionalPayMetadata: boolean = false
30+
sendAdditionalPayMetadata: boolean = false,
31+
authHeaders?: Record<string, string>
3132
) {
3233
// Commented out due to potential for logging PII
3334
// this.logger.info(
3435
// ["WebhookService", "postRequest body"],
3536
// JSON.stringify(data)
3637
// );
38+
3739
let request = method === "POST" ? post : put;
3840
try {
3941
if (!sendAdditionalPayMetadata) {
4042
delete data?.metadata?.pay;
4143
}
4244
const { payload } = await request(url, {
4345
...DEFAULT_OPTIONS,
46+
headers: {
47+
...DEFAULT_OPTIONS.headers,
48+
...(authHeaders || {}),
49+
},
4450
payload: JSON.stringify(data),
4551
});
4652

runner/src/server/utils/domain.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export function isAllowedDomain(
2+
email: string,
3+
allowedDomains: string[]
4+
): boolean {
5+
if (!allowedDomains || allowedDomains.length === 0) {
6+
return true;
7+
}
8+
9+
const trimmedEmail = email.trim();
10+
const basicEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11+
12+
if (!basicEmailRegex.test(trimmedEmail)) {
13+
return false;
14+
}
15+
16+
const domain = trimmedEmail.split("@")[1].toLowerCase();
17+
return allowedDomains.some((allowed) => {
18+
const allowedLower = allowed.toLowerCase();
19+
return domain === allowedLower || domain.endsWith("." + allowedLower);
20+
});
21+
}

runner/src/server/utils/hmac.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,29 @@ export async function createHmac(email: string, hmacKey: string) {
8080
}
8181
}
8282

83+
/**
84+
* Similar to the above but returns raw epoch timestamps,
85+
* making it preferable for cryptographic logic.
86+
* The other function may benefit from refactoring
87+
* to separate display logic from core logic. */
88+
export async function createHmacRaw(message: string, hmacKey: string) {
89+
try {
90+
const currentTimestamp = Math.floor(Date.now() / 1000);
91+
const dataToHash = message + currentTimestamp;
92+
const hmac = crypto
93+
.createHmac("sha256", hmacKey)
94+
.update(dataToHash)
95+
.digest("hex");
96+
97+
const expiryTimestamp = currentTimestamp + TIME_THRESHOLD;
98+
99+
return [hmac, currentTimestamp, expiryTimestamp];
100+
} catch (error) {
101+
console.error("Error creating HMAC (raw):", error);
102+
throw error;
103+
}
104+
}
105+
83106
/**
84107
* Validates an HMAC signature
85108
*/

runner/src/server/views/timeout.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ <h1 class="govuk-heading-l">Your application has timed out</h1>
1414
We have reset your application because you did not do anything for {{ sessionTimeout / 60000 }} minutes. We did this
1515
to keep your information secure.
1616
</p>
17-
<a href="{{ serviceStartPage }}" class="govuk-button">Start application again</a>
17+
<a href="{{ startPage }}" class="govuk-button">Start application again</a>
1818
</div>
1919
</div>
2020
</div>

0 commit comments

Comments
 (0)