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 ccb61f86080..222f15e52f5 100644
--- a/packages/app/src/cli/models/extensions/specifications/theme.ts
+++ b/packages/app/src/cli/models/extensions/specifications/theme.ts
@@ -108,6 +108,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.directory)
},
})
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