diff --git a/.changeset/fix-hmac-space-encoding.md b/.changeset/fix-hmac-space-encoding.md new file mode 100644 index 0000000000..8f6b27cac3 --- /dev/null +++ b/.changeset/fix-hmac-space-encoding.md @@ -0,0 +1,5 @@ +--- +"@shopify/shopify-api": patch +--- + +Fix HMAC verification failure when query parameter values contain spaces. `URLSearchParams.toString()` encodes spaces as `+` (application/x-www-form-urlencoded), but Shopify signs using percent-encoding (`%20`). The mismatch caused `api.utils.validateHmac()` to return `false` for valid requests with space-containing params. diff --git a/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts b/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts index 7cccbeb11c..9fc77d46df 100644 --- a/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts +++ b/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts @@ -23,7 +23,7 @@ describe('validateHmac', () => { testConfig({apiSecretKey: 'my super secret key'}), ); - const queryString = `code=some+code+goes+here&shop=the+shop+URL&state=some+nonce+passed+from+auth×tamp=${queryParams.timestamp}`; + const queryString = `code=some%20code%20goes%20here&shop=the%20shop%20URL&state=some%20nonce%20passed%20from%20auth×tamp=${queryParams.timestamp}`; const query = { ...queryParams, hmac: createHmacSignature(queryString, shopify.config.apiSecretKey), @@ -53,7 +53,7 @@ describe('validateHmac', () => { ); // NB: keys are listed alphabetically - const queryString = `code=some+code+goes+here&foo=bar&shop=the+shop+URL&state=some+nonce+passed+from+auth×tamp=${queryParams.timestamp}`; + const queryString = `code=some%20code%20goes%20here&foo=bar&shop=the%20shop%20URL&state=some%20nonce%20passed%20from%20auth×tamp=${queryParams.timestamp}`; const query = { ...queryParams, @@ -66,6 +66,30 @@ describe('validateHmac', () => { ); }); + test('spaces in param values are percent-encoded (%20), not plus-encoded', async () => { + const shopify = shopifyApi( + testConfig({apiSecretKey: 'my super secret key'}), + ); + + const timestamp = String(getCurrentTimeInSec() - 60); + const queryWithSpaces = { + custom: 'hello world', + shop: 'myshop.myshopify.com', + timestamp, + }; + + // Shopify signs with %20, not + + const queryString = `custom=hello%20world&shop=myshop.myshopify.com×tamp=${timestamp}`; + const query = { + ...queryWithSpaces, + hmac: createHmacSignature(queryString, shopify.config.apiSecretKey), + }; + + await expect(shopify.utils.validateHmac(query, options)).resolves.toBe( + true, + ); + }); + test('throws InvalidHmacError when there is no hmac key', async () => { const shopify = shopifyApi(testConfig()); @@ -84,7 +108,7 @@ describe('validateHmac', () => { ); const timestamp = String(getCurrentTimeInSec() - 91); - const queryString = `code=some+code+goes+here&shop=the+shop+URL&state=some+nonce+passed+from+auth×tamp=${timestamp}`; + const queryString = `code=some%20code%20goes%20here&shop=the%20shop%20URL&state=some%20nonce%20passed%20from%20auth×tamp=${timestamp}`; const query = { ...queryParams, timestamp, @@ -103,7 +127,7 @@ describe('validateHmac', () => { ); const timestamp = String(getCurrentTimeInSec() + 91); - const queryString = `code=some+code+goes+here&shop=the+shop+URL&state=some+nonce+passed+from+auth×tamp=${timestamp}`; + const queryString = `code=some%20code%20goes%20here&shop=the%20shop%20URL&state=some%20nonce%20passed%20from%20auth×tamp=${timestamp}`; const query = { ...queryParams, timestamp, @@ -198,7 +222,7 @@ describe('validateHmac', () => { ); const timestamp = String(getCurrentTimeInSec() - 91); - const queryString = `code=some+code+goes+here&shop=the+shop+URL&state=some+nonce+passed+from+auth×tamp=${timestamp}`; + const queryString = `code=some%20code%20goes%20here&shop=the%20shop%20URL&state=some%20nonce%20passed%20from%20auth×tamp=${timestamp}`; const query = { ...queryParams, timestamp, @@ -220,7 +244,7 @@ describe('validateHmac', () => { ); const timestamp = String(getCurrentTimeInSec() + 91); - const queryString = `code=some+code+goes+here&shop=the+shop+URL&state=some+nonce+passed+from+auth×tamp=${timestamp}`; + const queryString = `code=some%20code%20goes%20here&shop=the%20shop%20URL&state=some%20nonce%20passed%20from%20auth×tamp=${timestamp}`; const query = { ...queryParams, timestamp, diff --git a/packages/apps/shopify-api/lib/utils/processed-query.ts b/packages/apps/shopify-api/lib/utils/processed-query.ts index e67e309c25..c370823bd2 100644 --- a/packages/apps/shopify-api/lib/utils/processed-query.ts +++ b/packages/apps/shopify-api/lib/utils/processed-query.ts @@ -47,7 +47,7 @@ export default class ProcessedQuery { } stringify(omitQuestionMark = false): string { - const queryString = this.processedQuery.toString(); + const queryString = this.processedQuery.toString().replace(/\+/g, '%20'); return omitQuestionMark ? queryString : `?${queryString}`; } }