diff --git a/cookbook/llms/partytown.prompt.md b/cookbook/llms/partytown.prompt.md index 11de328013..2ecf96689f 100644 --- a/cookbook/llms/partytown.prompt.md +++ b/cookbook/llms/partytown.prompt.md @@ -235,7 +235,7 @@ Document Partytown setup and configuration instructions. + +1. **Partytown** runs third-party scripts in a web worker, keeping the main thread free +2. **GTM scripts** are loaded with `type="text/partytown"` to run in the worker -+3. **Reverse proxy** handles scripts that need CORS headers ++3. **Reverse proxy** handles scripts that need CORS headers, rejects upstream responses without a JavaScript content type, and blocks redirects outside the allowlist +4. **CSP headers** are configured to allow GTM and Google Analytics domains + +### Performance benefits @@ -270,11 +270,13 @@ Reverse the proxy route for third-party scripts requiring CORS headers. import type {Route} from './+types/reverse-proxy'; -type HandleRequestResponHeaders = { +type ProxyResponseHeaders = { 'Access-Control-Allow-Origin': string; + 'Content-Security-Policy': string; Vary: string; + 'X-Content-Type-Options': string; 'cache-control'?: string; - 'content-type'?: string; + 'content-type': string; }; type CorsHeaders = { @@ -293,6 +295,23 @@ const ALLOWED_PROXY_DOMAINS = new Set([ // other domains you may want to allow to proxy to ]); +const CORS_PREFLIGHT_MAX_AGE_IN_SECONDS = '86400'; +const PROXY_CONTENT_SECURITY_POLICY = + "default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"; +const FORBIDDEN_STATUS = 403; +const MAX_PROXY_REDIRECTS = 3; +const UNSUPPORTED_MEDIA_TYPE_STATUS = 415; + +const ALLOWED_SCRIPT_CONTENT_TYPES = new Set([ + 'application/ecmascript', + 'application/javascript', + 'application/x-javascript', + 'text/ecmascript', + 'text/javascript', +]); + +const REDIRECT_RESPONSE_STATUSES = new Set([301, 302, 303, 307, 308]); + // Handle CORS preflight for POST requests export async function action({request}: Route.ActionArgs) { const url = new URL(request.url); @@ -391,7 +410,7 @@ function handleCorsOptions(request: Route.LoaderArgs['request']) { const corsHeaders: CorsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,HEAD,POST,OPTIONS', - 'Access-Control-Max-Age': '86400', + 'Access-Control-Max-Age': CORS_PREFLIGHT_MAX_AGE_IN_SECONDS, }; const haveAcessControlHeaders = @@ -430,26 +449,38 @@ async function handleRequest(request: Route.LoaderArgs['request']) { if (!ALLOWED_PROXY_DOMAINS.has(apiUrlObj.origin)) { return handleErrorResponse({ - status: 403, + status: FORBIDDEN_STATUS, statusText: 'Forbidden', }); } try { - // fetch the requested resource - const response = await fetch(apiUrl); + const response = await fetchAllowedProxyResponse(apiUrlObj); - const respHeaders: HandleRequestResponHeaders = { - 'Access-Control-Allow-Origin': url.origin, - Vary: 'Origin', // Append to/Add Vary header so browser will cache response correctly - }; + if (response == null) { + return handleErrorResponse({ + status: FORBIDDEN_STATUS, + statusText: 'Forbidden', + }); + } - if (response.headers.has('content-type')) { - respHeaders['content-type'] = response.headers.get( - 'content-type', - ) as string; + const contentType = response.headers.get('content-type'); + + if (!hasAllowedScriptContentType(contentType)) { + return handleErrorResponse({ + status: UNSUPPORTED_MEDIA_TYPE_STATUS, + statusText: 'Unsupported Media Type', + }); } + const respHeaders: ProxyResponseHeaders = { + 'Access-Control-Allow-Origin': url.origin, + 'Content-Security-Policy': PROXY_CONTENT_SECURITY_POLICY, + Vary: 'Origin', + 'X-Content-Type-Options': 'nosniff', + 'content-type': contentType, + }; + if (response.headers.has('cache-control')) { respHeaders['cache-control'] = response.headers.get( 'cache-control', @@ -469,6 +500,52 @@ async function handleRequest(request: Route.LoaderArgs['request']) { } } } + +async function fetchAllowedProxyResponse(apiUrlObj: URL) { + let redirectCount = 0; + let nextUrl = apiUrlObj; + + while (true) { + const response = await fetch(nextUrl.href, {redirect: 'manual'}); + + if (!isRedirectResponse(response)) { + return response; + } + + if (redirectCount >= MAX_PROXY_REDIRECTS) { + return null; + } + + const location = response.headers.get('location'); + if (location == null) { + return null; + } + + nextUrl = new URL(location, nextUrl); + if (!ALLOWED_PROXY_DOMAINS.has(nextUrl.origin)) { + return null; + } + + redirectCount += 1; + } +} + +function hasAllowedScriptContentType( + contentType: string | null, +): contentType is string { + if (contentType == null) { + return false; + } + + const [mediaType] = contentType.split(';'); + + return ALLOWED_SCRIPT_CONTENT_TYPES.has(mediaType.trim().toLowerCase()); +} + +function isRedirectResponse(response: Response) { + return REDIRECT_RESPONSE_STATUSES.has(response.status); +} + ~~~ ### Step 5: Configure CSP headers @@ -653,7 +730,7 @@ Add a Partytown dependency and npm script for copying library files. #### File: /package.json ~~~diff -@@ -8,12 +8,14 @@ +@@ -8,9 +8,10 @@ "build": "shopify hydrogen build --codegen", "dev": "shopify hydrogen dev --codegen", "preview": "shopify hydrogen preview --build", @@ -666,8 +743,8 @@ Add a Partytown dependency and npm script for copying library files. }, "prettier": "@shopify/prettier-config", "dependencies": { +@@ -18,2 +19,3 @@ + "@qwik.dev/partytown": "^0.11.2", - "@shopify/hydrogen": "workspace:*", "graphql": "^16.10.0", "graphql-tag": "^2.12.6", ~~~ diff --git a/cookbook/recipes/partytown/README.md b/cookbook/recipes/partytown/README.md index 3bd408a068..6f8cfc3b7f 100644 --- a/cookbook/recipes/partytown/README.md +++ b/cookbook/recipes/partytown/README.md @@ -215,7 +215,7 @@ index c584e5370..1ac3a34cb 100644 + +1. **Partytown** runs third-party scripts in a web worker, keeping the main thread free +2. **GTM scripts** are loaded with `type="text/partytown"` to run in the worker -+3. **Reverse proxy** handles scripts that need CORS headers ++3. **Reverse proxy** handles scripts that need CORS headers, rejects upstream responses without a JavaScript content type, and blocks redirects outside the allowlist +4. **CSP headers** are configured to allow GTM and Google Analytics domains + +### Performance benefits @@ -254,11 +254,13 @@ Reverse the proxy route for third-party scripts requiring CORS headers. import type {Route} from './+types/reverse-proxy'; -type HandleRequestResponHeaders = { +type ProxyResponseHeaders = { 'Access-Control-Allow-Origin': string; + 'Content-Security-Policy': string; Vary: string; + 'X-Content-Type-Options': string; 'cache-control'?: string; - 'content-type'?: string; + 'content-type': string; }; type CorsHeaders = { @@ -277,6 +279,23 @@ const ALLOWED_PROXY_DOMAINS = new Set([ // other domains you may want to allow to proxy to ]); +const CORS_PREFLIGHT_MAX_AGE_IN_SECONDS = '86400'; +const PROXY_CONTENT_SECURITY_POLICY = + "default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"; +const FORBIDDEN_STATUS = 403; +const MAX_PROXY_REDIRECTS = 3; +const UNSUPPORTED_MEDIA_TYPE_STATUS = 415; + +const ALLOWED_SCRIPT_CONTENT_TYPES = new Set([ + 'application/ecmascript', + 'application/javascript', + 'application/x-javascript', + 'text/ecmascript', + 'text/javascript', +]); + +const REDIRECT_RESPONSE_STATUSES = new Set([301, 302, 303, 307, 308]); + // Handle CORS preflight for POST requests export async function action({request}: Route.ActionArgs) { const url = new URL(request.url); @@ -375,7 +394,7 @@ function handleCorsOptions(request: Route.LoaderArgs['request']) { const corsHeaders: CorsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,HEAD,POST,OPTIONS', - 'Access-Control-Max-Age': '86400', + 'Access-Control-Max-Age': CORS_PREFLIGHT_MAX_AGE_IN_SECONDS, }; const haveAcessControlHeaders = @@ -414,26 +433,38 @@ async function handleRequest(request: Route.LoaderArgs['request']) { if (!ALLOWED_PROXY_DOMAINS.has(apiUrlObj.origin)) { return handleErrorResponse({ - status: 403, + status: FORBIDDEN_STATUS, statusText: 'Forbidden', }); } try { - // fetch the requested resource - const response = await fetch(apiUrl); + const response = await fetchAllowedProxyResponse(apiUrlObj); - const respHeaders: HandleRequestResponHeaders = { - 'Access-Control-Allow-Origin': url.origin, - Vary: 'Origin', // Append to/Add Vary header so browser will cache response correctly - }; + if (response == null) { + return handleErrorResponse({ + status: FORBIDDEN_STATUS, + statusText: 'Forbidden', + }); + } - if (response.headers.has('content-type')) { - respHeaders['content-type'] = response.headers.get( - 'content-type', - ) as string; + const contentType = response.headers.get('content-type'); + + if (!hasAllowedScriptContentType(contentType)) { + return handleErrorResponse({ + status: UNSUPPORTED_MEDIA_TYPE_STATUS, + statusText: 'Unsupported Media Type', + }); } + const respHeaders: ProxyResponseHeaders = { + 'Access-Control-Allow-Origin': url.origin, + 'Content-Security-Policy': PROXY_CONTENT_SECURITY_POLICY, + Vary: 'Origin', + 'X-Content-Type-Options': 'nosniff', + 'content-type': contentType, + }; + if (response.headers.has('cache-control')) { respHeaders['cache-control'] = response.headers.get( 'cache-control', @@ -453,6 +484,52 @@ async function handleRequest(request: Route.LoaderArgs['request']) { } } } + +async function fetchAllowedProxyResponse(apiUrlObj: URL) { + let redirectCount = 0; + let nextUrl = apiUrlObj; + + while (true) { + const response = await fetch(nextUrl.href, {redirect: 'manual'}); + + if (!isRedirectResponse(response)) { + return response; + } + + if (redirectCount >= MAX_PROXY_REDIRECTS) { + return null; + } + + const location = response.headers.get('location'); + if (location == null) { + return null; + } + + nextUrl = new URL(location, nextUrl); + if (!ALLOWED_PROXY_DOMAINS.has(nextUrl.origin)) { + return null; + } + + redirectCount += 1; + } +} + +function hasAllowedScriptContentType( + contentType: string | null, +): contentType is string { + if (contentType == null) { + return false; + } + + const [mediaType] = contentType.split(';'); + + return ALLOWED_SCRIPT_CONTENT_TYPES.has(mediaType.trim().toLowerCase()); +} + +function isRedirectResponse(response: Response) { + return REDIRECT_RESPONSE_STATUSES.has(response.status); +} + ~~~ @@ -657,10 +734,10 @@ Add a Partytown dependency and npm script for copying library files. #### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) ~~~diff -index 0bb332639..529084e73 100644 +index a9c5da64b..e7736315a 100644 --- a/templates/skeleton/package.json +++ b/templates/skeleton/package.json -@@ -8,12 +8,14 @@ +@@ -8,9 +8,10 @@ "build": "shopify hydrogen build --codegen", "dev": "shopify hydrogen dev --codegen", "preview": "shopify hydrogen preview --build", @@ -673,8 +750,8 @@ index 0bb332639..529084e73 100644 }, "prettier": "@shopify/prettier-config", "dependencies": { +@@ -18,2 +19,3 @@ + "@qwik.dev/partytown": "^0.11.2", - "@shopify/hydrogen": "workspace:*", "graphql": "^16.10.0", "graphql-tag": "^2.12.6", ~~~ diff --git a/cookbook/recipes/partytown/__tests__/partytown.test.ts b/cookbook/recipes/partytown/__tests__/partytown.test.ts index 5c53b57089..b6541c70fa 100644 --- a/cookbook/recipes/partytown/__tests__/partytown.test.ts +++ b/cookbook/recipes/partytown/__tests__/partytown.test.ts @@ -1,8 +1,147 @@ -import {describe, expect, it} from 'vitest'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {loader} from '../ingredients/templates/skeleton/app/routes/reverse-proxy'; import {maybeProxyRequest} from '../ingredients/templates/skeleton/app/utils/partytown/maybeProxyRequest'; import {partytownAtomicHeaders} from '../ingredients/templates/skeleton/app/utils/partytown/partytownAtomicHeaders'; describe('partytown recipe', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('reverse-proxy route', () => { + const forbiddenStatus = 403; + const storeOrigin = 'https://my-store.com'; + const scriptCacheControl = 'public, immutable'; + const unsupportedMediaTypeStatus = 415; + + it('rejects proxied responses without a JavaScript content type', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response('', { + headers: { + 'content-type': 'text/html; charset=utf-8', + }, + }), + ), + ); + + const response = await loadProxyRoute('https://unpkg.com/unsafe-page'); + + await expectRejectedProxyResponse(response); + }); + + it('rejects redirects to non-allowlisted domains before following them', async () => { + const fetch = vi.fn( + async () => + new Response(null, { + headers: { + location: 'https://attacker.example/script.js', + }, + status: 302, + }), + ); + + vi.stubGlobal('fetch', fetch); + + const response = await loadProxyRoute('https://unpkg.com/redirect'); + + expect(response.status).toBe(forbiddenStatus); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledWith('https://unpkg.com/redirect', { + redirect: 'manual', + }); + expect(response.headers.get('content-type')).toBeNull(); + expect(await response.text()).toBe(''); + }); + + it('follows redirects to allowlisted domains', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + headers: { + location: 'https://unpkg.com/script.js', + }, + status: 302, + }), + ) + .mockResolvedValueOnce( + new Response('console.log("redirected")', { + headers: { + 'content-type': 'text/javascript; charset=utf-8', + }, + }), + ); + + vi.stubGlobal('fetch', fetch); + + const response = await loadProxyRoute('https://unpkg.com/redirect'); + + expect(response.status).toBe(200); + expect(fetch).toHaveBeenNthCalledWith(1, 'https://unpkg.com/redirect', { + redirect: 'manual', + }); + expect(fetch).toHaveBeenNthCalledWith(2, 'https://unpkg.com/script.js', { + redirect: 'manual', + }); + expect(await response.text()).toBe('console.log("redirected")'); + }); + + it('rejects proxied responses without a content type', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('console.log("missing type")')), + ); + + const response = await loadProxyRoute('https://unpkg.com/missing-type'); + + await expectRejectedProxyResponse(response); + }); + + it('adds defensive headers to proxied script responses', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response('console.log("loaded")', { + headers: { + 'cache-control': scriptCacheControl, + 'content-type': 'text/javascript; charset=utf-8', + }, + }), + ), + ); + + const response = await loadProxyRoute('https://unpkg.com/script.js'); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe( + 'text/javascript; charset=utf-8', + ); + expect(response.headers.get('cache-control')).toBe(scriptCacheControl); + expect(response.headers.get('x-content-type-options')).toBe('nosniff'); + expect(response.headers.get('content-security-policy')).toBe( + "default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + ); + expect(await response.text()).toBe('console.log("loaded")'); + }); + + function loadProxyRoute(apiUrl: string) { + const requestUrl = new URL('/reverse-proxy', storeOrigin); + requestUrl.searchParams.set('apiUrl', apiUrl); + + return loader({request: new Request(requestUrl)} as never); + } + + async function expectRejectedProxyResponse(response: Response) { + expect(response.status).toBe(unsupportedMediaTypeStatus); + expect(response.headers.get('content-type')).toBeNull(); + expect(await response.text()).toBe(''); + } + }); + describe('maybeProxyRequest', () => { const location = {origin: 'https://my-store.com'} as Location; diff --git a/cookbook/recipes/partytown/ingredients/templates/skeleton/app/routes/reverse-proxy.ts b/cookbook/recipes/partytown/ingredients/templates/skeleton/app/routes/reverse-proxy.ts index c43f54c73d..a0d11a649d 100644 --- a/cookbook/recipes/partytown/ingredients/templates/skeleton/app/routes/reverse-proxy.ts +++ b/cookbook/recipes/partytown/ingredients/templates/skeleton/app/routes/reverse-proxy.ts @@ -3,11 +3,13 @@ import type {Route} from './+types/reverse-proxy'; -type HandleRequestResponHeaders = { +type ProxyResponseHeaders = { 'Access-Control-Allow-Origin': string; + 'Content-Security-Policy': string; Vary: string; + 'X-Content-Type-Options': string; 'cache-control'?: string; - 'content-type'?: string; + 'content-type': string; }; type CorsHeaders = { @@ -26,6 +28,23 @@ const ALLOWED_PROXY_DOMAINS = new Set([ // other domains you may want to allow to proxy to ]); +const CORS_PREFLIGHT_MAX_AGE_IN_SECONDS = '86400'; +const PROXY_CONTENT_SECURITY_POLICY = + "default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"; +const FORBIDDEN_STATUS = 403; +const MAX_PROXY_REDIRECTS = 3; +const UNSUPPORTED_MEDIA_TYPE_STATUS = 415; + +const ALLOWED_SCRIPT_CONTENT_TYPES = new Set([ + 'application/ecmascript', + 'application/javascript', + 'application/x-javascript', + 'text/ecmascript', + 'text/javascript', +]); + +const REDIRECT_RESPONSE_STATUSES = new Set([301, 302, 303, 307, 308]); + // Handle CORS preflight for POST requests export async function action({request}: Route.ActionArgs) { const url = new URL(request.url); @@ -124,7 +143,7 @@ function handleCorsOptions(request: Route.LoaderArgs['request']) { const corsHeaders: CorsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,HEAD,POST,OPTIONS', - 'Access-Control-Max-Age': '86400', + 'Access-Control-Max-Age': CORS_PREFLIGHT_MAX_AGE_IN_SECONDS, }; const haveAcessControlHeaders = @@ -163,26 +182,38 @@ async function handleRequest(request: Route.LoaderArgs['request']) { if (!ALLOWED_PROXY_DOMAINS.has(apiUrlObj.origin)) { return handleErrorResponse({ - status: 403, + status: FORBIDDEN_STATUS, statusText: 'Forbidden', }); } try { - // fetch the requested resource - const response = await fetch(apiUrl); + const response = await fetchAllowedProxyResponse(apiUrlObj); - const respHeaders: HandleRequestResponHeaders = { - 'Access-Control-Allow-Origin': url.origin, - Vary: 'Origin', // Append to/Add Vary header so browser will cache response correctly - }; + if (response == null) { + return handleErrorResponse({ + status: FORBIDDEN_STATUS, + statusText: 'Forbidden', + }); + } - if (response.headers.has('content-type')) { - respHeaders['content-type'] = response.headers.get( - 'content-type', - ) as string; + const contentType = response.headers.get('content-type'); + + if (!hasAllowedScriptContentType(contentType)) { + return handleErrorResponse({ + status: UNSUPPORTED_MEDIA_TYPE_STATUS, + statusText: 'Unsupported Media Type', + }); } + const respHeaders: ProxyResponseHeaders = { + 'Access-Control-Allow-Origin': url.origin, + 'Content-Security-Policy': PROXY_CONTENT_SECURITY_POLICY, + Vary: 'Origin', + 'X-Content-Type-Options': 'nosniff', + 'content-type': contentType, + }; + if (response.headers.has('cache-control')) { respHeaders['cache-control'] = response.headers.get( 'cache-control', @@ -201,4 +232,49 @@ async function handleRequest(request: Route.LoaderArgs['request']) { }); } } -} \ No newline at end of file +} + +async function fetchAllowedProxyResponse(apiUrlObj: URL) { + let redirectCount = 0; + let nextUrl = apiUrlObj; + + while (true) { + const response = await fetch(nextUrl.href, {redirect: 'manual'}); + + if (!isRedirectResponse(response)) { + return response; + } + + if (redirectCount >= MAX_PROXY_REDIRECTS) { + return null; + } + + const location = response.headers.get('location'); + if (location == null) { + return null; + } + + nextUrl = new URL(location, nextUrl); + if (!ALLOWED_PROXY_DOMAINS.has(nextUrl.origin)) { + return null; + } + + redirectCount += 1; + } +} + +function hasAllowedScriptContentType( + contentType: string | null, +): contentType is string { + if (contentType == null) { + return false; + } + + const [mediaType] = contentType.split(';'); + + return ALLOWED_SCRIPT_CONTENT_TYPES.has(mediaType.trim().toLowerCase()); +} + +function isRedirectResponse(response: Response) { + return REDIRECT_RESPONSE_STATUSES.has(response.status); +} diff --git a/cookbook/recipes/partytown/patches/README.md.1764cd.patch b/cookbook/recipes/partytown/patches/README.md.1764cd.patch index e3a70b8ef6..6abeafdc67 100644 --- a/cookbook/recipes/partytown/patches/README.md.1764cd.patch +++ b/cookbook/recipes/partytown/patches/README.md.1764cd.patch @@ -69,7 +69,7 @@ index c584e5370..1ac3a34cb 100644 + +1. **Partytown** runs third-party scripts in a web worker, keeping the main thread free +2. **GTM scripts** are loaded with `type="text/partytown"` to run in the worker -+3. **Reverse proxy** handles scripts that need CORS headers ++3. **Reverse proxy** handles scripts that need CORS headers, rejects upstream responses without a JavaScript content type, and blocks redirects outside the allowlist +4. **CSP headers** are configured to allow GTM and Google Analytics domains + +### Performance benefits diff --git a/cookbook/recipes/partytown/patches/package.json.8e0ff5.patch b/cookbook/recipes/partytown/patches/package.json.8e0ff5.patch index baf6aca715..d5179995d1 100644 --- a/cookbook/recipes/partytown/patches/package.json.8e0ff5.patch +++ b/cookbook/recipes/partytown/patches/package.json.8e0ff5.patch @@ -1,7 +1,7 @@ -index 0bb332639..529084e73 100644 +index a9c5da64b..e7736315a 100644 --- a/templates/skeleton/package.json +++ b/templates/skeleton/package.json -@@ -8,12 +8,14 @@ +@@ -8,9 +8,10 @@ "build": "shopify hydrogen build --codegen", "dev": "shopify hydrogen dev --codegen", "preview": "shopify hydrogen preview --build", @@ -14,7 +14,7 @@ index 0bb332639..529084e73 100644 }, "prettier": "@shopify/prettier-config", "dependencies": { +@@ -18,2 +19,3 @@ + "@qwik.dev/partytown": "^0.11.2", - "@shopify/hydrogen": "workspace:*", "graphql": "^16.10.0", "graphql-tag": "^2.12.6",