Skip to content

Commit 5a9a740

Browse files
committed
Resolve symlinks in asset path guard so symlinked-out dirs are caught
1 parent 926db8b commit 5a9a740

5 files changed

Lines changed: 98 additions & 26 deletions

File tree

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,90 @@
11
import {assertPathWithinAppDir} from './assert-path-within-app.js'
22
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'
35
import {describe, expect, test} from 'vitest'
6+
import {symlink} from 'fs/promises'
47

58
describe('assertPathWithinAppDir', () => {
6-
test('allows a path inside the app directory', () => {
7-
expect(() =>
8-
assertPathWithinAppDir('/app/extensions/ext-a/icon.png', '/app', 'extensions/ext-a/icon.png'),
9-
).not.toThrow()
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+
})
1018
})
1119

12-
test('allows the app directory itself', () => {
13-
expect(() => assertPathWithinAppDir('/app', '/app', '.')).not.toThrow()
20+
test('allows the app directory itself', async () => {
21+
await inTemporaryDirectory(async (appDir) => {
22+
await expect(assertPathWithinAppDir(appDir, appDir, '.')).resolves.toBeUndefined()
23+
})
1424
})
1525

16-
test('rejects a relative path that escapes the app directory via ..', () => {
17-
expect(() => assertPathWithinAppDir('/other/secret.env', '/app', '../other/secret.env')).toThrow(AbortError)
18-
expect(() => assertPathWithinAppDir('/other/secret.env', '/app', '../other/secret.env')).toThrow(
19-
/resolves outside the app directory/,
20-
)
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+
})
2137
})
2238

23-
test('rejects an absolute path that points outside the app directory', () => {
24-
expect(() => assertPathWithinAppDir('/Users/me', '/app', '/Users/me')).toThrow(AbortError)
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+
})
2579
})
2680

27-
test('includes the original config value in the error message for debuggability', () => {
28-
expect(() => assertPathWithinAppDir('/other', '/app', '~/anywhere')).toThrow(/Asset path '~\/anywhere'/)
81+
test('includes the original config value in the error for debuggability', async () => {
82+
await inTemporaryDirectory(async (parent) => {
83+
const appDir = joinPath(parent, 'app')
84+
await mkdir(appDir)
85+
const outside = joinPath(parent, 'leak')
86+
await writeFile(outside, '')
87+
await expect(assertPathWithinAppDir(outside, appDir, '~/anywhere')).rejects.toThrow(/Asset path '~\/anywhere'/)
88+
})
2989
})
3090
})
Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,33 @@
11
import {relativePath, isAbsolutePath} from '@shopify/cli-kit/node/path'
2+
import {fileRealPath} from '@shopify/cli-kit/node/fs'
23
import {AbortError} from '@shopify/cli-kit/node/error'
34

45
/**
5-
* Throws if `resolvedPath` is not inside `appDirectory`.
6+
* Throws if `resolvedPath` is not inside `appDirectory` after symlink resolution.
67
*
78
* Guards against accidental misuse where an extension config points an asset
8-
* source outside the app folder (e.g. `source = "../../"` or an absolute home
9-
* directory path). Adversarial bypass via symlinks is out of scope — server-side
10-
* size enforcement is the real boundary; this check is a fast-fail for DX.
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.
1115
*
1216
* `configValue` is the raw value from configuration used in the error message
13-
* so the user can locate the offending entry.
17+
* so the user can locate the offending entry. Caller must ensure `resolvedPath`
18+
* exists on disk — `fileRealPath` throws on missing paths.
1419
*/
15-
export function assertPathWithinAppDir(resolvedPath: string, appDirectory: string, configValue: string): void {
16-
const relative = relativePath(appDirectory, resolvedPath)
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)
1727
if (relative.startsWith('..') || isAbsolutePath(relative)) {
1828
throw new AbortError(
1929
`Asset path '${configValue}' resolves outside the app directory.`,
20-
`Asset sources must be inside the app folder. Resolved to: ${resolvedPath}`,
30+
`Asset sources must be inside the app folder. Resolved to: ${realSource}`,
2131
)
2232
}
2333
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export async function copyByPattern(
1717
options: {stdout: NodeJS.WritableStream},
1818
): Promise<{filesCopied: number; outputPaths: string[]}> {
1919
const {sourceDir, outputDir, patterns, ignore, appDirectory, sourceDirConfigValue} = config
20-
assertPathWithinAppDir(sourceDir, appDirectory, sourceDirConfigValue)
2120
const files = await glob(patterns, {
2221
absolute: true,
2322
cwd: sourceDir,
@@ -27,6 +26,9 @@ export async function copyByPattern(
2726
if (files.length === 0) {
2827
return {filesCopied: 0, outputPaths: []}
2928
}
29+
// Realpath on sourceDir would throw on a missing dir; we only get here when
30+
// glob found files, so sourceDir exists.
31+
await assertPathWithinAppDir(sourceDir, appDirectory, sourceDirConfigValue)
3032

3133
await mkdir(outputDir)
3234

packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,14 @@ export async function copyConfigKeyEntry(config: {
6969
/* eslint-disable no-await-in-loop */
7070
for (const sourcePath of uniquePaths) {
7171
const fullPath = joinPath(baseDir, sourcePath)
72-
assertPathWithinAppDir(fullPath, appDirectory, sourcePath)
7372
const exists = await fileExists(fullPath)
7473
if (!exists) {
7574
throw new Error(
7675
outputContent`Couldn't find ${outputToken.path(fullPath)}\n Please check the path '${sourcePath}' in your configuration`
7776
.value,
7877
)
7978
}
79+
await assertPathWithinAppDir(fullPath, appDirectory, sourcePath)
8080

8181
const sourceIsDir = await isDirectory(fullPath)
8282

packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ export async function copySourceEntry(
2020
): Promise<{filesCopied: number; outputPaths: string[]}> {
2121
const {source, destination, baseDir, outputDir, appDirectory} = config
2222
const sourcePath = joinPath(baseDir, source)
23-
assertPathWithinAppDir(sourcePath, appDirectory, source)
2423
if (!(await fileExists(sourcePath))) {
2524
throw new Error(`Source does not exist: ${sourcePath}`)
2625
}
26+
await assertPathWithinAppDir(sourcePath, appDirectory, source)
2727

2828
const sourceIsDir = await isDirectory(sourcePath)
2929

0 commit comments

Comments
 (0)