All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
59 lines
2.2 KiB
TypeScript
59 lines
2.2 KiB
TypeScript
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
|
|
/**
|
|
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory.
|
|
* Throws SandboxEscapeError if the resolved path is outside the sandbox.
|
|
*
|
|
* Uses realpathSync to resolve symlinks in the sandbox root. The user-supplied path
|
|
* is checked for containment AFTER lexical resolution but BEFORE resolving any symlinks
|
|
* within the user path — so symlink escape attempts are caught too.
|
|
*
|
|
* @param userPath - The path provided by the agent (may be relative or absolute)
|
|
* @param sandboxDir - The allowed root directory (already validated on session creation)
|
|
* @returns The resolved absolute path, guaranteed to be within sandboxDir
|
|
*/
|
|
export function guardPath(userPath: string, sandboxDir: string): string {
|
|
const resolved = path.resolve(sandboxDir, userPath);
|
|
const sandboxResolved = fs.realpathSync.native(sandboxDir);
|
|
|
|
// Normalize both paths to resolve any symlinks in the sandbox root itself.
|
|
// For the user path, we check containment BEFORE resolving symlinks in the path
|
|
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox)
|
|
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) {
|
|
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Validates a path without resolving symlinks in the user-provided portion.
|
|
* Use for paths that may not exist yet (creates, writes).
|
|
*
|
|
* Performs a lexical containment check only using path.resolve.
|
|
*/
|
|
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
|
|
const resolved = path.resolve(sandboxDir, userPath);
|
|
const sandboxAbs = path.resolve(sandboxDir);
|
|
|
|
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
|
|
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
export class SandboxEscapeError extends Error {
|
|
constructor(
|
|
public readonly userPath: string,
|
|
public readonly sandboxDir: string,
|
|
public readonly resolvedPath: string,
|
|
) {
|
|
super(
|
|
`Path escape attempt blocked: "${userPath}" resolves to "${resolvedPath}" which is outside sandbox "${sandboxDir}"`,
|
|
);
|
|
this.name = 'SandboxEscapeError';
|
|
}
|
|
}
|