Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/repopo-path-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"repopo": patch
---

Harden file path handling to prevent policy reads/writes outside the repository root.

This adds centralized safe path resolution for repo-relative file access, validates incoming file paths from `check --stdin`, and applies root-bound resolution in package and file-header policy definers.

Also adds regression tests covering traversal/path-escape rejection to prevent future regressions.
82 changes: 75 additions & 7 deletions packages/repopo/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
type PolicyFailure,
} from "../policy.js";
import { type PolicyFileResult, PolicyRunner } from "../runner.js";
import { normalizeRepoRelativeFilePath } from "../utils/safePaths.js";

const trailingCarriageReturnRegex = /\r$/;

async function readStdin(): Promise<string> {
return new Promise((resolve) => {
Expand Down Expand Up @@ -107,27 +110,92 @@
/**
* Collects file paths to check from either stdin or git ls-files.
*/
private async collectFilePaths(): Promise<string[]> {

Check failure on line 113 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value.

Check failure on line 113 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value.
if (this.flags.stdin) {
const stdInput = await run(function* () {
return yield* call(() => readStdin());
});

if (stdInput !== undefined && stdInput !== null) {
return stdInput
.replace(
// normalize slashes in case they're windows paths
/\\/g,
"/",
)
.split("\n");
rawFilePathsToCheck.push(...this.splitInputPaths(stdInput));

Check failure on line 120 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'rawFilePathsToCheck'. Did you mean 'filePathsToCheck'?

Check failure on line 120 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'rawFilePathsToCheck'. Did you mean 'filePathsToCheck'?
}
} else {
const gitFiles =
(await this.git.raw(
"ls-files",
// include staged files and untracked files
"-co",
// exclude gitignored files and other standard ignore rules
"--exclude-standard",
// Outputs paths relative to the root of the repository, regardless of the current working directory.
"--full-name",
)) ?? "";

rawFilePathsToCheck.push(...this.splitInputPaths(gitFiles));

Check failure on line 134 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'rawFilePathsToCheck'. Did you mean 'filePathsToCheck'?

Check failure on line 134 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'rawFilePathsToCheck'. Did you mean 'filePathsToCheck'?
}

const filePathsToCheck = this.normalizePathsToCheck(
rawFilePathsToCheck,

Check failure on line 138 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'rawFilePathsToCheck'. Did you mean 'filePathsToCheck'?

Check failure on line 138 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'rawFilePathsToCheck'. Did you mean 'filePathsToCheck'?
context.gitRoot,

Check failure on line 139 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'context'.

Check failure on line 139 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'context'.
);

await run(() => this.checkAllFiles(filePathsToCheck, context));

Check failure on line 142 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'context'.

Check failure on line 142 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'context'.
}

private splitInputPaths(input: string): string[] {
return input
.replace(
// normalize slashes in case they're windows paths
/\\/g,
"/",
)
.split("\n")
.map((path) => path.replace(trailingCarriageReturnRegex, ""));
}

private normalizePathsToCheck(paths: string[], gitRoot: string): string[] {
const normalizedPaths: string[] = [];
for (const path of paths) {
if (path.length === 0) {
continue;
}

try {
normalizedPaths.push(normalizeRepoRelativeFilePath(gitRoot, path));
} catch (error: unknown) {
throw new Error(
`Invalid file path '${path}': ${(error as Error).message}`,
);
}
}

return normalizedPaths;
}

/**
* Executes all policies against the provided paths.
*
* @param pathsToCheck - All paths that should be checked. Paths should be relative to the repository root.
* @param context - The context.
*/
private *checkAllFiles(
pathsToCheck: string[],
context: RepopoCommandContext,
): Operation<void> {

Check failure on line 184 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'Operation'.

Check failure on line 184 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Cannot find name 'Operation'.
try {
for (const pathToCheck of pathsToCheck) {
yield* this.checkOrExcludeFile(pathToCheck, context);

Check failure on line 187 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Property 'checkOrExcludeFile' does not exist on type 'CheckPolicy<T>'.

Check failure on line 187 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Property 'checkOrExcludeFile' does not exist on type 'CheckPolicy<T>'.
}
} finally {
if (!this.flags.quiet) {
logStats(context.perfStats, this.logger);
}

return [];
}

const gitFiles =

Check failure on line 197 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Unreachable code detected.

Check failure on line 197 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

Unreachable code detected.
(await this.git.raw(

Check failure on line 198 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

'await' expressions are only allowed within async functions and at the top levels of modules.

Check failure on line 198 in packages/repopo/src/commands/check.ts

View workflow job for this annotation

GitHub Actions / tsgo-validation

'await' expressions are only allowed within async functions and at the top levels of modules.
"ls-files",
// include staged files and untracked files
"-co",
Expand Down
11 changes: 8 additions & 3 deletions packages/repopo/src/policyDefiners/defineFileHeaderPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EOL as newline } from "node:os";
import { call } from "effection";
import { extname } from "pathe";
import type { PolicyFailure, PolicyFixResult, PolicyShape } from "../policy.js";
import { resolveRepoFilePath } from "../utils/safePaths.js";

const trailingSpaces = /\s*\\r\?\\n/;

Expand Down Expand Up @@ -133,11 +134,13 @@ export function defineFileHeaderPolicy(
name,
description,
match: config.match,
handler: function* ({ file, resolve, config: policyConfig }) {
handler: function* ({ file, root, resolve, config: policyConfig }) {
if (policyConfig === undefined) {
return true;
}

const filePath = resolveRepoFilePath(root, file);

const failResult: PolicyFailure = {
name,
file,
Expand All @@ -147,7 +150,9 @@ export function defineFileHeaderPolicy(

// TODO: Consider reading only the first 512B or so since headers are typically
// at the beginning of the file.
const content = yield* call(() => readFile(file, { encoding: "utf8" }));
const content = yield* call(() =>
readFile(filePath, { encoding: "utf8" }),
);
const failed = !regex.test(content);

if (failed) {
Expand All @@ -157,7 +162,7 @@ export function defineFileHeaderPolicy(
if (failed) {
if (resolve) {
const newContent = config.replacer(content, policyConfig);
yield* call(() => writeFile(file, newContent));
yield* call(() => writeFile(filePath, newContent));

const fixResult: PolicyFixResult = {
...failResult,
Expand Down
7 changes: 3 additions & 4 deletions packages/repopo/src/policyDefiners/definePackagePolicy.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { call, type Operation } from "effection";
import jsonfile from "jsonfile";
import { resolve } from "pathe";
import type { PackageJson } from "type-fest";
import { PackageJsonRegexMatch } from "../policies/constants.js";
import type {
PolicyArgs,
PolicyHandlerResult,
PolicyShape,
} from "../policy.js";
import { resolveRepoFilePath } from "../utils/safePaths.js";

const { readFile: readJson } = jsonfile;

Expand Down Expand Up @@ -100,9 +100,8 @@ export function definePackagePolicy<J = PackageJson, C = undefined>(
match: PackageJsonRegexMatch,
defaultConfig,
handler: function* (innerArgs) {
const json: J = yield* call(() =>
readJson(resolve(innerArgs.root, innerArgs.file)),
);
const filePath = resolveRepoFilePath(innerArgs.root, innerArgs.file);
const json: J = yield* call(() => readJson(filePath));
const result = packageHandler(json, innerArgs);

// Handle both Operation (generator) and Promise return types
Expand Down
58 changes: 58 additions & 0 deletions packages/repopo/src/utils/safePaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { isAbsolute, normalize, relative, resolve } from "pathe";

function normalizeRoot(root: string): string {
return normalize(resolve(root));
}

function isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
const relativePath = normalize(relative(rootPath, candidatePath));
if (relativePath === "" || relativePath === ".") {
return true;
}
return (
!isAbsolute(relativePath) &&
relativePath !== ".." &&
!relativePath.startsWith("../")
);
}

/**
* Resolves a candidate file path against the repository root and verifies it does not escape that root.
*/
export function resolveRepoFilePath(root: string, filePath: string): string {
if (filePath.trim().length === 0) {
throw new Error("File path cannot be empty.");
}

const rootPath = normalizeRoot(root);
const candidatePath = normalize(resolve(rootPath, filePath));

if (!isPathWithinRoot(rootPath, candidatePath)) {
throw new Error(
`File path must be within repository root. Received: ${filePath}`,
);
}

return candidatePath;
}

/**
* Converts an input path into a normalized repository-relative path after validating root containment.
*/
export function normalizeRepoRelativeFilePath(
root: string,
filePath: string,
): string {
const rootPath = normalizeRoot(root);
const absolutePath = resolveRepoFilePath(rootPath, filePath);
const relativePath = normalize(relative(rootPath, absolutePath)).replace(
/\\/g,
"/",
);

if (relativePath === "" || relativePath === ".") {
throw new Error("File path resolves to repository root instead of a file.");
}

return relativePath;
}
35 changes: 35 additions & 0 deletions packages/repopo/test/defineFileHeaderPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,41 @@ describe("defineFileHeaderPolicy", () => {
expect(result.errorMessages.join()).toContain(".ts file missing header");
});

it("should reject file paths outside repository root", async () => {
const outsideDir = await mkdtemp(
join(tmpdir(), "repopo-header-outside-test-"),
);
const outsideFile = join(outsideDir, "outside.ts");
await writeFile(outsideFile, "const x = 1;");

const config: FileHeaderGeneratorConfig = {
match: /\.ts$/,
lineStart: /\/\/ /,
lineEnd: /\r?\n/,
replacer: (fileContent, cfg) =>
`// ${cfg.headerText}${EOL}${fileContent}`,
};

const policy = defineFileHeaderPolicy({
name: "TestPolicy",
description: "Test file header policy",
config,
});

try {
await expect(
runHandler(policy.handler, {
file: outsideFile,
root: testDir,
resolve: false,
config: { headerText: "Copyright 2025" },
}),
).rejects.toThrow("within repository root");
} finally {
await rm(outsideDir, { recursive: true, force: true });
}
});

it("should pass when config is undefined", async () => {
const testFile = join(testDir, "test.ts");
await writeFile(testFile, "const x = 1;");
Expand Down
39 changes: 39 additions & 0 deletions packages/repopo/test/definePackagePolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,45 @@ describe("definePackagePolicy", () => {
expect(receivedJson?.version).toBe("1.0.0");
});

it("should reject package.json paths outside repository root", async () => {
const outsideDir = await mkdtemp(
join(tmpdir(), "repopo-pkg-outside-test-"),
);
const outsidePath = join(outsideDir, packageJsonPath);
const packageJson: PackageJson = {
name: "outside-package",
version: "1.0.0",
};

try {
await writeFile(outsidePath, JSON.stringify(packageJson, null, 2));

const handler: PackageJsonHandler<PackageJson, undefined> = function* () {
yield* (function* () {
// Minimal yield to satisfy generator requirements
})();
return true as const;
};

const policy = definePackagePolicy({
name: "TestPackagePolicy",
description: "Test policy for package.json validation",
handler,
});

await expect(
runHandler(policy.handler, {
file: outsidePath,
root: testDir,
resolve: false,
config: undefined,
}),
).rejects.toThrow("within repository root");
} finally {
await rm(outsideDir, { recursive: true, force: true });
}
});

it("should match package.json files with regex", () => {
const handler: PackageJsonHandler<PackageJson, undefined> = function* () {
yield* (function* () {
Expand Down
Loading
Loading