mirror of
https://github.com/affaan-m/ECC.git
synced 2026-11-10 18:16:02 +00:00
fix: bound context source directory traversal (GREEN)
This commit is contained in:
1 file changed
+123
-98
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user