Skip to content

Commit be957c2

Browse files
Guard include_assets step against path traversal in destinations and pattern copy
- Add sanitizeDestination() that strips '..' segments from destination fields and emits a warning when any are removed - Sanitize entry.destination for all three inclusion types (pattern, static, configKey) before it reaches any path join - Add copy-time bounds check in copyByPattern: skip any file whose resolved destPath escapes outputDir and warn Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 98eb79b commit be957c2

File tree

1 file changed

+39
-3
lines changed

1 file changed

+39
-3
lines changed

packages/app/src/cli/services/build/steps/include_assets_step.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,31 @@ const IncludeAssetsConfigSchema = z.object({
6262
inclusions: z.array(InclusionEntrySchema),
6363
})
6464

65+
/**
66+
* Removes any '..' traversal segments from a relative destination path and
67+
* emits a warning if any were found. Preserves normal '..' that only navigate
68+
* within the path (e.g. 'foo/../bar' → 'bar') but never allows the result to
69+
* escape the output root.
70+
*/
71+
function sanitizeDestination(input: string, warn: (msg: string) => void): string {
72+
const segments = input.split('/')
73+
const stack: string[] = []
74+
let stripped = false
75+
for (const seg of segments) {
76+
if (seg === '..') {
77+
stripped = true
78+
stack.pop()
79+
} else if (seg !== '.') {
80+
stack.push(seg)
81+
}
82+
}
83+
const result = stack.join('/')
84+
if (stripped) {
85+
warn(`Warning: destination '${input}' contains '..' path traversal - sanitized to '${result || '.'}'\n`)
86+
}
87+
return result
88+
}
89+
6590
/**
6691
* Executes an include_assets build step.
6792
*
@@ -85,9 +110,13 @@ export async function executeIncludeAssetsStep(
85110

86111
const counts = await Promise.all(
87112
config.inclusions.map(async (entry) => {
113+
const warn = (msg: string) => options.stdout.write(msg)
114+
const sanitizedDest =
115+
entry.destination !== undefined ? sanitizeDestination(entry.destination, warn) : undefined
116+
88117
if (entry.type === 'pattern') {
89118
const sourceDir = entry.baseDir ? joinPath(extension.directory, entry.baseDir) : extension.directory
90-
const destinationDir = entry.destination ? joinPath(outputDir, entry.destination) : outputDir
119+
const destinationDir = sanitizedDest ? joinPath(outputDir, sanitizedDest) : outputDir
91120
const result = await copyByPattern(
92121
sourceDir,
93122
destinationDir,
@@ -107,13 +136,13 @@ export async function executeIncludeAssetsStep(
107136
context,
108137
options,
109138
entry.preserveStructure,
110-
entry.destination,
139+
sanitizedDest,
111140
)
112141
}
113142

114143
return copySourceEntry(
115144
entry.source,
116-
entry.destination,
145+
sanitizedDest,
117146
extension.directory,
118147
outputDir,
119148
options,
@@ -248,6 +277,13 @@ async function copyByPattern(
248277
const relPath = preserveStructure ? relativePath(sourceDir, filepath) : basename(filepath)
249278
const destPath = joinPath(outputDir, relPath)
250279

280+
if (relativePath(outputDir, destPath).startsWith('..')) {
281+
options.stdout.write(
282+
`Warning: skipping '${filepath}' - resolved destination is outside the output directory\n`,
283+
)
284+
return
285+
}
286+
251287
if (filepath === destPath) return
252288

253289
await mkdir(dirname(destPath))

0 commit comments

Comments
 (0)