From a7cd0eeefab88f8620764d4cf86e7fc32374b0e6 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Sun, 25 Oct 2026 14:52:42 -0500 Subject: [PATCH] Warn when a theme app extension contacts origins it doesn't declare Add a shared detector that finds the origins a theme app extension loads from or sends requests to, and compares them with `[extensions.capabilities.network_access] allowed_sources`. - The theme build step prints a non-blocking warning for each undeclared origin, or one info line when the extension doesn't declare allowed_sources. - A new deterministic app security check, THEME_EXTENSION_UNDECLARED_EGRESS (version 1, medium), reports the same origins. Navigation targets (links, form actions, redirects) are classified but not reported. --- .../theme-extension-undeclared-origins.md | 5 + .../models/extensions/specifications/theme.ts | 2 + .../THEME_EXTENSION_UNDECLARED_EGRESS.md | 18 + .../app-security-engine/checks/embedded.ts | 1 + .../app-security-engine/rules/catalog.ts | 11 + .../rules/theme-egress-rules.ts | 92 +++ .../app-security-engine/scanners/discover.ts | 33 +- .../app-security-engine/scanners/index.ts | 11 + .../app-security-engine/tests/catalog.test.ts | 1 + .../app-security-engine/tests/checks.test.ts | 1 + .../tests/deterministic-rules.test.ts | 2 + .../tests/registry.test.ts | 5 +- .../tests/theme-egress.test.ts | 131 +++++ .../build/steps/build-theme-step.test.ts | 125 ++++ .../services/build/steps/build-theme-step.ts | 83 ++- .../utilities/extensions/theme-egress.test.ts | 277 +++++++++ .../cli/utilities/extensions/theme-egress.ts | 540 ++++++++++++++++++ 17 files changed, 1334 insertions(+), 4 deletions(-) create mode 100644 .changeset/theme-extension-undeclared-origins.md create mode 100644 packages/app/src/cli/services/app-security-engine/checks/THEME_EXTENSION_UNDECLARED_EGRESS.md create mode 100644 packages/app/src/cli/services/app-security-engine/rules/theme-egress-rules.ts create mode 100644 packages/app/src/cli/services/app-security-engine/tests/theme-egress.test.ts create mode 100644 packages/app/src/cli/services/build/steps/build-theme-step.test.ts create mode 100644 packages/app/src/cli/utilities/extensions/theme-egress.test.ts create mode 100644 packages/app/src/cli/utilities/extensions/theme-egress.ts diff --git a/.changeset/theme-extension-undeclared-origins.md b/.changeset/theme-extension-undeclared-origins.md new file mode 100644 index 00000000000..1b18fbf76d5 --- /dev/null +++ b/.changeset/theme-extension-undeclared-origins.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Warn during build and deploy when a theme app extension loads from or sends requests to origins that its `[extensions.capabilities.network_access] allowed_sources` doesn't declare, and add the `THEME_EXTENSION_UNDECLARED_EGRESS` app security check. diff --git a/packages/app/src/cli/models/extensions/specifications/theme.ts b/packages/app/src/cli/models/extensions/specifications/theme.ts index 37a02c2a153..07673607b90 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.ts @@ -111,6 +111,8 @@ const themeSpec = createExtensionSpecification({ } }, preDeployValidation: async (extension) => { + // Undeclared network origins are only a build warning for now (see build-theme-step.ts). Once storefronts enforce + // allowed_sources, deploys can fail here using the same detector (utilities/extensions/theme-egress.ts). return validateThemeExtension(extension) }, }) diff --git a/packages/app/src/cli/services/app-security-engine/checks/THEME_EXTENSION_UNDECLARED_EGRESS.md b/packages/app/src/cli/services/app-security-engine/checks/THEME_EXTENSION_UNDECLARED_EGRESS.md new file mode 100644 index 00000000000..bbc777d7c7e --- /dev/null +++ b/packages/app/src/cli/services/app-security-engine/checks/THEME_EXTENSION_UNDECLARED_EGRESS.md @@ -0,0 +1,18 @@ +--- +id: THEME_EXTENSION_UNDECLARED_EGRESS +version: 1 +severity: medium +precedence: union +--- + +# Theme Extension Undeclared Egress + +Theme app extensions declare the origins their storefront code contacts in `shopify.extension.toml`, under `[capabilities.network_access] allowed_sources` (`[extensions.capabilities.network_access]` in the `[[extensions]]` format). Entries are HTTPS origins such as `https://api.example.com`, with an optional port. A leading `*.` covers one or more subdomain labels but not the bare domain. Requests to undeclared origins may be reported now and blocked on storefronts later. + +Inspect only theme app extensions (`type = "theme"`). The deterministic rule already reports literal URLs in Liquid and HTML (` + +Go +{% schema %}{"name":"App","target":"section"}{% endschema %}`, + 'extensions/theme/assets/app.js': `fetch(endpoint)\nfetch('http://legacy.example.com/collect')`, + 'extensions/theme/assets/app.css': `.hero { background: url(https://img.example.org/hero.png); }`, +} +const themeConfig = (declaration = '') => `type = "theme"\nname = "Theme"\n${declaration}` + +async function findings(files: {[path: string]: string}) { + const result = await scan(await app({'shopify.app.toml': appConfig, ...files})) + return { + issues: result.issues.filter((issue) => issue.id === CHECK_ID), + execution: result.scan.checks_executed.find((execution) => execution.id === CHECK_ID), + } +} + +describe('THEME_EXTENSION_UNDECLARED_EGRESS', () => { + test('reports each undeclared egress origin where it is used when allowed_sources is declared', async () => { + const {issues, execution} = await findings({ + ...themeFiles, + 'extensions/theme/shopify.extension.toml': themeConfig( + `[capabilities.network_access]\nallowed_sources = ["https://api.example.com"]\n`, + ), + }) + + expect(execution).toMatchObject({version: 1, status: 'executed', findings: 3, applicable: true}) + expect(execution?.inspected_files).toEqual( + expect.arrayContaining(['extensions/theme/assets/app.css', 'extensions/theme/assets/app.js']), + ) + expect(issues).toMatchObject([ + { + pattern_id: 'undeclared-origin', + severity: 'medium', + location: {file: 'extensions/theme/assets/app.css', line: 1, column: 25}, + message: + "CSS url() contacts https://img.example.org, which isn't in the extension's capabilities.network_access.allowed_sources.", + rule_version: 1, + }, + { + pattern_id: 'insecure-origin', + severity: 'medium', + location: {file: 'extensions/theme/assets/app.js', line: 2, column: 7}, + message: + "fetch() contacts http://legacy.example.com over an insecure scheme. capabilities.network_access.allowed_sources accepts only HTTPS origins, so this request can't be declared.", + rule_version: 1, + }, + { + pattern_id: 'undeclared-origin', + severity: 'medium', + location: {file: 'extensions/theme/blocks/app.liquid', line: 2, column: 14}, + message: + " + + +`, + 'assets/app.js': `fetch('http://legacy.example.com/collect')\nfetch(endpoint)\nnew WebSocket('wss://live.example.org/socket')`, + 'assets/app.css': `.hero { background: url(https://img.example.org/hero.png); }`, +} + +async function writeExtension(directory: string, files: {[path: string]: string}) { + await Promise.all( + Object.entries(files).map(async ([path, content]) => { + const fullPath = joinPath(directory, path) + await mkdir(dirname(fullPath)) + await writeFile(fullPath, content) + }), + ) +} + +function contextFor(directory: string, capabilities?: object) { + const write = vi.fn() + const context = { + extension: {directory, configuration: {name: 'Theme', type: 'theme', capabilities}} as unknown as ExtensionInstance, + options: {stdout: {write} as any, stderr: {write: vi.fn()} as any, app: {} as any, environment: 'production'}, + stepResults: new Map(), + } as BuildContext + return {context, output: () => write.mock.calls.map(([chunk]) => chunk).join('')} +} + +describe('executeBuildThemeStep', () => { + beforeEach(() => { + vi.mocked(runThemeCheck).mockResolvedValue('') + }) + + test('warns about each undeclared origin when allowed_sources is declared', async () => { + await inTemporaryDirectory(async (directory) => { + await writeExtension(directory, FILES) + const {context, output} = contextFor(directory, { + network_access: {allowed_sources: ['https://api.example.com']}, + }) + + await executeBuildThemeStep(step, context) + + expect(output()).toBe(`Running theme check on your Theme app extension... +[warning]: https://img.example.org (assets/app.css:1) isn't in allowed_sources. +[warning]: http://legacy.example.com (assets/app.js:1) uses an insecure scheme. allowed_sources accepts only HTTPS origins. +[warning]: wss://live.example.org (assets/app.js:3) isn't in allowed_sources. Declare it as https://live.example.org. +[warning]: https://cdn.other.example.net (blocks/app.liquid:2 and 1 more) isn't in allowed_sources. +Add the origins your extension needs to capabilities.network_access.allowed_sources in shopify.extension.toml. Requests to undeclared origins may be blocked on storefronts in the future. +[info]: 1 URL is built at runtime and can't be checked (assets/app.js:2).`) + }) + }) + + test('prints one info line when allowed_sources is not declared', async () => { + await inTemporaryDirectory(async (directory) => { + await writeExtension(directory, FILES) + const {context, output} = contextFor(directory) + + await executeBuildThemeStep(step, context) + + expect(output()).toBe(`Running theme check on your Theme app extension... +[info]: This extension contacts 5 external origins. Consider declaring them in capabilities.network_access.allowed_sources in shopify.extension.toml.`) + }) + }) + + test('prints nothing more when every origin is declared', async () => { + await inTemporaryDirectory(async (directory) => { + await writeExtension(directory, {'blocks/app.liquid': FILES['blocks/app.liquid']}) + const {context, output} = contextFor(directory, { + network_access: {allowed_sources: ['https://api.example.com', 'https://*.other.example.net']}, + }) + + await executeBuildThemeStep(step, context) + + expect(output()).toBe('Running theme check on your Theme app extension...') + }) + }) + + test("doesn't report navigation links or form actions", async () => { + await inTemporaryDirectory(async (directory) => { + await writeExtension(directory, { + 'blocks/app.liquid': `Go\n
`, + }) + const declared = contextFor(directory, {network_access: {allowed_sources: []}}) + const undeclared = contextFor(directory) + + await executeBuildThemeStep(step, declared.context) + await executeBuildThemeStep(step, undeclared.context) + + expect(declared.output()).toBe('Running theme check on your Theme app extension...') + expect(undeclared.output()).toBe('Running theme check on your Theme app extension...') + }) + }) + + test('never fails the build when the detector throws', async () => { + await inTemporaryDirectory(async (directory) => { + await writeExtension(directory, FILES) + vi.mocked(findOutboundOrigins).mockImplementationOnce(() => { + throw new Error('detector bug') + }) + vi.mocked(runThemeCheck).mockResolvedValue('theme check offenses') + const {context, output} = contextFor(directory, {network_access: {allowed_sources: []}}) + + await expect(executeBuildThemeStep(step, context)).resolves.toBeUndefined() + expect(output()).toBe('Running theme check on your Theme app extension...theme check offenses') + }) + }) +}) diff --git a/packages/app/src/cli/services/build/steps/build-theme-step.ts b/packages/app/src/cli/services/build/steps/build-theme-step.ts index 5c9c65861a2..135531cb45c 100644 --- a/packages/app/src/cli/services/build/steps/build-theme-step.ts +++ b/packages/app/src/cli/services/build/steps/build-theme-step.ts @@ -1,14 +1,95 @@ import {runThemeCheck} from '../theme-check.js' +import {themeExtensionFiles} from '../../../utilities/extensions/theme.js' +import { + allowedSourceFor, + declaredAllowedSources, + findOutboundOrigins, + type OutboundFinding, +} from '../../../utilities/extensions/theme-egress.js' +import {readFile} from '@shopify/cli-kit/node/fs' +import {relativePath} from '@shopify/cli-kit/node/path' +import {outputDebug} from '@shopify/cli-kit/node/output' import type {LifecycleStep, BuildContext} from '../client-steps.js' +const EGRESS_SCANNED_FILE_EXTENSIONS = ['.liquid', '.js', '.mjs', '.css'] +const ALLOWED_SOURCES_TABLE = 'capabilities.network_access.allowed_sources' + /** * Executes a build_theme build step. * - * Runs theme check on the extension directory and writes any offenses to stdout. + * Runs theme check on the extension directory and writes any offenses to stdout, then warns about origins the + * extension contacts without declaring them. Neither fails the build. */ export async function executeBuildThemeStep(_step: LifecycleStep, context: BuildContext): Promise { const {extension, options} = context options.stdout.write(`Running theme check on your Theme app extension...`) const offenses = await runThemeCheck(extension.directory) if (offenses) options.stdout.write(offenses) + await warnAboutUndeclaredOrigins(context) +} + +/** + * Warns when the extension loads from or sends requests to origins that `allowed_sources` doesn't cover. + * + * This is advisory only. When storefronts enforce the declared origins, blocking deploys belongs in the theme + * specification's `preDeployValidation`, using the same detector. + */ +async function warnAboutUndeclaredOrigins({extension, options}: BuildContext): Promise { + try { + const allowedSources = declaredAllowedSources(extension.configuration.capabilities) + const paths = (await themeExtensionFiles(extension)).filter((path) => + EGRESS_SCANNED_FILE_EXTENSIONS.some((fileExtension) => path.endsWith(fileExtension)), + ) + const files = await Promise.all( + paths.map(async (path) => ({path: relativePath(extension.directory, path), content: await readFile(path)})), + ) + // Navigation (links, form actions, redirects) isn't covered by allowed_sources, so it isn't reported here. + const egress = findOutboundOrigins(files, allowedSources).findings.filter((finding) => finding.kind === 'egress') + const message = undeclaredOriginsMessage(egress, allowedSources !== undefined) + if (message) options.stdout.write(message) + // The warning must never fail a build or deploy. + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputDebug(`Skipped the check for undeclared network origins: ${error instanceof Error ? error.message : error}`) + } +} + +function undeclaredOriginsMessage(findings: OutboundFinding[], declared: boolean): string | undefined { + const byOrigin = new Map() + for (const finding of findings) { + if (finding.status !== 'undeclared' || !finding.origin) continue + byOrigin.set(finding.origin, [...(byOrigin.get(finding.origin) ?? []), finding]) + } + + // Extensions that don't declare allowed_sources get one line, not one per origin. + if (!declared) { + if (byOrigin.size === 0) return undefined + const origins = byOrigin.size === 1 ? '1 external origin' : `${byOrigin.size} external origins` + return `\n[info]: This extension contacts ${origins}. Consider declaring them in ${ALLOWED_SOURCES_TABLE} in shopify.extension.toml.` + } + + const dynamic = findings.filter((finding) => finding.status === 'dynamic') + if (byOrigin.size === 0 && dynamic.length === 0) return undefined + const lines = [...byOrigin.entries()].map(([origin, occurrences]) => { + const entry = allowedSourceFor(origin) + let reason = "isn't in allowed_sources." + if (occurrences[0]!.insecure) reason = 'uses an insecure scheme. allowed_sources accepts only HTTPS origins.' + else if (entry !== origin) reason = `isn't in allowed_sources. Declare it as ${entry}.` + return `[warning]: ${origin} (${locations(occurrences)}) ${reason}` + }) + if (byOrigin.size > 0) { + lines.push( + `Add the origins your extension needs to ${ALLOWED_SOURCES_TABLE} in shopify.extension.toml. Requests to undeclared origins may be blocked on storefronts in the future.`, + ) + } + if (dynamic.length > 0) { + const requests = dynamic.length === 1 ? '1 URL is' : `${dynamic.length} URLs are` + lines.push(`[info]: ${requests} built at runtime and can't be checked (${locations(dynamic)}).`) + } + return `\n${lines.join('\n')}` +} + +function locations(findings: OutboundFinding[]): string { + const [first, ...rest] = findings.map((finding) => `${finding.file}:${finding.line}`) + return rest.length === 0 ? first! : `${first} and ${rest.length} more` } diff --git a/packages/app/src/cli/utilities/extensions/theme-egress.test.ts b/packages/app/src/cli/utilities/extensions/theme-egress.test.ts new file mode 100644 index 00000000000..e8f1bcf035a --- /dev/null +++ b/packages/app/src/cli/utilities/extensions/theme-egress.test.ts @@ -0,0 +1,277 @@ +import {allowedSourceFor, declaredAllowedSources, findOutboundOrigins, isOriginAllowed} from './theme-egress.js' +import {describe, expect, test} from 'vitest' + +const scan = (path: string, content: string, allowedSources?: string[]) => + findOutboundOrigins([{path, content}], allowedSources).findings + +const origins = (path: string, content: string, allowedSources?: string[]) => + scan(path, content, allowedSources) + .filter((finding) => finding.status === 'undeclared') + .map((finding) => finding.origin) + +describe('findOutboundOrigins', () => { + describe('Liquid', () => { + test.each([ + [''], + ['', ''], + ['', ''], + ['', ''], + [''], + ['', ''], + ['', ''], + ['script_tag filter', "{{ 'https://a.example.com/app.js' | script_tag }}"], + ['stylesheet_tag filter', "{{ 'https://a.example.com/app.css' | stylesheet_tag }}"], + ])('finds %s', (sink, content) => { + expect(scan('blocks/app.liquid', content)).toEqual([ + expect.objectContaining({ + kind: 'egress', + status: 'undeclared', + origin: 'https://a.example.com', + sink, + file: 'blocks/app.liquid', + line: 1, + }), + ]) + }) + + test('reports one-based line and column of the URL', () => { + const content = '
\n \n
' + expect(scan('snippets/app.liquid', content)).toEqual([expect.objectContaining({line: 2, column: 16})]) + }) + + test('finds requests in inline scripts and styles', () => { + const content = `

