diff --git a/__tests__/main.test.js b/__tests__/main.test.js index ef41967..c749741 100644 --- a/__tests__/main.test.js +++ b/__tests__/main.test.js @@ -179,3 +179,54 @@ test('test patch level with latest patches only and unstable throws error', () = 'The options "unstable" and "latest-patches-only" cannot be used together' ) }) + +import path from 'path' +import os from 'os' +import {validateWorkingDirectory} from '../src/main.js' + +describe('validateWorkingDirectory', () => { + test('rejects path traversal via ../..', () => { + expect(() => { + validateWorkingDirectory('../..', '/tmp/workspace') + }).toThrow( + 'working-directory must resolve to a path inside the workspace' + ) + }) + + test('rejects absolute path outside workspace', () => { + expect(() => { + validateWorkingDirectory('/etc', '/tmp/workspace') + }).toThrow( + 'working-directory must resolve to a path inside the workspace' + ) + }) + + test('accepts empty working-directory (workspace root)', () => { + expect(() => { + validateWorkingDirectory('', '/tmp/workspace') + }).not.toThrow() + }) + + test('accepts valid subdirectory inside workspace', () => { + expect(() => { + validateWorkingDirectory('subdir', '/tmp/workspace') + }).not.toThrow() + }) + + test('rejects symlink inside workspace pointing outside', () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-')) + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-')) + const symlinkInWorkspace = path.join(workspace, 'escape') + fs.symlinkSync(outside, symlinkInWorkspace) + try { + expect(() => { + validateWorkingDirectory('escape', workspace) + }).toThrow( + 'working-directory must resolve to a path inside the workspace' + ) + } finally { + fs.rmSync(workspace, {recursive: true, force: true}) + fs.rmSync(outside, {recursive: true, force: true}) + } + }) +}) diff --git a/src/main.js b/src/main.js index b3c5735..1cd96ca 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3 +1,5 @@ +import path from 'path' +import fs from 'fs' import * as core from '@actions/core' import { getGoModVersion, @@ -9,6 +11,37 @@ import { modulename } from './go-versions.js' +export function validateWorkingDirectory(workingDirectory, workspace) { + const resolvedDirectory = path.resolve(workspace, workingDirectory) + if ( + resolvedDirectory !== workspace && + !resolvedDirectory.startsWith(workspace + path.sep) + ) { + throw new Error( + 'working-directory must resolve to a path inside the workspace' + ) + } + // Resolve symlinks to prevent symlink-based workspace escapes + try { + const realDirectory = fs.realpathSync(resolvedDirectory) + const realWorkspace = fs.realpathSync(workspace) + if ( + realDirectory !== realWorkspace && + !realDirectory.startsWith(realWorkspace + path.sep) + ) { + throw new Error( + 'working-directory must resolve to a path inside the workspace' + ) + } + } catch (e) { + if (e.code !== 'ENOENT') { + throw e + } + // Directory does not exist yet; the downstream file read will handle it + } + return resolvedDirectory +} + async function run() { try { if (process.env.GITHUB_TOKEN !== undefined) { @@ -23,7 +56,9 @@ async function run() { const withPatchLevel = core.getBooleanInput('patch-level') const withLatestPatches = core.getBooleanInput('latest-patches-only') const withStrictSemver = core.getBooleanInput('strict-semver') - const content = gomod(`${workingDirectory}/go.mod`) + const workspace = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd()) + const resolvedDirectory = validateWorkingDirectory(workingDirectory, workspace) + const content = gomod(path.join(resolvedDirectory, 'go.mod')) const name = modulename(content) const goModVersion = getGoModVersion(content) const versions = await getVersions(withUnsupported) @@ -74,4 +109,7 @@ async function run() { } } -run() +/* istanbul ignore next */ +if (process.env.NODE_ENV !== 'test') { + run() +}