diff --git a/Docs/scripts/themeUpdate.js b/Docs/scripts/themeUpdate.js index 742cd20b..314ec2b6 100644 --- a/Docs/scripts/themeUpdate.js +++ b/Docs/scripts/themeUpdate.js @@ -5,15 +5,17 @@ const localDirectory = "./themes/hugoplate"; const foldersToFetch = ["assets", "layouts"]; const foldersToSkip = ["exampleSite"]; -const fetchFolder = (folder) => { - exec( - `curl -L ${repositoryUrl}/tarball/main | tar -xz --strip-components=1 --directory=${localDirectory} --exclude=$(curl -sL ${repositoryUrl}/tarball/main | tar -tz | grep -E "/(${foldersToSkip.join( - "|", - )})/") */${folder}`, - ); -}; +const excludePattern = foldersToSkip.join("|"); -// Fetch each specified folder -foldersToFetch.forEach((folder) => { - fetchFolder(folder); -}); +// Precompute safe, static command strings keyed by allowed folder name +const folderCommands = Object.fromEntries( + foldersToFetch.map((f) => [ + f, + `curl -L ${repositoryUrl}/tarball/main | tar -xz --strip-components=1 --directory=${localDirectory} --exclude=$(curl -sL ${repositoryUrl}/tarball/main | tar -tz | grep -E "/(${excludePattern})/") */${f}`, + ]), +); + +// Fetch each specified folder using only precomputed static commands +for (const cmd of Object.values(folderCommands)) { + exec(cmd); +} diff --git a/tests/invariant_themeUpdate.test.js b/tests/invariant_themeUpdate.test.js new file mode 100644 index 00000000..e48d1a39 --- /dev/null +++ b/tests/invariant_themeUpdate.test.js @@ -0,0 +1,53 @@ +const { exec } = require('child_process'); +const path = require('path'); + +// Import the actual production function +const themeUpdate = require('./themeUpdate.js'); + +describe('Shell commands never include unsanitized user input', () => { + const payloads = [ + // Exact exploit case - command injection + 'theme; rm -rf /', + // Alternative injection syntax + '$(whoami)', + // Backtick execution + '`id`', + // Boundary case - normal input + 'valid-theme-folder', + // Edge case with spaces and special chars + 'theme folder; echo hacked' + ]; + + test.each(payloads)('rejects adversarial input: %s', async (folder) => { + // Mock exec to capture the actual command being executed + const execCalls = []; + const originalExec = exec; + + // Temporarily replace exec to intercept calls + require('child_process').exec = jest.fn((command, callback) => { + execCalls.push(command); + // Simulate error to prevent actual execution + callback(new Error('Mocked exec call')); + }); + + try { + await themeUpdate(folder); + + // If we reach here, check what command would have been executed + if (execCalls.length > 0) { + // Assert that the raw folder input doesn't appear unescaped in the command + execCalls.forEach(command => { + // The command should NOT contain the raw, unescaped folder value + // If it does, that's a command injection vulnerability + expect(command).not.toContain(folder); + }); + } + } catch (error) { + // If the function throws, that's acceptable - it means it rejected bad input + // No assertion needed here as throwing is a valid defense + } finally { + // Restore original exec + require('child_process').exec = originalExec; + } + }); +}); \ No newline at end of file