From 92326e8bc2bebe7b1e0bf88c1849789702772742 Mon Sep 17 00:00:00 2001
From: Josh White
Date: Fri, 8 May 2026 18:34:11 -0400
Subject: [PATCH] Validate extensions.targeting assets value in
include-assets-step
---
.../build/steps/include-assets-step.ts | 1 +
.../copy-config-key-entry.test.ts | 128 ++++++++++++++++++
.../include-assets/copy-config-key-entry.ts | 18 ++-
3 files changed, 146 insertions(+), 1 deletion(-)
diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.ts b/packages/app/src/cli/services/build/steps/include-assets-step.ts
index 1aba68d5d5d..e6eabe6c87f 100644
--- a/packages/app/src/cli/services/build/steps/include-assets-step.ts
+++ b/packages/app/src/cli/services/build/steps/include-assets-step.ts
@@ -138,6 +138,7 @@ export async function executeIncludeAssetsStep(
let configKeyCount = 0
for (const entry of config.inclusions) {
if (entry.type !== 'configKey') continue
+
const warn = (msg: string) => options.stdout.write(msg)
const rawDest = entry.destination ? sanitizeRelativePath(entry.destination, warn) : undefined
const sanitizedDest = rawDest === '' ? undefined : rawDest
diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts
index 70a12187d21..2e59715f36e 100644
--- a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts
+++ b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts
@@ -316,4 +316,132 @@ describe('copyConfigKeyEntry', () => {
await expect(fileExists(joinPath(outDir, 'tools.json'))).resolves.toBe(true)
})
})
+
+ describe('value guard', () => {
+ test('throws when value is an empty string', async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+ const context = makeContext({assets: ''})
+ await expect(copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context})).rejects.toThrow(
+ `'assets' can't be empty.`,
+ )
+ })
+ })
+
+ test('throws when value is whitespace-only', async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+ const context = makeContext({assets: ' '})
+ await expect(copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context})).rejects.toThrow(
+ `'assets' can't be empty.`,
+ )
+ })
+ })
+
+ test(`throws when value is '.'`, async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+ const context = makeContext({assets: '.'})
+ await expect(copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context})).rejects.toThrow(
+ `'assets' is not a valid path: '.'`,
+ )
+ })
+ })
+
+ test(`throws when value is './'`, async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+ const context = makeContext({assets: './'})
+ await expect(copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context})).rejects.toThrow(
+ `'assets' is not a valid path: './'`,
+ )
+ })
+ })
+
+ test(`throws when value is './' with surrounding whitespace`, async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+ const context = makeContext({assets: ' ./ '})
+ await expect(copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context})).rejects.toThrow(
+ `'assets' is not a valid path: ' ./ '`,
+ )
+ })
+ })
+
+ test(`throws when one path in an array is invalid`, async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+ const context = makeContext({roots: ['./public', '.']})
+ await expect(copyConfigKeyEntry({key: 'roots', baseDir: tmpDir, outputDir: outDir, context})).rejects.toThrow(
+ `'roots' is not a valid path: '.'`,
+ )
+ })
+ })
+
+ test(`throws when value is '../foo' (escapes via single parent)`, async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+ const context = makeContext({assets: '../foo'})
+ await expect(copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context})).rejects.toThrow(
+ `'assets' is not a valid path: '../foo'`,
+ )
+ })
+ })
+
+ test(`throws when value is '../../../foo' (escapes via deep parent chain)`, async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+ const context = makeContext({assets: '../../../foo'})
+ await expect(copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context})).rejects.toThrow(
+ `'assets' is not a valid path: '../../../foo'`,
+ )
+ })
+ })
+
+ test(`throws when value escapes via interior '..' segments`, async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+ const context = makeContext({assets: 'public/../../bad'})
+ await expect(copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context})).rejects.toThrow(
+ `'assets' is not a valid path: 'public/../../bad'`,
+ )
+ })
+ })
+
+ test(`does not throw when interior '..' resolves back inside`, async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const pubDir = joinPath(tmpDir, 'public')
+ await mkdir(pubDir)
+ await writeFile(joinPath(pubDir, 'index.html'), '')
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+
+ const context = makeContext({assets: 'public/../public'})
+ const result = await copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context})
+
+ expect(result.filesCopied).toBe(1)
+ await expect(fileExists(joinPath(outDir, 'index.html'))).resolves.toBe(true)
+ })
+ })
+
+ test(`uses leaf segment of dotted configKey for the error message`, async () => {
+ await inTemporaryDirectory(async (tmpDir) => {
+ const outDir = joinPath(tmpDir, 'out')
+ await mkdir(outDir)
+ const context = makeContext({extension_points: [{assets: ''}]})
+ await expect(
+ copyConfigKeyEntry({key: 'extension_points[].assets', baseDir: tmpDir, outputDir: outDir, context}),
+ ).rejects.toThrow(`'assets' can't be empty.`)
+ })
+ })
+ })
})
diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts
index edb9b9e6b38..e7be8fd2996 100644
--- a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts
+++ b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts
@@ -1,4 +1,4 @@
-import {joinPath, basename, relativePath, extname} from '@shopify/cli-kit/node/path'
+import {joinPath, basename, relativePath, extname, resolvePath, isSubpath} from '@shopify/cli-kit/node/path'
import {glob, copyFile, copyDirectoryContents, fileExists, mkdir, isDirectory} from '@shopify/cli-kit/node/fs'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {AbortError} from '@shopify/cli-kit/node/error'
@@ -59,6 +59,22 @@ export async function copyConfigKeyEntry(config: {
// should only be copied once; the pathMap entry is reused for all references.
const uniquePaths = [...new Set(paths)]
+ // Validate up-front so a bad value fails fast before any copying happens.
+ // Use the leaf segment of `key` as the user-facing field name (e.g.
+ // `extension_points[].assets` → `assets`) so error messages match the
+ // developer's TOML rather than internal config paths.
+ const fieldName = key.split('.').pop()?.replace(/\[\]$/, '') ?? key
+ for (const sourcePath of uniquePaths) {
+ const trimmed = sourcePath.trim()
+ if (trimmed === '') {
+ throw new Error(`'${fieldName}' can't be empty.`)
+ }
+ const resolved = resolvePath(baseDir, trimmed)
+ if (resolved === baseDir || !isSubpath(baseDir, resolved)) {
+ throw new Error(`'${fieldName}' is not a valid path: '${sourcePath}'`)
+ }
+ }
+
// Process sequentially to avoid filesystem race conditions on shared output paths.
const pathMap = new Map()
let filesCopied = 0