From 5f9503e6474979ecc01dcc0ae20ed5faf048fab2 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:58:20 -0400 Subject: [PATCH] fix: bound context source directory traversal (GREEN) --- scripts/lib/context-profile-support.js | 221 ++++++++++++++----------- 1 file changed, 123 insertions(+), 98 deletions(-) diff --git a/scripts/lib/context-profile-support.js b/scripts/lib/context-profile-support.js index 4dd8d3412..c352c187a 100644 --- a/scripts/lib/context-profile-support.js +++ b/scripts/lib/context-profile-support.js @@ -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();