Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions src/generator/template-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmdirSync,
rmSync,
Expand Down Expand Up @@ -124,13 +125,68 @@ function copyTemplateTree(
}
}

function assertDestinationContainment(root: string, destination: string): void {
const destinationParent = dirname(destination);

if (!isWithin(destinationParent, destination)) {
throw new ValidationCliError(
'Project destination escapes its validated parent directory.',
);
}

if (existsSync(root)) {
try {
const realRoot = realpathSync(root);
let existing = destination;
while (!existsSync(existing)) {
const parent = dirname(existing);
if (parent === existing) break;
existing = parent;
}

if (existsSync(existing)) {
const realExisting = realpathSync(existing);
const rel = relative(realRoot, realExisting);
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new ValidationCliError(
'Project destination escapes its validated parent directory.',
);
}
}
} catch (error) {
if (error instanceof ValidationCliError) throw error;
}
}

let current = destination;
while (current !== dirname(current)) {
const parent = dirname(current);
if (existsSync(current) && existsSync(parent)) {
try {
const realCurrent = realpathSync(current);
const realParent = realpathSync(parent);
const rel = relative(realParent, realCurrent);
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new ValidationCliError(
'Project destination escapes its validated parent directory.',
);
}
} catch (error) {
if (error instanceof ValidationCliError) throw error;
}
}
current = parent;
}
}

export function generateProjectFromTemplate(
context: GenerationContext,
options: TemplateGenerationOptions,
): ProjectGenerationResult {
const templateRoot = resolve(options.templateRoot);
const destination = resolve(context.destination.destination);
const destinationParent = dirname(destination);
const root = resolve(context.input.cwd);

if (!existsSync(templateRoot) || !lstatSync(templateRoot).isDirectory()) {
throw new ValidationCliError('Selected template source is unavailable.');
Expand All @@ -142,11 +198,7 @@ export function generateProjectFromTemplate(
);
}

if (!isWithin(destinationParent, destination)) {
throw new ValidationCliError(
'Project destination escapes its validated parent directory.',
);
}
assertDestinationContainment(root, destination);

const destinationExisted = existsSync(destination);

Expand Down
57 changes: 55 additions & 2 deletions src/generator/validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, readdirSync, statSync } from 'node:fs';
import { isAbsolute, relative, resolve } from 'node:path';
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { ValidationCliError } from '../errors/errors.js';
import type {
DestinationPlan,
Expand All @@ -14,6 +14,7 @@ export interface DestinationFileSystem {
exists(path: string): boolean;
isDirectory(path: string): boolean;
entries(path: string): readonly string[];
realpath?(path: string): string;
}

const nodeFileSystem: DestinationFileSystem = {
Expand All @@ -22,6 +23,7 @@ const nodeFileSystem: DestinationFileSystem = {
return statSync(path).isDirectory();
},
entries: readdirSync,
realpath: realpathSync,
};

function validateProjectName(projectName: string): string {
Expand Down Expand Up @@ -98,6 +100,56 @@ function assertDestinationWithinRoot(root: string, destination: string): void {
}
}

function assertSymlinkAncestorContainment(
root: string,
destination: string,
fileSystem: DestinationFileSystem,
): void {
if (!fileSystem.realpath || !fileSystem.exists(root)) {
return;
}

try {
const realRoot = fileSystem.realpath(root);
let existing = destination;
while (!fileSystem.exists(existing)) {
const parent = dirname(existing);
if (parent === existing) break;
existing = parent;
}

if (fileSystem.exists(existing)) {
const realExisting = fileSystem.realpath(existing);
const rel = relative(realRoot, realExisting);
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new ValidationCliError(
'Project destination must remain inside the current working directory.',
);
}
}

let current = destination;
while (current !== dirname(current)) {
const parent = dirname(current);
if (fileSystem.exists(current) && fileSystem.exists(parent)) {
const realCurrent = fileSystem.realpath(current);
const realParent = fileSystem.realpath(parent);
const rel = relative(realParent, realCurrent);
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new ValidationCliError(
'Project destination must remain inside the current working directory.',
);
}
}
current = parent;
}
} catch (error) {
if (error instanceof ValidationCliError) {
throw error;
}
}
}

export function planProjectDestination(
input: ValidatedProjectInput,
fileSystem: DestinationFileSystem = nodeFileSystem,
Expand All @@ -106,6 +158,7 @@ export function planProjectDestination(
const destination = resolve(root, input.projectName);

assertDestinationWithinRoot(root, destination);
assertSymlinkAncestorContainment(root, destination, fileSystem);

if (fileSystem.exists(destination)) {
if (!fileSystem.isDirectory(destination)) {
Expand Down
Loading