sanitizeHost throws instead of returning null for base64-shaped hosts that don't decode to a URL

Open Beginner friendly
#3,377 1 comment 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
84/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
typescript

Research direction

Start in packages/apps/shopify-api/lib/utils/shop-validator.ts and run the provided repro.mjs with the four host values. Trace sanitizeHost through decodeHost and URL parsing, then add coverage for base64-shaped invalid inputs if the project’s tests provide a suitable location. Done means invalid hosts return null, while throwOnInvalid consistently produces InvalidHostError instead of leaking decode or URL exceptions.

Written by the indexing model from the issue text.

Description

devtools-gardener

Issue summary

Before opening this issue, I have:

  • Upgraded to the latest version of the relevant packages
    • @shopify/* package and version: @shopify/shopify-api 14.0.0 (via @shopify/shopify-app-react-router 2.0.0). Also verified against the published @shopify/shopify-api@14.0.1 and against main — the code is unchanged in both.
    • Node version: v24.19.0
    • Operating system: macOS 26.6.2
  • Set { logger: { level: LogSeverity.Debug } } in my configuration, when applicable
  • Found a reliable way to reproduce the problem that indicates it's a problem with the package
  • Looked for similar issues in this repository
  • Checked that this isn't an issue with a Shopify API

sanitizeHost throws instead of returning null when host passes the base64 character check but does not decode into something new URL() accepts.

packages/apps/shopify-api/lib/utils/shop-validator.ts:

let sanitizedHost = base64regex.test(host) ? host : null;

if (sanitizedHost) {
  const {hostname} = new URL(`https://${decodeHost(sanitizedHost)}`);
  ...
}

Neither decodeHost (which is atob) nor the URL constructor is guarded. The function returns null for "not base64-shaped" and for "decoded fine but isn't a Shopify domain", but the case in between — base64 charset, decodes (or fails to decode), doesn't form a URL — escapes as an exception.

This matters because callers are written against the null contract. In @shopify/shopify-app-react-router, validateShopAndHostParams does:

const host = api.utils.sanitizeHost(url.searchParams.get('host'));
if (!host) {
  logger.debug('Invalid host, rendering App Bridge', {shop, host: url.searchParams.get('host')});
  throw renderAppBridgeOrError(request, params);
}

That if (!host) branch is exactly the intended handling for an invalid host, and the throw skips it. The result is an unhandled TypeError out of authenticate.admin(), which surfaces as a 500 rather than the App Bridge re-acquisition page.

We hit this in production: an automated scanner requested our embedded entry point with ?host=9995625306531699999 (a digit string that happens to satisfy the base64 character class), and every such request returned 500 and raised our error alerting.

Expected behavior

sanitizeHost(host) returns null for any host it cannot validate, including one that is base64-shaped but does not decode to a URL. throwOnInvalid: true then raises InvalidHostError, consistent with every other invalid input.

Actual behavior

It throws the raw underlying error — TypeError: Invalid URL from the URL constructor, or DOMException: The string to be decoded is not correctly encoded. from atob — regardless of throwOnInvalid.

Steps to reproduce the problem

  1. Save this as repro.mjs in a project with @shopify/shopify-api installed:
import '@shopify/shopify-api/adapters/node';
import {shopifyApi, ApiVersion} from '@shopify/shopify-api';

const shopify = shopifyApi({
  apiKey: 'key',
  apiSecretKey: 'secret',
  scopes: ['read_products'],
  hostName: 'example.com',
  apiVersion: ApiVersion.January26,
  isEmbeddedApp: true,
});

for (const host of [
  '9995625306531699999', // base64 charset, decodes, not a URL
  'abcde',               // base64 charset, length % 4 === 1
  'not-base64!!',        // not base64 charset
  'YQ',                  // decodes to "a" -> valid URL, wrong domain
]) {
  try {
    console.log(JSON.stringify(host), '->', JSON.stringify(shopify.utils.sanitizeHost(host)));
  } catch (error) {
    console.log(JSON.stringify(host), '-> THREW', `${error.constructor.name}: ${error.message}`);
  }
}
  1. node repro.mjs
  2. Observe that the first two throw while the last two return null.

Equivalently, against an embedded app built on @shopify/shopify-app-react-router, request any route that calls authenticate.admin() with ?shop=<your-shop>.myshopify.com&host=9995625306531699999 and observe a 500.

Debug logs

"9995625306531699999" -> THREW TypeError: Invalid URL
"abcde" -> THREW DOMException: The string to be decoded is not correctly encoded.
"not-base64!!" -> null
"YQ" -> null

Suggested fix

Wrap the decode-and-parse in a try/catch and fall through to the existing null path, so all three invalid shapes are handled identically:

if (sanitizedHost) {
  let hostname: string | undefined;
  try {
    ({hostname} = new URL(`https://${decodeHost(sanitizedHost)}`));
  } catch {
    return throwOnInvalid
      ? (() => { throw new InvalidHostError('Received invalid host argument'); })()
      : null;
  }
  ...
}

Happy to open a PR with a test covering the three shapes if that would help.

Dominant language
TypeScript
Stars
540
Forks
225
Avg merge
3d 7h
Merged PRs (30d)
6

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from Shopify/shopify-app-js

All issues in Shopify/shopify-app-js

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.