Hello Community,
We’ve encountered an issue where Shopify sends us a webhook when a user requests shipping rates for the items in their shopping cart. This webhook includes a signature in the headers, which we verify using the client_secret from our Shopify app, linked to the client’s store.
The problem started occurring after January 2, 2024. The signature verification began failing for several of our clients. What’s puzzling is that it doesn’t affect all stores, and for some stores experiencing the issue, the verification works for certain products but fails for others.
We’ve tried adjusting our signature verification strategies without success. Below, I’ve attached our implementation using the Koa Framework:
import cors from '@koa/cors';
import { inject, injectable } from 'inversify';
import Koa from 'koa';
import { HttpMethodEnum, koaBody } from 'koa-body';
import cookie from 'koa-cookie';
import helmet from 'koa-helmet';
import json from 'koa-json';
import morgan from 'koa-morgan';
import rateLimit, { MiddlewareOptions } from 'koa-ratelimit';
import session from 'koa-session';
import koaStatic from 'koa-static';
import { TYPES } from '@/shared/domain/d-injection/types';
import { Framework } from '@/shared/domain/framework';
import { Logger } from '@/shared/domain/logger';
import { serverAdapter } from '@/shared/infrastructure/bull-board/bull.board';
import { SERVER, TEST } from '@/shared/infrastructure/config';
import { tracer } from '@/shared/infrastructure/logger/datadog.logger';
import authRouter, {
authMiddleware
} from '@/shared/infrastructure/middlewares/auth-board.middleware';
import {
router,
registerRoutes
} from '@/shared/infrastructure/open-api/router';
// Rate limit db
const db = new Map();
@injectable()
export class KoaFramework implements Framework {
private app: Koa;
constructor(@inject(TYPES.Logger) private readonly logger: Logger) {
this.app = new Koa();
}
async init(): Promise<void> {
// Session
this.app.keys = ['your-session-secret'];
this.app.use(session({}, this.app));
// Middleware
this.app.use(cors());
this.app.use(
helmet(
SERVER.embedding.enabled
? {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
imgSrc: ["'self'", '*.googleapis.com'],
styleSrc: ["'self'", '*.googleapis.com'],
'frame-ancestors': [...SERVER.embedding.allowedOrigins]
},
reportOnly: true
},
frameguard: false // Note: https://github.com/helmetjs/helmet/issues/167
}
: { contentSecurityPolicy: false }
)
);
this.app.use(json());
this.app.use(
koaBody({
multipart: true,
includeUnparsed: true,
parsedMethods: Object.values(HttpMethodEnum),
onError: (err, ctx) => {
ctx.throw(422, `Body parse error: ${err}`);
}
})
);
this.app.use(async (ctx, next) => {
if (ctx?.request?.body?.[Symbol.for('unparsedBody')]) {
ctx.request.rawBody = ctx.request.body[Symbol.for('unparsedBody')];
delete ctx.request.body[Symbol.for('unparsedBody')];
}
await next();
});
this.app.use(cookie());
this.app.use(
morgan('dev', {
stream: { write: (message) => this.logger.debug(message) }
})
);
// rateLimit per IP = max / duration * 1000 [req/s]
this.app.use(
rateLimit({
driver: 'memory',
db,
duration: SERVER.rateLimit.duration,
max: SERVER.rateLimit.maxRequestsInDurationFrame,
id: (ctx) => ctx.ip,
headers: {
remaining: 'Rate-Limit-Remaining',
reset: 'Rate-Limit-Reset',
total: 'Rate-Limit-Total'
},
throw: true,
disableHeader: false
} as MiddlewareOptions)
);
// Error handling middleware
this.app.use(async (ctx: Koa.Context, next: Koa.Next) => {
try {
await next();
} catch (err: any) {
const { body, headers, method, originalUrl, ip } = ctx.request;
this.logger.registerLog({
type: 'WEB_FRAMEWORK_ERROR',
message: `${err}`,
module: 'SHARED',
level: 'error',
request: { body, headers, method, originalUrl, ip }
});
ctx.status = ctx.status || 500;
ctx.body = { message: err.toString() };
} finally {
if (!TEST.isDefined) {
const span = tracer.scope().active();
if (span) span.finish();
}
}
});
// Bull board
this.app.use(authRouter.routes());
this.app.use(authRouter.allowedMethods());
this.app.use(authMiddleware);
this.app.use(serverAdapter.registerPlugin());
// End Bull board
registerRoutes();
this.app.use(router.routes());
this.app.use(router.allowedMethods());
this.app.use(
koaStatic(
`${require('path').resolve()}/src/shared/infrastructure/layouts`
)
);
const port = SERVER.port;
this.app.listen(port, () =>
this.logger.debug(`[Koa] Started in ${SERVER.hostname}:${port}`)
);
}
getApp(): Koa {
return this.app;
}
}
Here is how we validate the signatures:
/**
* Validates a given HMAC against a secret.
* hmac - The HMAC to be validated.
* platFormOrigin - The platform origin ex. 'shopify'.
* body - The body data.
* secret - The secret key to extract the value in the config file, ex. 'shopifySecret'.
* @returns Boolean indicating whether the HMAC is valid.
*/
export const validateHmac = (
hmac: string,
secretValue: string,
body: any,
encoding: EncodingType = 'Base64'
): boolean => {
const crypto = new CryptoUtil();
// Sometimes we may receive a rawBody
const transFormBody = typeof body !== 'string' ? JSON.stringify(body) : body;
return crypto.verifyHmac(hmac, secretValue, transFormBody, encoding);
};
We use the following CryptoUtil class for HMAC generation and verification:
import CryptoJS from 'crypto-js';
import { injectable } from 'inversify';
import { Crypto, EncodingType } from '@/shared/domain/utils/crypto';
@injectable()
export class CryptoUtil implements Crypto {
/**
* secret - The secret key to use for HMAC generation.
* payload - The data to be hashed.
* encoding - The encoding type for the generated HMAC (e.g., 'Hex', 'Base64', 'Latin1', etc.).
* @returns The generated HMAC.
*/
public generateHmac(
secret: string,
payload: any,
encoding: EncodingType = 'Base64'
string {
return CryptoJS.HmacSHA256(payload, secret).toString(
CryptoJS.enc[encoding]
);
}
/**
* hmac - The HMAC to verify.
* secret - The secret key to use for verification.
* payload - The data to verify against.
* [encoding] - The encoding type for the generated HMAC (e.g., 'Hex', 'Base64', 'Latin1', etc.).
* @returns Boolean indicating whether the verification was successful.
*/
public verifyHmac(
hmac: string,
secret: string,
payload: string,
encoding?: EncodingType
boolean {
const calculatedHmac = this.generateHmac(secret, payload, encoding);
return hmac === calculatedHmac;
}
}
Additionally, we attempted using the shopify-api library for Node.js and followed its documented webhook verification process but encountered the same issues described above. As well as changing crypto-js library for crypto.
Has anyone else experienced a similar problem, or could you suggest an approach to resolve this?
Thank you in advance for your help!