fix(coding-agent): harden git package install paths
This commit is contained in:
@@ -1952,10 +1952,11 @@ export class DefaultPackageManager implements PackageManager {
|
||||
if (scope === "temporary") {
|
||||
return this.getTemporaryDir(`git-${source.host}`, source.path);
|
||||
}
|
||||
if (scope === "project") {
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path);
|
||||
const installRoot = this.getGitInstallRoot(scope);
|
||||
if (!installRoot) {
|
||||
throw new Error("Missing git install root");
|
||||
}
|
||||
return join(this.agentDir, "git", source.host, source.path);
|
||||
return this.resolveManagedPath(installRoot, source.host, source.path);
|
||||
}
|
||||
|
||||
private getGitInstallRoot(scope: SourceScope): string | undefined {
|
||||
@@ -1969,11 +1970,21 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
|
||||
private getTemporaryDir(prefix: string, suffix?: string): string {
|
||||
const root = this.resolveManagedPath(join(tmpdir(), "pi-extensions"), prefix);
|
||||
const hash = createHash("sha256")
|
||||
.update(`${prefix}-${suffix ?? ""}`)
|
||||
.digest("hex")
|
||||
.slice(0, 8);
|
||||
return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? "");
|
||||
return this.resolveManagedPath(root, hash, suffix ?? "");
|
||||
}
|
||||
|
||||
private resolveManagedPath(root: string, ...parts: string[]): string {
|
||||
const resolvedRoot = resolve(root);
|
||||
const resolvedPath = resolve(resolvedRoot, ...parts);
|
||||
if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) {
|
||||
throw new Error(`Refusing to use path outside package install root: ${resolvedPath}`);
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
private getBaseDirForScope(scope: SourceScope): string {
|
||||
|
||||
@@ -73,6 +73,56 @@ function splitRef(url: string): { repo: string; ref?: string } {
|
||||
};
|
||||
}
|
||||
|
||||
function decodeForValidation(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsafeGitInstallPart(value: string, allowSlash: boolean): boolean {
|
||||
const decoded = decodeForValidation(value);
|
||||
if (decoded === null) {
|
||||
return true;
|
||||
}
|
||||
const candidates = [value, decoded];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.includes("\0") || candidate.includes("\\") || candidate.startsWith("/")) {
|
||||
return true;
|
||||
}
|
||||
if (!allowSlash && candidate.includes("/")) {
|
||||
return true;
|
||||
}
|
||||
if (candidate.split("/").includes("..")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildGitSource(args: { repo: string; host: string; path: string; ref?: string }): GitSource | null {
|
||||
if (args.path.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
const normalizedPath = args.path.replace(/\.git$/, "").replace(/^\/+/, "");
|
||||
if (!args.host || !normalizedPath || normalizedPath.split("/").length < 2) {
|
||||
return null;
|
||||
}
|
||||
if (hasUnsafeGitInstallPart(args.host, false) || hasUnsafeGitInstallPart(normalizedPath, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "git",
|
||||
repo: args.repo,
|
||||
host: args.host,
|
||||
path: normalizedPath,
|
||||
ref: args.ref,
|
||||
pinned: Boolean(args.ref),
|
||||
};
|
||||
}
|
||||
|
||||
function parseGenericGitUrl(url: string): GitSource | null {
|
||||
const { repo: repoWithoutRef, ref } = splitRef(url);
|
||||
let repo = repoWithoutRef;
|
||||
@@ -109,19 +159,7 @@ function parseGenericGitUrl(url: string): GitSource | null {
|
||||
repo = `https://${repoWithoutRef}`;
|
||||
}
|
||||
|
||||
const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "");
|
||||
if (!host || !normalizedPath || normalizedPath.split("/").length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "git",
|
||||
repo,
|
||||
host,
|
||||
path: normalizedPath,
|
||||
ref,
|
||||
pinned: Boolean(ref),
|
||||
};
|
||||
return buildGitSource({ repo, host, path, ref });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,14 +195,12 @@ export function parseGitUrl(source: string): GitSource | null {
|
||||
!split.repo.startsWith("ssh://") &&
|
||||
!split.repo.startsWith("git://") &&
|
||||
!split.repo.startsWith("git@");
|
||||
return {
|
||||
type: "git",
|
||||
return buildGitSource({
|
||||
repo: useHttpsPrefix ? `https://${split.repo}` : split.repo,
|
||||
host: info.domain || "",
|
||||
path: `${info.user}/${info.project}`.replace(/\.git$/, ""),
|
||||
path: `${info.user}/${info.project}`,
|
||||
ref: info.committish || split.ref || undefined,
|
||||
pinned: Boolean(info.committish || split.ref),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,14 +213,12 @@ export function parseGitUrl(source: string): GitSource | null {
|
||||
if (split.ref && info.project?.includes("@")) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
type: "git",
|
||||
return buildGitSource({
|
||||
repo: `https://${split.repo}`,
|
||||
host: info.domain || "",
|
||||
path: `${info.user}/${info.project}`.replace(/\.git$/, ""),
|
||||
path: `${info.user}/${info.project}`,
|
||||
ref: info.committish || split.ref || undefined,
|
||||
pinned: Boolean(info.committish || split.ref),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user