Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion scripts/github-actions/check-plugin-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -645,9 +645,18 @@ function walkTextFiles(repoRoot, rel = "", seen = new Set()) {
return out;
}

function repoAbsPath(repoRoot, repoPath) {
const base = path.resolve(repoRoot);
const abs = path.join(base, repoPath);
if (abs !== base && !abs.startsWith(base + path.sep)) return null;
return abs;
}
Comment on lines +648 to +653

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · low
Consider also applying the repoAbsPath guard to listEntries (line 676) for defense-in-depth. While current callers pass constants (SKILL_ROOTS), future callers might pass user-derived paths. Applying the same traversal check consistently across all functions that join repoRoot with an external path would make the codebase more robust against future regressions.


function probeKind(repoRoot, repoPath) {
const abs = repoAbsPath(repoRoot, repoPath);
if (abs === null) return null;
try {
const stat = fs.statSync(path.join(repoRoot, repoPath));
const stat = fs.statSync(abs);
return stat.isDirectory() ? "dir" : "file";
} catch (e) {
return null;
Expand Down Expand Up @@ -967,6 +976,7 @@ module.exports = {
resolveDeclaredPath,
checkPluginDeclarations,
pluginEntries,
repoAbsPath,
listEntries,
readFileOrNull,
runLinksCheck,
Expand Down
37 changes: 37 additions & 0 deletions scripts/github-actions/check-plugin-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const {
resolveDeclaredPath,
checkPluginDeclarations,
pluginEntries,
repoAbsPath,
listEntries,
readFileOrNull,
runLinksCheck,
Expand Down Expand Up @@ -673,6 +674,40 @@ function testReadFileOrNullSizeCapIsOptional() {
});
}

function testRepoAbsPathRefusesEscapes() {
const root = path.resolve(path.join(os.tmpdir(), "ocr-confine-root"));
assert.strictEqual(repoAbsPath(root, "docs/x.md"), path.join(root, "docs/x.md"));
assert.strictEqual(repoAbsPath(root, "./docs/x.md"), path.join(root, "docs/x.md"));
// The root itself is in the root.
assert.strictEqual(repoAbsPath(root, ""), root);
// `..` is what path.join normalises away, so it is the escape that has to be
// refused rather than followed.
assert.strictEqual(repoAbsPath(root, "../sibling.md"), null);
assert.strictEqual(repoAbsPath(root, "docs/../../sibling.md"), null);
assert.strictEqual(repoAbsPath(root, ".."), null);
// A leading slash joins INSIDE the root, which is path.join's behaviour and
// not the path.resolve reset that a naive confinement would introduce.
assert.strictEqual(repoAbsPath(root, "/docs/x.md"), path.join(root, "docs/x.md"));
}

function testOutOfRepoLinkPathsAreNotProbed() {
withTempDir((tmp) => {
const repo = path.join(tmp, "repo");
fs.mkdirSync(repo);
fs.writeFileSync(path.join(tmp, "sibling.md"), "not part of the repo\n");
fs.writeFileSync(path.join(repo, "doc.md"), `link: ${BLOB}../sibling.md`);

const { code, lines } = withCapturedStdout(() =>
runLinksCheck({ repoRoot: repo, minLinks: 1, minFiles: 1 })
);
assert.strictEqual(code, 1, lines.join("\n"));
assert.ok(
lines.some((l) => l.includes("`../sibling.md`") && /does not exist/.test(l)),
lines.join("\n")
);
});
}

function withCapturedStdout(fn) {
const orig = console.log;
const lines = [];
Expand Down Expand Up @@ -839,6 +874,8 @@ function main_() {
testWalkFollowsSymlinksWithoutLooping();
testUnscannedDocsAreReportedNotSkippedSilently();
testReadFileOrNullSizeCapIsOptional();
testRepoAbsPathRefusesEscapes();
testOutOfRepoLinkPathsAreNotProbed();
testDeclarationTargetsAreCoveredByTheTreesWeValidate();
testCorpusFloorFailsClosed();
testLinksRunnerOnFixture();
Expand Down
Loading