Skip to content

Commit ced0ad4

Browse files
authored
Merge pull request #7542 from Shopify/05-13-cli_bundle_guard_size_and_path
Guard CLI bundle upload against oversized and out-of-app paths
2 parents c1511f6 + 2acfce3 commit ced0ad4

14 files changed

Lines changed: 312 additions & 19 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': patch
3+
---
4+
5+
Guard app bundle uploads against oversized bundles and asset paths resolving outside the app directory

packages/app/src/cli/services/build/steps/include-assets-step.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('executeIncludeAssetsStep', () => {
2525
options: {
2626
stdout: mockStdout,
2727
stderr: mockStderr,
28-
app: {} as any,
28+
app: {directory: '/test'} as any,
2929
environment: 'production',
3030
},
3131
stepResults: new Map(),
@@ -552,6 +552,11 @@ describe('executeIncludeAssetsStep', () => {
552552
})
553553

554554
describe('pattern entries', () => {
555+
beforeEach(() => {
556+
// copyByPattern now short-circuits if sourceDir doesn't exist; default true here.
557+
vi.mocked(fs.fileExists).mockResolvedValue(true)
558+
})
559+
555560
test('copies files matching include patterns', async () => {
556561
// Given
557562
vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/logo.png', '/test/extension/public/style.css'])
@@ -943,11 +948,13 @@ describe('executeIncludeAssetsStep', () => {
943948
})
944949

945950
test('writes manifest.json with files array when generatesAssetsManifest is true and only pattern inclusions exist', async () => {
946-
// Given — pattern entries contribute output paths to the manifest "files" array
951+
// Given — pattern entries contribute output paths to the manifest "files" array.
952+
// sourceDir must exist for copyByPattern's pre-glob fileExists check to pass;
953+
// everything else can read false (the parent beforeEach default).
947954
vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/logo.png'])
948955
vi.mocked(fs.copyFile).mockResolvedValue()
949956
vi.mocked(fs.mkdir).mockResolvedValue()
950-
vi.mocked(fs.fileExists).mockResolvedValue(false)
957+
vi.mocked(fs.fileExists).mockImplementation(async (path) => String(path) === '/test/extension/public')
951958
vi.mocked(fs.writeFile).mockResolvedValue()
952959

953960
const step: LifecycleStep = {

packages/app/src/cli/services/build/steps/include-assets-step.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export async function executeIncludeAssetsStep(
125125
const config = IncludeAssetsConfigSchema.parse(step.config)
126126
const {extension, options} = context
127127
const outputDir = resolveOutputDir(extension.outputPath)
128+
const appDirectory = options.app.directory
128129

129130
const aggregatedPathMap = new Map<string, string | string[]>()
130131
// Track basenames written across all configKey entries in this build. In
@@ -147,6 +148,7 @@ export async function executeIncludeAssetsStep(
147148
baseDir: extension.directory,
148149
outputDir,
149150
context,
151+
appDirectory,
150152
destination: sanitizedDest,
151153
usedBasenames,
152154
preserveFilePaths: entry.preserveFilePaths,
@@ -172,6 +174,8 @@ export async function executeIncludeAssetsStep(
172174
outputDir: destinationDir,
173175
patterns: entry.include,
174176
ignore: entry.ignore ?? [],
177+
appDirectory,
178+
sourceDirConfigValue: entry.baseDir ?? '.',
175179
},
176180
options,
177181
)
@@ -189,6 +193,7 @@ export async function executeIncludeAssetsStep(
189193
destination: sanitizedDest,
190194
baseDir: extension.directory,
191195
outputDir,
196+
appDirectory,
192197
},
193198
options,
194199
)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {assertPathWithinAppDir} from './assert-path-within-app.js'
2+
import {AbortError} from '@shopify/cli-kit/node/error'
3+
import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs'
4+
import {joinPath} from '@shopify/cli-kit/node/path'
5+
import {describe, expect, test} from 'vitest'
6+
import {symlink} from 'fs/promises'
7+
8+
describe('assertPathWithinAppDir', () => {
9+
test('allows a path inside the app directory', async () => {
10+
await inTemporaryDirectory(async (appDir) => {
11+
const inside = joinPath(appDir, 'extensions', 'ext-a')
12+
await mkdir(inside)
13+
await writeFile(joinPath(inside, 'icon.png'), 'x')
14+
await expect(
15+
assertPathWithinAppDir(joinPath(inside, 'icon.png'), appDir, 'extensions/ext-a/icon.png'),
16+
).resolves.toBeUndefined()
17+
})
18+
})
19+
20+
test('allows the app directory itself', async () => {
21+
await inTemporaryDirectory(async (appDir) => {
22+
await expect(assertPathWithinAppDir(appDir, appDir, '.')).resolves.toBeUndefined()
23+
})
24+
})
25+
26+
test('rejects a path that resolves outside the app directory via ..', async () => {
27+
await inTemporaryDirectory(async (parent) => {
28+
const appDir = joinPath(parent, 'app')
29+
await mkdir(appDir)
30+
const outside = joinPath(parent, 'outside.json')
31+
await writeFile(outside, '{}')
32+
await expect(assertPathWithinAppDir(outside, appDir, '../outside.json')).rejects.toThrow(AbortError)
33+
await expect(assertPathWithinAppDir(outside, appDir, '../outside.json')).rejects.toThrow(
34+
/resolves outside the app directory/,
35+
)
36+
})
37+
})
38+
39+
test('rejects a symlink whose target is outside the app directory', async () => {
40+
await inTemporaryDirectory(async (parent) => {
41+
const appDir = joinPath(parent, 'app')
42+
await mkdir(appDir)
43+
const outsideDir = joinPath(parent, 'home', 'big-folder')
44+
await mkdir(outsideDir)
45+
await writeFile(joinPath(outsideDir, 'huge.bin'), 'x')
46+
47+
// Inside the app dir, but the symlink points outside.
48+
const symlinkInApp = joinPath(appDir, 'assets')
49+
await symlink(outsideDir, symlinkInApp)
50+
51+
await expect(assertPathWithinAppDir(symlinkInApp, appDir, 'assets')).rejects.toThrow(AbortError)
52+
})
53+
})
54+
55+
test('allows an in-tree symlink (e.g. pnpm-style links staying inside the app)', async () => {
56+
await inTemporaryDirectory(async (appDir) => {
57+
const realTarget = joinPath(appDir, 'shared')
58+
await mkdir(realTarget)
59+
const linkPath = joinPath(appDir, 'extensions', 'ext-a-assets')
60+
await mkdir(joinPath(appDir, 'extensions'))
61+
await symlink(realTarget, linkPath)
62+
63+
await expect(assertPathWithinAppDir(linkPath, appDir, 'extensions/ext-a-assets')).resolves.toBeUndefined()
64+
})
65+
})
66+
67+
test('does not false-positive on macOS-style symlinked temp dirs (both sides realpath’d)', async () => {
68+
// inTemporaryDirectory on macOS returns a /var/folders/... path whose
69+
// realpath is /private/var/folders/.... If only the source were realpath’d
70+
// the check would treat the temp dir as outside itself.
71+
await inTemporaryDirectory(async (appDir) => {
72+
const inside = joinPath(appDir, 'src')
73+
await mkdir(inside)
74+
await writeFile(joinPath(inside, 'schema.json'), '{}')
75+
await expect(
76+
assertPathWithinAppDir(joinPath(inside, 'schema.json'), appDir, 'src/schema.json'),
77+
).resolves.toBeUndefined()
78+
})
79+
})
80+
81+
test('allows a sibling whose name starts with two dots (e.g. ..cache)', async () => {
82+
await inTemporaryDirectory(async (appDir) => {
83+
const dotdotDir = joinPath(appDir, '..cache')
84+
await mkdir(dotdotDir)
85+
await writeFile(joinPath(dotdotDir, 'file.txt'), 'x')
86+
await expect(
87+
assertPathWithinAppDir(joinPath(dotdotDir, 'file.txt'), appDir, '..cache/file.txt'),
88+
).resolves.toBeUndefined()
89+
})
90+
})
91+
92+
test('includes the original config value in the error for debuggability', async () => {
93+
await inTemporaryDirectory(async (parent) => {
94+
const appDir = joinPath(parent, 'app')
95+
await mkdir(appDir)
96+
const outside = joinPath(parent, 'leak')
97+
await writeFile(outside, '')
98+
await expect(assertPathWithinAppDir(outside, appDir, '~/anywhere')).rejects.toThrow(/Asset path '~\/anywhere'/)
99+
})
100+
})
101+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {relativePath, isAbsolutePath} from '@shopify/cli-kit/node/path'
2+
import {fileRealPath} from '@shopify/cli-kit/node/fs'
3+
import {AbortError} from '@shopify/cli-kit/node/error'
4+
5+
/**
6+
* Throws if `resolvedPath` is not inside `appDirectory` after symlink resolution.
7+
*
8+
* Guards against accidental misuse where an extension config points an asset
9+
* source outside the app folder — either via `..`/absolute paths or by
10+
* symlinking an in-app directory to somewhere else (e.g. a home directory).
11+
*
12+
* Both sides are realpath'd before comparison so macOS temp paths
13+
* (`/var/folders` → `/private/var/folders`) and pnpm-style in-tree symlinks
14+
* don't trip a false positive.
15+
*
16+
* `configValue` is the raw value from configuration used in the error message
17+
* so the user can locate the offending entry. Caller must ensure `resolvedPath`
18+
* exists on disk — `fileRealPath` throws on missing paths.
19+
*/
20+
export async function assertPathWithinAppDir(
21+
resolvedPath: string,
22+
appDirectory: string,
23+
configValue: string,
24+
): Promise<void> {
25+
const [realSource, realAppDir] = await Promise.all([fileRealPath(resolvedPath), fileRealPath(appDirectory)])
26+
const relative = relativePath(realAppDir, realSource)
27+
// Check `..` as a path segment, not just a prefix. A sibling-style name like
28+
// `..cache` is a valid in-app entry whose relative path begins with `..` but
29+
// does not escape.
30+
const firstSegment = relative.split(/[/\\]/, 1)[0]
31+
if (firstSegment === '..' || isAbsolutePath(relative)) {
32+
throw new AbortError(
33+
`Asset path '${configValue}' resolves outside the app directory.`,
34+
`Asset sources must be inside the app folder. Resolved to: ${realSource}`,
35+
)
36+
}
37+
}

packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ describe('copyByPattern', () => {
99

1010
beforeEach(() => {
1111
mockStdout = {write: vi.fn()}
12+
vi.mocked(fs.fileExists).mockResolvedValue(true)
1213
})
1314

1415
test('copies matched files preserving relative paths', async () => {
@@ -24,6 +25,8 @@ describe('copyByPattern', () => {
2425
outputDir: '/out',
2526
patterns: ['**/*.ts', '**/*.tsx'],
2627
ignore: [],
28+
appDirectory: '/src',
29+
sourceDirConfigValue: '.',
2730
},
2831
{stdout: mockStdout},
2932
)
@@ -47,6 +50,8 @@ describe('copyByPattern', () => {
4750
outputDir: '/out',
4851
patterns: ['**/*.png'],
4952
ignore: [],
53+
appDirectory: '/src',
54+
sourceDirConfigValue: '.',
5055
},
5156
{stdout: mockStdout},
5257
)
@@ -72,6 +77,8 @@ describe('copyByPattern', () => {
7277
outputDir: '/out/sub',
7378
patterns: ['**/*'],
7479
ignore: [],
80+
appDirectory: '/out',
81+
sourceDirConfigValue: 'sub',
7582
},
7683
{stdout: mockStdout},
7784
)
@@ -96,6 +103,8 @@ describe('copyByPattern', () => {
96103
outputDir: '/out',
97104
patterns: ['*.png'],
98105
ignore: [],
106+
appDirectory: '/out',
107+
sourceDirConfigValue: '.',
99108
},
100109
{stdout: mockStdout},
101110
)
@@ -120,6 +129,8 @@ describe('copyByPattern', () => {
120129
outputDir: '/out/dist',
121130
patterns: ['*.js'],
122131
ignore: [],
132+
appDirectory: '/src',
133+
sourceDirConfigValue: '.',
123134
},
124135
{stdout: mockStdout},
125136
)
@@ -139,6 +150,8 @@ describe('copyByPattern', () => {
139150
outputDir: '/out',
140151
patterns: ['**/*'],
141152
ignore: ['**/*.test.ts', 'node_modules/**'],
153+
appDirectory: '/src',
154+
sourceDirConfigValue: '.',
142155
},
143156
{stdout: mockStdout},
144157
)

packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import {assertPathWithinAppDir} from './assert-path-within-app.js'
12
import {joinPath, dirname, relativePath} from '@shopify/cli-kit/node/path'
2-
import {glob, copyFile, mkdir} from '@shopify/cli-kit/node/fs'
3+
import {glob, copyFile, mkdir, fileExists} from '@shopify/cli-kit/node/fs'
34

45
/**
56
* Pattern strategy: glob-based file selection.
@@ -10,10 +11,21 @@ export async function copyByPattern(
1011
outputDir: string
1112
patterns: string[]
1213
ignore: string[]
14+
appDirectory: string
15+
sourceDirConfigValue: string
1316
},
1417
options: {stdout: NodeJS.WritableStream},
1518
): Promise<{filesCopied: number; outputPaths: string[]}> {
16-
const {sourceDir, outputDir, patterns, ignore} = config
19+
const {sourceDir, outputDir, patterns, ignore, appDirectory, sourceDirConfigValue} = config
20+
21+
// Validate the boundary up front, before touching the filesystem. Realpath
22+
// would throw on a missing sourceDir, so preserve the existing "missing dir
23+
// = no files" behavior by short-circuiting first.
24+
if (!(await fileExists(sourceDir))) {
25+
return {filesCopied: 0, outputPaths: []}
26+
}
27+
await assertPathWithinAppDir(sourceDir, appDirectory, sourceDirConfigValue)
28+
1729
const files = await glob(patterns, {
1830
absolute: true,
1931
cwd: sourceDir,

0 commit comments

Comments
 (0)