Skip to content
This repository was archived by the owner on Apr 11, 2024. It is now read-only.

Commit e594c74

Browse files
authored
Merge pull request #1255 from Shopify/liz/refactor-hmac-validation
Refactor the validation of HMAC to be more reusable
2 parents d36ecfd + f57712c commit e594c74

File tree

8 files changed

+191
-110
lines changed

8 files changed

+191
-110
lines changed

.changeset/grumpy-numbers-count.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@shopify/shopify-api": patch
3+
---
4+
5+
Refactor HMAC validation to use a common function.
Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
ValidationErrorReason,
3+
ValidationInvalid,
4+
ValidationValid,
5+
} from '../utils/types';
16
import {AdapterArgs} from '../../runtime/types';
27

38
export interface FlowValidateParams extends AdapterArgs {
@@ -7,26 +12,19 @@ export interface FlowValidateParams extends AdapterArgs {
712
rawBody: string;
813
}
914

10-
export enum FlowValidationErrorReason {
11-
MissingBody = 'missing_body',
12-
MissingHmac = 'missing_hmac',
13-
InvalidHmac = 'invalid_hmac',
14-
}
15+
export const FlowValidationErrorReason = {
16+
...ValidationErrorReason,
17+
} as const;
1518

16-
export interface FlowValidationInvalid {
17-
/**
18-
* Whether the request is a valid Flow request from Shopify.
19-
*/
20-
valid: false;
19+
export type FlowValidationErrorReasonType =
20+
(typeof FlowValidationErrorReason)[keyof typeof FlowValidationErrorReason];
21+
22+
export interface FlowValidationInvalid
23+
extends Omit<ValidationInvalid, 'reason'> {
2124
/**
2225
* The reason why the request is not valid.
2326
*/
24-
reason: FlowValidationErrorReason;
27+
reason: FlowValidationErrorReasonType;
2528
}
2629

27-
export interface FlowValidationValid {
28-
/**
29-
* Whether the request is a valid Flow request from Shopify.
30-
*/
31-
valid: true;
32-
}
30+
export interface FlowValidationValid extends ValidationValid {}
Lines changed: 13 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,22 @@
1-
import {abstractConvertRequest, getHeader} from '../../runtime/http';
2-
import {HashFormat} from '../../runtime/crypto/types';
1+
import {
2+
HmacValidationType,
3+
ValidationInvalid,
4+
ValidationValid,
5+
} from '../utils/types';
6+
import {validateHmacFromRequestFactory} from '../utils/hmac-validator';
37
import {ConfigInterface} from '../base-types';
4-
import {logger} from '../logger';
5-
import {ShopifyHeader} from '../types';
6-
import {validateHmacString} from '../utils/hmac-validator';
78

8-
import {
9-
FlowValidateParams,
10-
FlowValidationInvalid,
11-
FlowValidationValid,
12-
FlowValidationErrorReason,
13-
} from './types';
9+
import {FlowValidateParams} from './types';
1410

1511
export function validateFactory(config: ConfigInterface) {
1612
return async function validate({
1713
rawBody,
1814
...adapterArgs
19-
}: FlowValidateParams): Promise<FlowValidationInvalid | FlowValidationValid> {
20-
const request = await abstractConvertRequest(adapterArgs);
21-
22-
if (!rawBody.length) {
23-
return fail(FlowValidationErrorReason.MissingBody, config);
24-
}
25-
26-
const hmac = getHeader(request.headers, ShopifyHeader.Hmac);
27-
28-
if (!hmac) {
29-
return fail(FlowValidationErrorReason.MissingHmac, config);
30-
}
31-
32-
if (await validateHmacString(config, rawBody, hmac, HashFormat.Base64)) {
33-
return succeed(config);
34-
}
35-
36-
return fail(FlowValidationErrorReason.InvalidHmac, config);
37-
};
38-
}
39-
40-
async function fail(
41-
reason: FlowValidationErrorReason,
42-
config: ConfigInterface,
43-
): Promise<FlowValidationInvalid> {
44-
const log = logger(config);
45-
await log.debug('Flow request is not valid', {reason});
46-
47-
return {
48-
valid: false,
49-
reason,
50-
};
51-
}
52-
53-
async function succeed(config: ConfigInterface): Promise<FlowValidationValid> {
54-
const log = logger(config);
55-
await log.debug('Flow request is valid');
56-
57-
return {
58-
valid: true,
15+
}: FlowValidateParams): Promise<ValidationInvalid | ValidationValid> {
16+
return validateHmacFromRequestFactory(config)({
17+
type: HmacValidationType.Flow,
18+
rawBody,
19+
...adapterArgs,
20+
});
5921
};
6022
}

packages/shopify-api/lib/utils/hmac-validator.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import {logger} from '../logger';
2+
import {ShopifyHeader} from '../types';
3+
import {
4+
AdapterArgs,
5+
abstractConvertRequest,
6+
getHeader,
7+
} from '../../runtime/http';
18
import {ConfigInterface} from '../base-types';
29
import {createSHA256HMAC} from '../../runtime/crypto';
310
import {HashFormat} from '../../runtime/crypto/types';
@@ -6,11 +13,29 @@ import * as ShopifyErrors from '../error';
613
import {safeCompare} from '../auth/oauth/safe-compare';
714

815
import ProcessedQuery from './processed-query';
16+
import {
17+
ValidationErrorReason,
18+
ValidationInvalid,
19+
HmacValidationType,
20+
ValidationValid,
21+
ValidationErrorReasonType,
22+
} from './types';
923

1024
const HMAC_TIMESTAMP_PERMITTED_CLOCK_TOLERANCE_SEC = 90;
1125

1226
export type HMACSignator = 'admin' | 'appProxy';
1327

28+
export interface ValidateParams extends AdapterArgs {
29+
/**
30+
* The type of validation to perform, either 'flow' or 'webhook'.
31+
*/
32+
type: HmacValidationType;
33+
/**
34+
* The raw body of the request.
35+
*/
36+
rawBody: string;
37+
}
38+
1439
function stringifyQueryForAdmin(query: AuthQuery): string {
1540
const processedQuery = new ProcessedQuery();
1641
Object.keys(query)
@@ -85,6 +110,34 @@ export function getCurrentTimeInSec() {
85110
return Math.trunc(Date.now() / 1000);
86111
}
87112

113+
export function validateHmacFromRequestFactory(config: ConfigInterface) {
114+
return async function validateHmacFromRequest({
115+
type,
116+
rawBody,
117+
...adapterArgs
118+
}: ValidateParams): Promise<ValidationInvalid | ValidationValid> {
119+
const request = await abstractConvertRequest(adapterArgs);
120+
if (!rawBody.length) {
121+
return fail(ValidationErrorReason.MissingBody, type, config);
122+
}
123+
const hmac = getHeader(request.headers, ShopifyHeader.Hmac);
124+
if (!hmac) {
125+
return fail(ValidationErrorReason.MissingHmac, type, config);
126+
}
127+
const validHmac = await validateHmacString(
128+
config,
129+
rawBody,
130+
hmac,
131+
HashFormat.Base64,
132+
);
133+
if (!validHmac) {
134+
return fail(ValidationErrorReason.InvalidHmac, type, config);
135+
}
136+
137+
return succeed(type, config);
138+
};
139+
}
140+
88141
function validateHmacTimestamp(query: AuthQuery) {
89142
if (
90143
Math.abs(getCurrentTimeInSec() - Number(query.timestamp)) >
@@ -95,3 +148,29 @@ function validateHmacTimestamp(query: AuthQuery) {
95148
);
96149
}
97150
}
151+
152+
async function fail(
153+
reason: ValidationErrorReasonType,
154+
type: HmacValidationType,
155+
config: ConfigInterface,
156+
): Promise<ValidationInvalid> {
157+
const log = logger(config);
158+
await log.debug(`${type} request is not valid`, {reason});
159+
160+
return {
161+
valid: false,
162+
reason,
163+
};
164+
}
165+
166+
async function succeed(
167+
type: HmacValidationType,
168+
config: ConfigInterface,
169+
): Promise<ValidationValid> {
170+
const log = logger(config);
171+
await log.debug(`${type} request is valid`);
172+
173+
return {
174+
valid: true,
175+
};
176+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export enum HmacValidationType {
2+
Flow = 'flow',
3+
Webhook = 'webhook',
4+
}
5+
6+
export const ValidationErrorReason = {
7+
MissingBody: 'missing_body',
8+
InvalidHmac: 'invalid_hmac',
9+
MissingHmac: 'missing_hmac',
10+
} as const;
11+
12+
export type ValidationErrorReasonType =
13+
(typeof ValidationErrorReason)[keyof typeof ValidationErrorReason];
14+
15+
export interface ValidationInvalid {
16+
/**
17+
* Whether the request is a valid Flow request from Shopify.
18+
*/
19+
valid: false;
20+
/**
21+
* The reason why the request is not valid.
22+
*/
23+
reason: ValidationErrorReasonType;
24+
}
25+
26+
export interface ValidationValid {
27+
/**
28+
* Whether the request is a valid request from Shopify.
29+
*/
30+
valid: true;
31+
}

packages/shopify-api/lib/webhooks/__tests__/process.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,12 @@ describe('shopify.webhooks.process', () => {
248248
for (const header of emptyHeaders) {
249249
const response = await request(app)
250250
.post('/webhooks')
251-
.set(headers(header))
251+
.set(
252+
headers({
253+
hmac: hmac(shopify.config.apiSecretKey, rawBody),
254+
...header,
255+
}),
256+
)
252257
.send(rawBody)
253258
.expect(StatusCode.BadRequest);
254259

packages/shopify-api/lib/webhooks/types.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {ValidationErrorReason, ValidationInvalid} from '../utils/types';
12
import {AdapterArgs} from '../../runtime/types';
23
import {Session} from '../session/session';
34

@@ -121,11 +122,13 @@ export interface WebhookProcessParams extends AdapterArgs {
121122

122123
export interface WebhookValidateParams extends WebhookProcessParams {}
123124

124-
export enum WebhookValidationErrorReason {
125-
MissingHeaders = 'missing_headers',
126-
MissingBody = 'missing_body',
127-
InvalidHmac = 'invalid_hmac',
128-
}
125+
export const WebhookValidationErrorReason = {
126+
...ValidationErrorReason,
127+
MissingHeaders: 'missing_headers',
128+
} as const;
129+
130+
export type WebhookValidationErrorReasonType =
131+
(typeof WebhookValidationErrorReason)[keyof typeof WebhookValidationErrorReason];
129132

130133
export interface WebhookFields {
131134
webhookId: string;
@@ -136,14 +139,15 @@ export interface WebhookFields {
136139
subTopic?: string;
137140
}
138141

139-
export interface WebhookValidationInvalid {
142+
export interface WebhookValidationInvalid
143+
extends Omit<ValidationInvalid, 'reason'> {
140144
valid: false;
141-
reason: WebhookValidationErrorReason;
145+
reason: WebhookValidationErrorReasonType;
142146
}
143147

144148
export interface WebhookValidationMissingHeaders
145149
extends WebhookValidationInvalid {
146-
reason: WebhookValidationErrorReason.MissingHeaders;
150+
reason: typeof WebhookValidationErrorReason.MissingHeaders;
147151
missingHeaders: string[];
148152
}
149153

0 commit comments

Comments
 (0)