Hi

+ +` + expect(scan('blocks/app.liquid', content)).toEqual([ + expect.objectContaining({origin: 'https://api.example.com', line: 3, column: 9, sink: 'fetch()'}), + expect.objectContaining({origin: 'https://fonts.example.com', line: 5, sink: 'CSS @import'}), + ]) + }) + + test('ignores links that do not fetch', () => { + expect(scan('blocks/app.liquid', '')).toEqual([]) + }) + + test('keeps a literal origin before a dynamic path', () => { + expect(origins('blocks/app.liquid', '')).toEqual([ + 'https://img.example.com', + ]) + }) + + test('reports URLs built in Liquid as dynamic', () => { + expect(scan('blocks/app.liquid', '')).toEqual([ + expect.objectContaining({kind: 'egress', status: 'dynamic', sink: ' +{{ 'app.css' | asset_url | stylesheet_tag }} + + + + +` + expect(scan('blocks/app.liquid', content)).toEqual([]) + }) + + test('reports files the parser cannot read', () => { + expect(findOutboundOrigins([{path: 'blocks/broken.liquid', content: '{% if %} { + test.each([ + ['fetch()', "fetch('https://a.example.com/v1')"], + ['XMLHttpRequest.open()', "xhr.open('POST', 'https://a.example.com/v1')"], + ['new WebSocket()', "new WebSocket('wss://a.example.com/socket')"], + ['new EventSource()', 'new EventSource("https://a.example.com/events")'], + ['navigator.sendBeacon()', 'navigator.sendBeacon(`https://a.example.com/collect`, data)'], + ['import()', "await import('https://a.example.com/module.js')"], + ['new URL()', "const url = new URL('https://a.example.com/path')"], + ])('finds %s', (sink, content) => { + const expectedOrigin = sink === 'new WebSocket()' ? 'wss://a.example.com' : 'https://a.example.com' + expect(scan('assets/app.js', content)).toEqual([ + expect.objectContaining({kind: 'egress', status: 'undeclared', origin: expectedOrigin, sink}), + ]) + }) + + test('reports runtime URLs as dynamic', () => { + expect(scan('assets/app.js', 'fetch(endpoint)')).toEqual([expect.objectContaining({status: 'dynamic'})]) + // eslint-disable-next-line no-template-curly-in-string + expect(scan('assets/app.js', 'fetch(`https://${host}/v1`)')).toEqual([ + expect.objectContaining({status: 'dynamic'}), + ]) + expect(scan('assets/app.js', "fetch('https://' + host + '/v1')")).toEqual([ + expect.objectContaining({status: 'dynamic'}), + ]) + }) + + test('keeps a literal origin before a dynamic path', () => { + // eslint-disable-next-line no-template-curly-in-string + expect(origins('assets/app.js', 'fetch(`https://api.example.com/v1/${id}`)')).toEqual(['https://api.example.com']) + expect(origins('assets/app.js', "fetch('https://api.example.com/v1/' + id)")).toEqual(['https://api.example.com']) + }) + + test('ignores relative URLs, the shop routes and non-literal new URL()', () => { + const content = `fetch('/cart/add.js', {method: 'POST'}) +fetch(\`/apps/proxy/\${id}\`) +fetch(window.Shopify.routes.root + 'cart.js') +fetch(location.origin + '/cart.js') +const url = new URL(path, window.location.origin) +import('./chunk.js')` + expect(scan('assets/app.js', content)).toEqual([]) + }) + + test('ignores comments', () => { + expect( + scan('assets/app.js', "// fetch('https://a.example.com')\n/* new WebSocket('wss://b.example.com') */"), + ).toEqual([]) + }) + + test('ignores shop URLs rendered by Liquid in inline scripts', () => { + expect(scan('blocks/app.liquid', "")).toEqual([]) + }) + }) + + describe('CSS', () => { + test('finds url() and @import', () => { + const content = `@import 'https://fonts.example.com/a.css'; +.hero { background: url(https://img.example.com/hero.png); } +.icon { background: url("https://img.example.com/icon.svg"); } +.local { background: url(icon.svg); } +/* url(https://commented.example.com/x.png) */` + expect(scan('assets/app.css', content)).toEqual([ + expect.objectContaining({origin: 'https://fonts.example.com', line: 1, sink: 'CSS @import'}), + expect.objectContaining({origin: 'https://img.example.com', line: 2, column: 25, sink: 'CSS url()'}), + expect.objectContaining({origin: 'https://img.example.com', line: 3, sink: 'CSS url()'}), + ]) + }) + }) + + describe('declared sources', () => { + const content = ` +` + + test('reports only origins that allowed_sources does not cover', () => { + expect(origins('blocks/app.liquid', content, ['https://api.example.com'])).toEqual(['https://other.example.net']) + }) + + test('reports every external origin when nothing is declared', () => { + expect(origins('blocks/app.liquid', content)).toEqual(['https://api.example.com', 'https://other.example.net']) + }) + + test('reports nothing when every origin is declared', () => { + expect(scan('blocks/app.liquid', content, ['https://api.example.com', 'https://other.example.net'])).toEqual([]) + }) + + test('always reports http and ws origins', () => { + const insecure = "fetch('http://api.example.com/v1'); new WebSocket('ws://api.example.com')" + expect(scan('assets/app.js', insecure, ['https://api.example.com'])).toEqual([ + expect.objectContaining({origin: 'http://api.example.com', insecure: true}), + expect.objectContaining({origin: 'ws://api.example.com', insecure: true}), + ]) + }) + + test('ignores Shopify-owned hosts', () => { + const shopify = ` + +` + expect(scan('blocks/app.liquid', shopify, [])).toEqual([]) + }) + }) + + test('classifies navigation sinks separately from egress', () => { + const liquid = `Go +
` + const javascript = `location.href = 'https://nav.example.com/a' +window.location.assign('https://nav.example.com/b') +location.replace(next) +window.open('https://nav.example.com/c')` + const findings = findOutboundOrigins( + [ + {path: 'blocks/app.liquid', content: liquid}, + {path: 'assets/app.js', content: javascript}, + ], + [], + ).findings + expect(findings.map(({kind, status, sink, origin}) => ({kind, status, sink, origin}))).toEqual([ + {kind: 'navigation', status: 'undeclared', sink: 'location.href', origin: 'https://nav.example.com'}, + {kind: 'navigation', status: 'undeclared', sink: 'location.assign()', origin: 'https://nav.example.com'}, + {kind: 'navigation', status: 'dynamic', sink: 'location.replace()', origin: undefined}, + {kind: 'navigation', status: 'undeclared', sink: 'window.open()', origin: 'https://nav.example.com'}, + {kind: 'navigation', status: 'undeclared', sink: '', origin: 'https://nav.example.com'}, + {kind: 'navigation', status: 'undeclared', sink: '
', origin: 'https://forms.example.com'}, + ]) + }) +}) + +describe('isOriginAllowed', () => { + test('matches exact origins case-insensitively', () => { + expect(isOriginAllowed('https://API.Example.com/v1', ['https://api.example.com'])).toBe(true) + expect(isOriginAllowed('https://api.example.com', ['HTTPS://API.EXAMPLE.COM/'])).toBe(true) + expect(isOriginAllowed('https://www.example.com', ['https://api.example.com'])).toBe(false) + }) + + test('matches one or more subdomain labels for a leading wildcard, but not the bare domain', () => { + const allowed = ['https://*.cdn.example.com'] + expect(isOriginAllowed('https://a.cdn.example.com', allowed)).toBe(true) + expect(isOriginAllowed('https://a.b.cdn.example.com', allowed)).toBe(true) + expect(isOriginAllowed('https://cdn.example.com', allowed)).toBe(false) + expect(isOriginAllowed('https://evilcdn.example.com', allowed)).toBe(false) + }) + + test('normalizes :443 and requires other ports to match', () => { + expect(isOriginAllowed('https://api.example.com:443', ['https://api.example.com'])).toBe(true) + expect(isOriginAllowed('https://api.example.com', ['https://api.example.com:443'])).toBe(true) + expect(isOriginAllowed('https://api.example.com:8443', ['https://api.example.com'])).toBe(false) + expect(isOriginAllowed('https://api.example.com:8443', ['https://api.example.com:8443'])).toBe(true) + }) + + test('matches wss like https and never matches http or ws', () => { + expect(isOriginAllowed('wss://api.example.com', ['https://api.example.com'])).toBe(true) + expect(isOriginAllowed('http://api.example.com', ['https://api.example.com'])).toBe(false) + expect(isOriginAllowed('ws://api.example.com', ['https://api.example.com'])).toBe(false) + }) + + test('ignores invalid entries', () => { + expect(isOriginAllowed('https://api.example.com', ['http://api.example.com', 'api.example.com'])).toBe(false) + }) +}) + +describe('allowedSourceFor', () => { + test('gives the HTTPS entry that covers an origin', () => { + expect(allowedSourceFor('https://api.example.com')).toBe('https://api.example.com') + expect(allowedSourceFor('wss://realtime.example.com')).toBe('https://realtime.example.com') + expect(allowedSourceFor('wss://realtime.example.com:8443')).toBe('https://realtime.example.com:8443') + }) + + test('returns undefined for origins that cannot be declared', () => { + expect(allowedSourceFor('http://api.example.com')).toBeUndefined() + expect(allowedSourceFor('ws://api.example.com')).toBeUndefined() + }) +}) + +describe('declaredAllowedSources', () => { + test('reads allowed_sources from a network_access table', () => { + expect(declaredAllowedSources({network_access: {allowed_sources: ['https://a.example.com']}})).toEqual([ + 'https://a.example.com', + ]) + expect(declaredAllowedSources({network_access: {allowed_sources: []}})).toEqual([]) + }) + + test('returns undefined when nothing is declared', () => { + expect(declaredAllowedSources(undefined)).toBeUndefined() + expect(declaredAllowedSources({network_access: true})).toBeUndefined() + expect(declaredAllowedSources({network_access: {}})).toBeUndefined() + }) +}) diff --git a/packages/app/src/cli/utilities/extensions/theme-egress.ts b/packages/app/src/cli/utilities/extensions/theme-egress.ts new file mode 100644 index 00000000000..7f837603b15 --- /dev/null +++ b/packages/app/src/cli/utilities/extensions/theme-egress.ts @@ -0,0 +1,540 @@ +import {normalizeAllowedSource} from '../../models/extensions/specifications/theme.js' +import {SourceCodeType, toLiquidHTMLAST, visit} from '@shopify/theme-check-node' +import type {LiquidHtmlNode} from '@shopify/theme-check-node' + +/** + * Finds the origins a theme app extension contacts, so they can be compared with the origins it declares in + * `[extensions.capabilities.network_access] allowed_sources`. + * + * The detector is static and conservative. It only resolves URLs written as literals (or with a literal origin + * followed by a dynamic path). URLs built at runtime are reported as `dynamic` because they can't be checked here. + * Relative URLs, Liquid URL filters such as `asset_url`, the shop's own routes and Shopify-owned hosts are ignored. + */ + +/** + * `egress` sinks load or send data (scripts, styles, frames, images, requests). Only they are checked against + * `allowed_sources`. `navigation` sinks move the visitor to another page. They're collected so a later check can + * compare them with app-level navigation hosts, but nothing reports them yet. + */ +export type OutboundSinkKind = 'egress' | 'navigation' + +/** + * `undeclared`: a literal external origin. For egress, it isn't covered by `allowed_sources` (or nothing is declared). + * For navigation, it isn't compared with any list yet. + * `dynamic`: the URL is built at runtime, so it can't be checked statically. + */ +export type OutboundFindingStatus = 'undeclared' | 'dynamic' + +interface OutboundSourceFile { + /** Path used in findings, for example relative to the extension or the app. */ + path: string + content: string +} + +export interface OutboundFinding { + file: string + /** One-based line. */ + line: number + /** One-based column. */ + column: number + kind: OutboundSinkKind + status: OutboundFindingStatus + /** The sink, for example `