ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
75 lines
2.6 KiB
TypeScript
75 lines
2.6 KiB
TypeScript
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
|
|
function isContained(candidate: string, root: string): boolean {
|
|
return candidate === root || candidate.startsWith(root + path.sep);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
return assertLexicalContainment(userPath, sandboxDir);
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|