From 8048cb92535a0f412e4df665ca49e3751d28ab82 Mon Sep 17 00:00:00 2001 From: Josh Faigan Date: Tue, 20 Jan 2026 11:20:13 -0500 Subject: [PATCH] Reject proxied requests where the hosts don't match --- .changeset/rich-swans-draw.md | 5 +++++ .../utilities/theme-environment/proxy.test.ts | 22 ++++++++++++++++++- .../cli/utilities/theme-environment/proxy.ts | 7 ++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 .changeset/rich-swans-draw.md diff --git a/.changeset/rich-swans-draw.md b/.changeset/rich-swans-draw.md new file mode 100644 index 00000000000..e6d65015a79 --- /dev/null +++ b/.changeset/rich-swans-draw.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': patch +--- + +Protect SSRF vulnerability in proxy requests when hosts don't match diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts index 5775b1d5f76..8d6c91c095f 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts @@ -1,4 +1,10 @@ -import {canProxyRequest, getProxyStorefrontHeaders, injectCdnProxy, patchRenderingResponse} from './proxy.js' +import { + canProxyRequest, + getProxyStorefrontHeaders, + injectCdnProxy, + patchRenderingResponse, + proxyStorefrontRequest, +} from './proxy.js' import {describe, test, expect} from 'vitest' import {createEvent} from 'h3' import {IncomingMessage, ServerResponse} from 'node:http' @@ -338,4 +344,18 @@ describe('dev proxy', () => { expect(canProxyRequest(event)).toBeTruthy() }) }) + describe('proxyStorefrontRequest', () => { + test('should reject hostname mismatch and throw error for non-CDN paths (SSRF protection)', async () => { + const event = createH3Event('GET', '//evil.com/some-path') + await expect(proxyStorefrontRequest(event, ctx)).rejects.toThrow( + 'Request failed: Hostname mismatch. Expected host: my-store.myshopify.com. Resulting URL hostname: evil.com', + ) + }) + test('should reject hostname mismatch and throw error for CDN paths (SSRF protection)', async () => { + const event = createH3Event('GET', '/ext/cdn//evil.com/some-path') + await expect(proxyStorefrontRequest(event, ctx)).rejects.toThrow( + 'Request failed: Hostname mismatch. Expected host: cdn.shopify.com. Resulting URL hostname: evil.com', + ) + }) + }) }) diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.ts index d228837ef64..b2178b2c445 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.ts @@ -293,6 +293,13 @@ export function proxyStorefrontRequest(event: H3Event, ctx: DevServerContext): P const host = event.path.startsWith(EXTENSION_CDN_PREFIX) ? 'cdn.shopify.com' : ctx.session.storeFqdn const url = new URL(path, `https://${host}`) + // Check that we aren't redirecting to external hosts + if (url.hostname !== host) { + return Promise.reject( + new Error(`Request failed: Hostname mismatch. Expected host: ${host}. Resulting URL hostname: ${url.hostname}`), + ) + } + // When a .css.liquid or .js.liquid file is requested but it doesn't exist in SFR, // it will be rendered with a query string like `assets/file.css?1234`. // For some reason, after refreshing, this rendered URL keeps the wrong `?1234`