fix: bound context source directory traversal (GREEN)

This commit is contained in:
haelyra committed 2026-09-08 23:58:20 -04:00
1 parent 9b5e393435
commit 5f9503e647
1 file changed
+123 -98
+123 -98
View File
@@ -10,6 +10,8 @@ const DEFAULT_REPO_ROOT = path.resolve(__dirname, '../..');
const MAX_FILE_BYTES = 4 * 1024 * 1024;
const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
const MAX_SOURCE_FILES = 10000;
const MAX_DIRECTORY_ENTRIES = 10000;
const MAX_TRAVERSAL_OPERATIONS = 20000;
const TARGETS = Object.freeze([...new Set([...SUPPORTED_INSTALL_TARGETS, 'pi'])].sort());
const EXCLUDED_DIRECTORIES = new Set(['.git', 'node_modules', '__pycache__', '.pytest_cache']);
@@ -52,109 +54,132 @@ function validateRelativePath(relativePath) {
}
}
function sameIdentity(before, after) {
return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode;
}
function inspectSource(state, relativePath, kind) {
validateRelativePath(relativePath);
let current = state.root;
let stats = fs.lstatSync(current);
if (!sameIdentity(state.rootIdentity, stats)) throw new Error('Source root identity changed');
const chain = [{ path: current, stats }];
const segments = relativePath.split('/');
for (const [index, segment] of segments.entries()) {
current = path.join(current, segment);
stats = fs.lstatSync(current);
if (stats.isSymbolicLink()) throw new Error(`Symbolic link source is forbidden: ${relativePath}`);
if (index < segments.length - 1 && !stats.isDirectory()) throw new Error(`Source ancestor is not a directory: ${relativePath}`);
chain.push({ path: current, stats });
}
if (kind === 'file' && !stats.isFile()) throw new Error(`Source is not a regular file: ${relativePath}`);
if (kind === 'directory' && !stats.isDirectory()) throw new Error(`Source is not a directory: ${relativePath}`);
return { path: current, stats, chain };
}
function revalidateSource(source) {
for (const entry of source.chain) {
const current = fs.lstatSync(entry.path);
if (current.isSymbolicLink() || !sameIdentity(entry.stats, current)) {
throw new Error('Source ancestor or file identity changed during read');
}
}
}
function validateOpenedFile(state, source, before, relativePath) {
// Recheck before the first byte read. O_NOFOLLOW only guards the leaf.
revalidateSource(source);
if (!sameIdentity(source.stats, before) || source.stats.size !== before.size
|| source.stats.mtimeMs !== before.mtimeMs || source.stats.ctimeMs !== before.ctimeMs) {
throw new Error(`Source identity changed before read: ${relativePath}`);
}
if (!before.isFile() || before.size > MAX_FILE_BYTES) throw new Error(`Source byte limit exceeded: ${relativePath}`);
if (state.totalBytes + before.size > MAX_TOTAL_BYTES) throw new Error('Cumulative source byte limit exceeded');
}
function readDescriptorBytes(descriptor, size) {
const buffer = Buffer.alloc(size + 1);
let bytes = 0;
while (bytes < buffer.length) {
const count = fs.readSync(descriptor, buffer, bytes, buffer.length - bytes, null);
if (!count) break;
bytes += count;
}
return buffer.subarray(0, bytes);
}
function readSourceFile(state, relativePath) {
if (state.cache.has(relativePath)) return state.cache.get(relativePath);
const source = inspectSource(state, relativePath, 'file');
if (state.cache.size >= MAX_SOURCE_FILES) throw new Error('Source file count limit exceeded');
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0);
const descriptor = fs.openSync(source.path, flags);
try {
const before = fs.fstatSync(descriptor);
validateOpenedFile(state, source, before, relativePath);
const content = readDescriptorBytes(descriptor, before.size);
const after = fs.fstatSync(descriptor);
revalidateSource(source);
if (content.length !== before.size || after.size !== before.size || before.mtimeMs !== after.mtimeMs
|| before.ctimeMs !== after.ctimeMs) throw new Error(`Source changed during read: ${relativePath}`);
const value = { path: relativePath, bytes: content.length, digest: digest(content), content };
state.totalBytes += content.length;
state.cache.set(relativePath, value);
return value;
} finally { fs.closeSync(descriptor); }
}
function chargeTraversal(state) {
state.traversalOperations++;
if (state.traversalOperations > MAX_TRAVERSAL_OPERATIONS) throw new Error('Source traversal operation limit exceeded');
}
function listSourceDirectory(state, relativePath) {
const source = inspectSource(state, relativePath, 'directory');
chargeTraversal(state); // Empty directories still consume a traversal operation.
const directory = fs.opendirSync(source.path, { bufferSize: 32 });
try {
revalidateSource(source);
const entries = [];
for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) {
if (entries.length >= MAX_DIRECTORY_ENTRIES) throw new Error('Source directory entry limit exceeded');
chargeTraversal(state); // Count all names before any generated-file filtering.
entries.push(entry.name);
}
revalidateSource(source);
return entries.sort();
} finally { directory.closeSync(); }
}
function walkSourceDirectory(state, relativePath, depth = 0) {
if (depth > 32) throw new Error('Source directory depth limit exceeded');
return listSourceDirectory(state, relativePath).flatMap(name => {
const child = `${relativePath}/${name}`;
if (isExcludedResource(child)) return [];
const source = inspectSource(state, child);
return source.stats.isDirectory() ? walkSourceDirectory(state, child, depth + 1) : [readSourceFile(state, child)];
});
}
function readSourceJson(state, relativePath) {
try { return JSON.parse(readSourceFile(state, relativePath).content.toString('utf8')); } catch (error) {
throw new Error(`Cannot read JSON source ${relativePath}: ${error.message}`);
}
}
function createSourceReader(repoRoot = DEFAULT_REPO_ROOT) {
if (typeof repoRoot !== 'string' || !repoRoot.trim()) throw new Error('repoRoot must be a non-empty path');
const root = fs.realpathSync(repoRoot);
const rootIdentity = fs.lstatSync(root);
if (!rootIdentity.isDirectory()) throw new Error('repoRoot must be a directory');
const cache = new Map();
let totalBytes = 0;
function sameIdentity(before, after) {
return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode;
}
function inspect(relativePath, kind) {
validateRelativePath(relativePath);
let current = root;
let stats = fs.lstatSync(root);
if (!sameIdentity(rootIdentity, stats)) throw new Error('Source root identity changed');
const chain = [{ path: root, stats }];
const segments = relativePath.split('/');
for (const [index, segment] of segments.entries()) {
current = path.join(current, segment);
stats = fs.lstatSync(current);
if (stats.isSymbolicLink()) throw new Error(`Symbolic link source is forbidden: ${relativePath}`);
if (index < segments.length - 1 && !stats.isDirectory()) throw new Error(`Source ancestor is not a directory: ${relativePath}`);
chain.push({ path: current, stats });
}
if (kind === 'file' && !stats.isFile()) throw new Error(`Source is not a regular file: ${relativePath}`);
if (kind === 'directory' && !stats.isDirectory()) throw new Error(`Source is not a directory: ${relativePath}`);
return { path: current, stats, chain };
}
function revalidate(source) {
for (const entry of source.chain) {
const current = fs.lstatSync(entry.path);
if (current.isSymbolicLink() || !sameIdentity(entry.stats, current)) {
throw new Error('Source ancestor or file identity changed during read');
}
}
}
function resolve(relativePath, kind) {
return inspect(relativePath, kind).path;
}
function read(relativePath) {
if (cache.has(relativePath)) return cache.get(relativePath);
const source = inspect(relativePath, 'file');
if (cache.size >= MAX_SOURCE_FILES) throw new Error('Source file count limit exceeded');
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0);
const descriptor = fs.openSync(source.path, flags);
try {
const before = fs.fstatSync(descriptor);
// Recheck before the first byte read. O_NOFOLLOW only guards the leaf.
revalidate(source);
if (!sameIdentity(source.stats, before) || source.stats.size !== before.size
|| source.stats.mtimeMs !== before.mtimeMs || source.stats.ctimeMs !== before.ctimeMs) {
throw new Error(`Source identity changed before read: ${relativePath}`);
}
if (!before.isFile() || before.size > MAX_FILE_BYTES) throw new Error(`Source byte limit exceeded: ${relativePath}`);
if (totalBytes + before.size > MAX_TOTAL_BYTES) throw new Error('Cumulative source byte limit exceeded');
const buffer = Buffer.alloc(before.size + 1);
let bytes = 0;
while (bytes < buffer.length) {
const count = fs.readSync(descriptor, buffer, bytes, buffer.length - bytes, null);
if (!count) break;
bytes += count;
}
const after = fs.fstatSync(descriptor);
revalidate(source);
if (bytes !== before.size || after.size !== before.size || before.mtimeMs !== after.mtimeMs
|| before.ctimeMs !== after.ctimeMs) throw new Error(`Source changed during read: ${relativePath}`);
const content = buffer.subarray(0, bytes);
const value = { path: relativePath, bytes, digest: digest(content), content };
totalBytes += bytes;
cache.set(relativePath, value);
return value;
} finally { fs.closeSync(descriptor); }
}
function list(relativePath) {
const source = inspect(relativePath, 'directory');
const entries = fs.readdirSync(source.path).sort();
revalidate(source);
return entries;
}
function walk(relativePath, depth = 0) {
if (depth > 32) throw new Error('Source directory depth limit exceeded');
return list(relativePath).flatMap(name => {
const child = `${relativePath}/${name}`;
if (isExcludedResource(child)) return [];
const absolute = resolve(child);
const stats = fs.lstatSync(absolute);
return stats.isDirectory() ? walk(child, depth + 1) : [read(child)];
});
}
function json(relativePath) {
try { return JSON.parse(read(relativePath).content.toString('utf8')); } catch (error) {
throw new Error(`Cannot read JSON source ${relativePath}: ${error.message}`);
}
}
return { read, list, walk, json, resolve };
const state = { root, rootIdentity, cache: new Map(), totalBytes: 0, traversalOperations: 0 };
return {
read: relativePath => readSourceFile(state, relativePath),
list: relativePath => listSourceDirectory(state, relativePath),
walk: (relativePath, depth = 0) => walkSourceDirectory(state, relativePath, depth),
json: relativePath => readSourceJson(state, relativePath),
resolve: (relativePath, kind) => inspectSource(state, relativePath, kind).path,
};
}
const schemaValidators = new Map();