fix(security): restrict skill download target paths

This commit is contained in:
Peter Steinberger
2026-02-16 03:46:32 +01:00
parent c6c53437f7
commit 2363e1b085
9 changed files with 442 additions and 324 deletions

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import * as tar from "tar";
import { resolveSafeBaseDir } from "./path-safety.js";
export type ArchiveKind = "tar" | "zip";
@@ -101,11 +102,6 @@ export async function withTimeout<T>(
}
}
function resolveSafeBaseDir(destDir: string): string {
const resolved = path.resolve(destDir);
return resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`;
}
// Path hygiene.
function normalizeArchivePath(raw: string): string {
// Archives may contain Windows separators; treat them as separators.

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import path from "node:path";
export function unscopedPackageName(name: string): string {
@@ -16,6 +17,30 @@ export function safeDirName(input: string): string {
return trimmed.replaceAll("/", "__").replaceAll("\\", "__");
}
export function safePathSegmentHashed(input: string): string {
const trimmed = input.trim();
const base = trimmed
.replaceAll(/[\\/]/g, "-")
.replaceAll(/[^a-zA-Z0-9._-]/g, "-")
.replaceAll(/-+/g, "-")
.replaceAll(/^-+/g, "")
.replaceAll(/-+$/g, "");
const normalized = base.length > 0 ? base : "skill";
const safe = normalized === "." || normalized === ".." ? "skill" : normalized;
const hash = createHash("sha256").update(trimmed).digest("hex").slice(0, 10);
if (safe !== trimmed) {
const prefix = safe.length > 50 ? safe.slice(0, 50) : safe;
return `${prefix}-${hash}`;
}
if (safe.length > 60) {
return `${safe.slice(0, 50)}-${hash}`;
}
return safe;
}
export function resolveSafeInstallDir(params: {
baseDir: string;
id: string;

20
src/infra/path-safety.ts Normal file
View File

@@ -0,0 +1,20 @@
import path from "node:path";
export function resolveSafeBaseDir(rootDir: string): string {
const resolved = path.resolve(rootDir);
return resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`;
}
export function isWithinDir(rootDir: string, targetPath: string): boolean {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(targetPath);
// Windows paths are effectively case-insensitive; normalize to avoid false negatives.
if (process.platform === "win32") {
const relative = path.win32.relative(resolvedRoot.toLowerCase(), resolvedTarget.toLowerCase());
return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative));
}
const relative = path.relative(resolvedRoot, resolvedTarget);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}

View File

@@ -20,6 +20,7 @@ import {
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "../routing/session-key.js";
import { isWithinDir } from "./path-safety.js";
import {
ensureDir,
existsDir,
@@ -360,11 +361,6 @@ function isDirPath(filePath: string): boolean {
}
}
function isWithinDir(targetPath: string, rootDir: string): boolean {
const relative = path.relative(path.resolve(rootDir), path.resolve(targetPath));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function isLegacyTreeSymlinkMirror(currentDir: string, realTargetDir: string): boolean {
let entries: fs.Dirent[];
try {
@@ -395,7 +391,7 @@ function isLegacyTreeSymlinkMirror(currentDir: string, realTargetDir: string): b
} catch {
return false;
}
if (!isWithinDir(resolvedRealTarget, realTargetDir)) {
if (!isWithinDir(realTargetDir, resolvedRealTarget)) {
return false;
}
continue;