Skip to content

Commit d6dee5e

Browse files
committed
Validate bundle stays within output dir (#6277)
When a file would leave the output dir, an error is thrown. # Conflicts: # src/Bundle.ts # src/utils/logs.ts # src/utils/path.ts
1 parent c9bd03d commit d6dee5e

File tree

34 files changed

+521
-25
lines changed

34 files changed

+521
-25
lines changed

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
# rollup changelog
22

3+
## 2.80.0
4+
5+
_2026-02-22_
6+
7+
### Features
8+
9+
- Throw when the generated bundle contains paths that would leave the output directory (#6277)
10+
11+
### Pull Requests
12+
13+
- [#6277](https://github.com/rollup/rollup/pull/6277): Validate bundle stays within output dir (@lukastaegert)
14+
15+
## 2.79.2
16+
17+
_2024-09-26_
18+
19+
### Bug Fixes
20+
21+
- Resolve CVE-2024-43788 (#5677)
22+
23+
### Pull Requests
24+
25+
- [#5677](https://github.com/rollup/rollup/pull/5677): resolve DOM Clobbering CVE-2024-43788 (backport to v2) (@fabianszabo)
26+
327
## 2.79.1
428

529
_2022-09-22_

browser/path.ts

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const ABSOLUTE_PATH_REGEX = /^(?:\/|(?:[A-Za-z]:)?[\\|/])/;
1+
const ABSOLUTE_PATH_REGEX = /^(?:\/|(?:[A-Za-z]:)?[/\\|])/;
22
const RELATIVE_PATH_REGEX = /^\.?\.\//;
33
const ALL_BACKSLASHES_REGEX = /\\/g;
44
const ANY_SLASH_REGEX = /[/\\]/;
@@ -24,17 +24,42 @@ export function dirname(path: string): string {
2424
const match = /[/\\][^/\\]*$/.exec(path);
2525
if (!match) return '.';
2626

27-
const dir = path.slice(0, -match[0].length);
27+
const directory = path.slice(0, -match[0].length);
2828

29-
// If `dir` is the empty string, we're at root.
30-
return dir ? dir : '/';
29+
// If `directory` is the empty string, we're at root.
30+
return directory || '/';
3131
}
3232

3333
export function extname(path: string): string {
3434
const match = EXTNAME_REGEX.exec(basename(path)!);
3535
return match ? match[0] : '';
3636
}
3737

38+
export function join(...segments: string[]): string {
39+
const joined = segments.join('/');
40+
const absolute = ANY_SLASH_REGEX.test(joined[0]);
41+
return (
42+
(absolute ? '/' : '') +
43+
(normalizePathSegments(joined.split(ANY_SLASH_REGEX), absolute) || (absolute ? '' : '.'))
44+
);
45+
}
46+
47+
function normalizePathSegments(parts: string[], absolute = false): string {
48+
const normalized: string[] = [];
49+
for (const part of parts) {
50+
if (part === '..') {
51+
if (normalized.length > 0 && normalized[normalized.length - 1] !== '..') {
52+
normalized.pop();
53+
} else if (!absolute) {
54+
normalized.push('..');
55+
}
56+
} else if (part !== '.' && part !== '') {
57+
normalized.push(part);
58+
}
59+
}
60+
return normalized.join('/');
61+
}
62+
3863
export function relative(from: string, to: string): string {
3964
const fromParts = from.split(ANY_SLASH_REGEX).filter(Boolean);
4065
const toParts = to.split(ANY_SLASH_REGEX).filter(Boolean);
@@ -60,28 +85,19 @@ export function relative(from: string, to: string): string {
6085
}
6186

6287
export function resolve(...paths: string[]): string {
63-
const firstPathSegment = paths.shift();
64-
if (!firstPathSegment) {
65-
return '/';
66-
}
67-
let resolvedParts = firstPathSegment.split(ANY_SLASH_REGEX);
68-
88+
let parts: string[] = [];
89+
let isAbsoluteResult = false;
6990
for (const path of paths) {
7091
if (isAbsolute(path)) {
71-
resolvedParts = path.split(ANY_SLASH_REGEX);
92+
parts = path.split(ANY_SLASH_REGEX);
93+
isAbsoluteResult = true;
7294
} else {
73-
const parts = path.split(ANY_SLASH_REGEX);
74-
75-
while (parts[0] === '.' || parts[0] === '..') {
76-
const part = parts.shift();
77-
if (part === '..') {
78-
resolvedParts.pop();
79-
}
80-
}
81-
82-
resolvedParts.push(...parts);
95+
parts.push(...path.split(ANY_SLASH_REGEX));
8396
}
8497
}
85-
86-
return resolvedParts.join('/');
98+
const normalized = normalizePathSegments(parts, isAbsoluteResult);
99+
if (!isAbsoluteResult) return normalized || '/';
100+
// Windows absolute paths (e.g. "C:/path") must not get a leading "/" prepended.
101+
// Unix absolute paths must start with "/".
102+
return /^[A-Za-z]:/.test(normalized) ? normalized : '/' + normalized;
87103
}

src/Bundle.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import commondir from './utils/commondir';
1818
import {
1919
errCannotAssignModuleToChunk,
2020
errChunkInvalid,
21+
errFileNameOutsideOutputDirectory,
2122
errInvalidOption,
2223
error,
2324
warnDeprecation
@@ -29,7 +30,7 @@ import {
2930
getOutputBundle,
3031
OutputBundleWithPlaceholders
3132
} from './utils/outputBundle';
32-
import { basename, isAbsolute } from './utils/path';
33+
import { basename, isAbsolute, join } from './utils/path';
3334
import { timeEnd, timeStart } from './utils/timers';
3435

3536
export default class Bundle {
@@ -80,6 +81,7 @@ export default class Bundle {
8081
isWrite
8182
]);
8283
this.finaliseAssets(outputBundle);
84+
validateOutputBundleFileNames(outputBundle);
8385

8486
timeEnd('GENERATE', 1);
8587
return outputBundleBase;
@@ -335,3 +337,29 @@ function addModuleToManualChunk(
335337
}
336338
manualChunkAliasByEntry.set(module, alias);
337339
}
340+
341+
function isFileNameOutsideOutputDirectory(fileName: string): boolean {
342+
// Use join() to normalize ".." segments, then replace backslashes so the
343+
// string checks below work identically on Windows and POSIX.
344+
const normalized = join(fileName).replace(/\\/g, '/');
345+
return (
346+
normalized === '..' ||
347+
normalized.startsWith('../') ||
348+
normalized === '.' ||
349+
isAbsolute(normalized)
350+
);
351+
}
352+
353+
function validateOutputBundleFileNames(bundle: OutputBundleWithPlaceholders): void {
354+
for (const [bundleKey, entry] of Object.entries(bundle)) {
355+
if (isFileNameOutsideOutputDirectory(bundleKey)) {
356+
return error(errFileNameOutsideOutputDirectory(bundleKey));
357+
}
358+
if (entry.type !== 'placeholder') {
359+
const { fileName } = entry;
360+
if (fileName !== bundleKey && isFileNameOutsideOutputDirectory(fileName)) {
361+
return error(errFileNameOutsideOutputDirectory(fileName));
362+
}
363+
}
364+
}
365+
}

src/utils/error.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const enum Errors {
5252
DEPRECATED_FEATURE = 'DEPRECATED_FEATURE',
5353
EXTERNAL_SYNTHETIC_EXPORTS = 'EXTERNAL_SYNTHETIC_EXPORTS',
5454
FILE_NAME_CONFLICT = 'FILE_NAME_CONFLICT',
55+
FILE_NAME_OUTSIDE_OUTPUT_DIRECTORY = 'FILE_NAME_OUTSIDE_OUTPUT_DIRECTORY',
5556
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
5657
INPUT_HOOK_IN_OUTPUT_PLUGIN = 'INPUT_HOOK_IN_OUTPUT_PLUGIN',
5758
INVALID_CHUNK = 'INVALID_CHUNK',
@@ -190,6 +191,13 @@ export function errFileNameConflict(fileName: string): RollupLogProps {
190191
};
191192
}
192193

194+
export function errFileNameOutsideOutputDirectory(fileName: string): RollupLogProps {
195+
return {
196+
code: Errors.FILE_NAME_OUTSIDE_OUTPUT_DIRECTORY,
197+
message: `The output file name "${fileName}" is not contained in the output directory. Make sure all file names are relative paths without ".." segments.`
198+
};
199+
}
200+
193201
export function errInputHookInOutputPlugin(pluginName: string, hookName: string): RollupLogProps {
194202
return {
195203
code: Errors.INPUT_HOOK_IN_OUTPUT_PLUGIN,

src/utils/path.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@ export function normalize(path: string): string {
1515
return path.replace(BACKSLASH_REGEX, '/');
1616
}
1717

18-
export { basename, dirname, extname, relative, resolve } from 'path';
18+
export { basename, dirname, extname, join, relative, resolve } from 'path';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const { loader } = require('../../../utils.js');
2+
3+
module.exports = {
4+
description: 'throws when a plugin adds an absolute file name to the output bundle',
5+
options: {
6+
plugins: [
7+
loader({ main: 'export default 42;' }),
8+
{
9+
name: 'test',
10+
generateBundle(_options, bundle) {
11+
bundle['/etc/passwd'] = {
12+
type: 'asset',
13+
fileName: '/etc/passwd',
14+
name: undefined,
15+
needsCodeReference: false,
16+
names: [],
17+
originalFileNames: [],
18+
source: 'injected'
19+
};
20+
}
21+
}
22+
]
23+
},
24+
generateError: {
25+
code: 'FILE_NAME_OUTSIDE_OUTPUT_DIRECTORY',
26+
message:
27+
'The output file name "/etc/passwd" is not contained in the output directory. Make sure all file names are relative paths without ".." segments.'
28+
}
29+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const { loader } = require('../../../utils.js');
2+
3+
module.exports = {
4+
description: 'throws when a plugin adds a path traversal file name to the output bundle',
5+
options: {
6+
plugins: [
7+
loader({ main: 'export default 42;' }),
8+
{
9+
name: 'test',
10+
generateBundle(_options, bundle) {
11+
bundle['a/../../escaped.js'] = {
12+
type: 'asset',
13+
fileName: 'a/../../escaped.js',
14+
name: undefined,
15+
needsCodeReference: false,
16+
names: [],
17+
originalFileNames: [],
18+
source: 'injected'
19+
};
20+
}
21+
}
22+
]
23+
},
24+
generateError: {
25+
code: 'FILE_NAME_OUTSIDE_OUTPUT_DIRECTORY',
26+
message:
27+
'The output file name "a/../../escaped.js" is not contained in the output directory. Make sure all file names are relative paths without ".." segments.'
28+
}
29+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const { loader } = require('../../../utils.js');
2+
3+
module.exports = {
4+
description: 'throws when a plugin adds a Windows-style absolute file name to the output bundle',
5+
options: {
6+
plugins: [
7+
loader({ main: 'export default 42;' }),
8+
{
9+
name: 'test',
10+
generateBundle(_options, bundle) {
11+
bundle['C:\\etc\\passwd'] = {
12+
type: 'asset',
13+
fileName: 'C:\\etc\\passwd',
14+
name: undefined,
15+
needsCodeReference: false,
16+
names: [],
17+
originalFileNames: [],
18+
source: 'injected'
19+
};
20+
}
21+
}
22+
]
23+
},
24+
generateError: {
25+
code: 'FILE_NAME_OUTSIDE_OUTPUT_DIRECTORY',
26+
message:
27+
'The output file name "C:\\etc\\passwd" is not contained in the output directory. Make sure all file names are relative paths without ".." segments.'
28+
}
29+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
const { join, dirname } = require('node:path').posix;
2+
3+
module.exports = {
4+
description:
5+
'clamps at root when renormalizing external paths that traverse past the root via resolve',
6+
options: {
7+
input: '/main.js',
8+
external(id) {
9+
return id.endsWith('ext');
10+
},
11+
plugins: {
12+
name: 'test-plugin',
13+
resolveId(source, importer) {
14+
if (source.endsWith('ext.js')) {
15+
return false;
16+
}
17+
if (!importer) {
18+
return source;
19+
}
20+
return join(dirname(importer), source);
21+
},
22+
load(id) {
23+
switch (id) {
24+
case '/main.js': {
25+
// dep.js is at root; the external is imported via '../../escaped-ext.js'
26+
// from dep.js — two levels up from '/' — which should clamp to '/escaped-ext.js'
27+
return `import './dep.js';`;
28+
}
29+
case '/dep.js': {
30+
return `import '../../escaped-ext.js';`;
31+
}
32+
default: {
33+
throw new Error(`Unexpected id ${id}`);
34+
}
35+
}
36+
}
37+
}
38+
}
39+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './escaped-ext.js';

0 commit comments

Comments
 (0)