Issue Verifying Shopify Webhook Signatures After January 2, 2024

Issue Verifying Shopify Webhook Signatures After January 2, 2024

Mike-Rodriguez
Shopify Partner
1 0 0

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.
 * @param hmac - The HMAC to be validated.
 * @param platFormOrigin - The platform origin ex. 'shopify'.
 * @param body - The body data.
 * @param 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 {
  /**
   * @param secret - The secret key to use for HMAC generation.
   * @param payload - The data to be hashed.
   * @param 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]
    );
  }

  /**
   * @param hmac - The HMAC to verify.
   * @param secret - The secret key to use for verification.
   * @param payload - The data to verify against.
   * @param [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!

Replies 0 (0)