import {logger} from '../logger'; import {ShopifyHeader} from '../types'; import { AdapterArgs, abstractConvertRequest, getHeader, } from '../../runtime/http'; import {ConfigInterface} from '../base-types'; import {createSHA256HMAC} from '../../runtime/crypto'; import {HashFormat} from '../../runtime/crypto/types'; import {AuthQuery} from '../auth/oauth/types'; import * as ShopifyErrors from '../error'; import {safeCompare} from '../auth/oauth/safe-compare'; import {WEBHOOK_HEADER_NAMES, WebhookTypeValue} from '../webhooks/types'; import ProcessedQuery from './processed-query'; import { ValidationErrorReason, ValidationInvalid, HmacValidationType, ValidationValid, ValidationErrorReasonType, } from './types'; const HMAC_TIMESTAMP_PERMITTED_CLOCK_TOLERANCE_SEC = 90; export type HMACSignator = 'admin' | 'appProxy'; export interface ValidateParams extends AdapterArgs { /** * The type of validation to perform, either 'flow' or 'webhook'. */ type: HmacValidationType; /** * The raw body of the request. */ rawBody: string; /** * The webhook type for header selection (optional, only for webhooks). */ webhookType?: WebhookTypeValue; } function stringifyQueryForAdmin(query: AuthQuery): string { const processedQuery = new ProcessedQuery(); Object.keys(query) .sort((val1, val2) => val1.localeCompare(val2)) .forEach((key: string) => processedQuery.put(key, query[key])); return processedQuery.stringify(true); } function stringifyQueryForAppProxy(query: AuthQuery): string { return Object.entries(query) .sort(([val1], [val2]) => val1.localeCompare(val2)) .reduce((acc, [key, value]) => { return `${acc}${key}=${Array.isArray(value) ? value.join(',') : value}`; }, ''); } export function generateLocalHmac(config: ConfigInterface) { return async ( params: AuthQuery, signator: HMACSignator = 'admin', ): Promise => { const {hmac, signature, ...query} = params; const queryString = signator === 'admin' ? stringifyQueryForAdmin(query) : stringifyQueryForAppProxy(query); return createSHA256HMAC(config.apiSecretKey, queryString, HashFormat.Hex); }; } export function validateHmac(config: ConfigInterface) { return async ( query: AuthQuery, {signator}: {signator: HMACSignator} = {signator: 'admin'}, ): Promise => { if (signator === 'admin' && !query.hmac) { throw new ShopifyErrors.InvalidHmacError( 'Query does not contain an HMAC value.', ); } if (signator === 'appProxy' && !query.signature) { throw new ShopifyErrors.InvalidHmacError( 'Query does not contain a signature value.', ); } validateHmacTimestamp(query); const hmac = signator === 'appProxy' ? query.signature : query.hmac; const localHmac = await generateLocalHmac(config)(query, signator); return safeCompare(hmac as string, localHmac); }; } export async function validateHmacString( config: ConfigInterface, data: string, hmac: string, format: HashFormat, ) { const localHmac = await createSHA256HMAC(config.apiSecretKey, data, format); return safeCompare(hmac, localHmac); } export function getCurrentTimeInSec() { return Math.trunc(Date.now() / 1000); } export function validateHmacFromRequestFactory(config: ConfigInterface) { return async function validateHmacFromRequest({ type, rawBody, webhookType, ...adapterArgs }: ValidateParams): Promise { const request = await abstractConvertRequest(adapterArgs); if (!rawBody.length) { return fail(ValidationErrorReason.MissingBody, type, config); } // Use appropriate header based on webhook type const hmacHeaderName = webhookType ? WEBHOOK_HEADER_NAMES[webhookType].hmac : ShopifyHeader.Hmac; const hmac = getHeader(request.headers, hmacHeaderName); if (!hmac) { return fail(ValidationErrorReason.MissingHmac, type, config); } const validHmac = await validateHmacString( config, rawBody, hmac, HashFormat.Base64, ); if (!validHmac) { return fail(ValidationErrorReason.InvalidHmac, type, config); } return succeed(type, config); }; } function validateHmacTimestamp(query: AuthQuery) { if ( Math.abs(getCurrentTimeInSec() - Number(query.timestamp)) > HMAC_TIMESTAMP_PERMITTED_CLOCK_TOLERANCE_SEC ) { throw new ShopifyErrors.InvalidHmacError( 'HMAC timestamp is outside of the tolerance range', ); } } async function fail( reason: ValidationErrorReasonType, type: HmacValidationType, config: ConfigInterface, ): Promise { const log = logger(config); await log.debug(`${type} request is not valid`, {reason}); return { valid: false, reason, }; } async function succeed( type: HmacValidationType, config: ConfigInterface, ): Promise { const log = logger(config); await log.debug(`${type} request is valid`); return { valid: true, }; }