compose: add wrapper-first dogfood workspace (#1488)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
This commit was merged in pull request #1488.
This commit is contained in:
@@ -1,47 +1,63 @@
|
||||
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);
|
||||
function isContained(candidate: string, root: string): boolean {
|
||||
return candidate === root || candidate.startsWith(root + path.sep);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
function assertLexicalContainment(userPath: string, sandboxDir: string): string {
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxAbsolute = path.resolve(sandboxDir);
|
||||
if (!isContained(resolved, sandboxAbsolute)) {
|
||||
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.
|
||||
* Resolve an existing path and verify both its lexical path and real symlink
|
||||
* target remain inside the sandbox.
|
||||
*/
|
||||
export function guardPath(userPath: string, sandboxDir: string): string {
|
||||
const resolved = assertLexicalContainment(userPath, sandboxDir);
|
||||
const sandboxReal = fs.realpathSync.native(sandboxDir);
|
||||
const resolvedReal = fs.realpathSync.native(resolved);
|
||||
if (!isContained(resolvedReal, sandboxReal)) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolvedReal);
|
||||
}
|
||||
return resolvedReal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a writable file path whose parent already exists. Existing targets
|
||||
* are resolved fully. New targets use the real parent directory, which blocks
|
||||
* writes through a parent symlink that leaves the sandbox.
|
||||
*/
|
||||
export function guardWritePath(userPath: string, sandboxDir: string): string {
|
||||
const resolved = assertLexicalContainment(userPath, sandboxDir);
|
||||
const sandboxReal = fs.realpathSync.native(sandboxDir);
|
||||
let writableReal: string;
|
||||
try {
|
||||
writableReal = fs.realpathSync.native(resolved);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') throw error;
|
||||
const parentReal = fs.realpathSync.native(path.dirname(resolved));
|
||||
writableReal = path.join(parentReal, path.basename(resolved));
|
||||
}
|
||||
if (!isContained(writableReal, sandboxReal)) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, writableReal);
|
||||
}
|
||||
return writableReal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexical-only validation for non-filesystem pathspecs such as `git diff --`
|
||||
* targets, where the path may name a deleted file and Git does not dereference
|
||||
* a tracked symlink.
|
||||
*/
|
||||
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;
|
||||
return assertLexicalContainment(userPath, sandboxDir);
|
||||
}
|
||||
|
||||
export class SandboxEscapeError extends Error {
|
||||
|
||||
Reference in New Issue
Block a user