fix: harden filesystem trust boundaries

This commit is contained in:
sck_0
2026-03-15 08:39:22 +01:00
parent 226f10c2a6
commit fe07e07215
20 changed files with 630 additions and 124 deletions

View File

@@ -0,0 +1,53 @@
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { copyRecursiveSync } = require("../../bin/install");
async function main() {
const { copyFolderSync } = await import("../../scripts/setup_web.js");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "copy-security-"));
try {
const safeRoot = path.join(root, "safe-root");
const destRoot = path.join(root, "dest-root");
const outsideDir = path.join(root, "outside");
fs.mkdirSync(path.join(safeRoot, "nested"), { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(path.join(safeRoot, "nested", "ok.txt"), "ok");
fs.writeFileSync(path.join(outsideDir, "secret.txt"), "secret");
fs.symlinkSync(outsideDir, path.join(safeRoot, "escape-link"));
copyRecursiveSync(safeRoot, path.join(destRoot, "install-copy"), safeRoot);
copyFolderSync(safeRoot, path.join(destRoot, "web-copy"), safeRoot);
assert.strictEqual(
fs.existsSync(path.join(destRoot, "install-copy", "escape-link", "secret.txt")),
false,
"installer copy must not follow symlinks outside the cloned root",
);
assert.strictEqual(
fs.existsSync(path.join(destRoot, "web-copy", "escape-link", "secret.txt")),
false,
"web setup copy must not follow symlinks outside the skills root",
);
assert.strictEqual(
fs.readFileSync(path.join(destRoot, "install-copy", "nested", "ok.txt"), "utf8"),
"ok",
);
assert.strictEqual(
fs.readFileSync(path.join(destRoot, "web-copy", "nested", "ok.txt"), "utf8"),
"ok",
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,37 @@
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { listSkillIds } = require("../../lib/skill-utils");
function withTempDir(fn) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-utils-security-"));
try {
fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
withTempDir((root) => {
const skillsDir = path.join(root, "skills");
const outsideDir = path.join(root, "outside-secret");
fs.mkdirSync(skillsDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
fs.mkdirSync(path.join(skillsDir, "safe-skill"));
fs.writeFileSync(path.join(skillsDir, "safe-skill", "SKILL.md"), "# safe\n");
fs.writeFileSync(path.join(outsideDir, "SKILL.md"), "# secret\n");
fs.symlinkSync(outsideDir, path.join(skillsDir, "linked-secret"));
const skillIds = listSkillIds(skillsDir);
assert.deepStrictEqual(
skillIds,
["safe-skill"],
"symlinked skill directories must not be treated as local skills",
);
});

View File

@@ -0,0 +1,37 @@
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { resolveSafeRealPath } = require("../../lib/symlink-safety");
function withTempDir(fn) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "symlink-safety-"));
try {
fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
withTempDir((root) => {
const safeRoot = path.join(root, "safe-root");
const internalDir = path.join(safeRoot, "internal");
const outsideDir = path.join(root, "outside");
const internalLink = path.join(safeRoot, "internal-link");
const outsideLink = path.join(safeRoot, "outside-link");
fs.mkdirSync(internalDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(path.join(internalDir, "data.txt"), "ok");
fs.writeFileSync(path.join(outsideDir, "secret.txt"), "secret");
fs.symlinkSync(internalDir, internalLink);
fs.symlinkSync(outsideDir, outsideLink);
const internalResolved = resolveSafeRealPath(safeRoot, internalLink);
const outsideResolved = resolveSafeRealPath(safeRoot, outsideLink);
assert.strictEqual(internalResolved, fs.realpathSync(internalDir));
assert.strictEqual(outsideResolved, null);
});

View File

@@ -0,0 +1,36 @@
import sys
import tempfile
import unittest
from pathlib import Path
TOOLS_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(TOOLS_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_SCRIPTS_DIR))
import fix_skills_metadata
class FixSkillsMetadataSecurityTests(unittest.TestCase):
def test_skips_symlinked_skill_markdown(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
skill_dir = root / "safe-skill"
outside_dir = root / "outside"
skill_dir.mkdir()
outside_dir.mkdir()
target = outside_dir / "SKILL.md"
target.write_text("---\nname: outside\n---\nbody\n", encoding="utf-8")
(skill_dir / "SKILL.md").symlink_to(target)
fix_skills_metadata.fix_skills(root)
self.assertEqual(
target.read_text(encoding="utf-8"),
"---\nname: outside\n---\nbody\n",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,53 @@
import importlib.util
import json
import pathlib
import sys
import tempfile
import unittest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
sys.path.insert(0, str(REPO_ROOT / "tools" / "scripts"))
def load_module(module_path: str, module_name: str):
spec = importlib.util.spec_from_file_location(module_name, REPO_ROOT / module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
generate_index = load_module("tools/scripts/generate_index.py", "generate_index")
class GenerateIndexSecurityTests(unittest.TestCase):
def test_parse_frontmatter_rejects_non_mapping_yaml(self):
metadata = generate_index.parse_frontmatter("---\njust-a-string\n---\nbody\n")
self.assertEqual(metadata, {})
def test_generate_index_skips_symlinked_skill_markdown(self):
with tempfile.TemporaryDirectory() as temp_dir:
skills_dir = pathlib.Path(temp_dir) / "skills"
safe_skill_dir = skills_dir / "safe-skill"
linked_skill_dir = skills_dir / "linked-skill"
outside_dir = pathlib.Path(temp_dir) / "outside"
output_file = pathlib.Path(temp_dir) / "skills_index.json"
safe_skill_dir.mkdir(parents=True)
linked_skill_dir.mkdir(parents=True)
outside_dir.mkdir()
(safe_skill_dir / "SKILL.md").write_text("---\nname: Safe Skill\n---\nbody\n", encoding="utf-8")
target = outside_dir / "secret.txt"
target.write_text("outside data", encoding="utf-8")
(linked_skill_dir / "SKILL.md").symlink_to(target)
skills = generate_index.generate_index(str(skills_dir), str(output_file))
self.assertEqual([skill["id"] for skill in skills], ["safe-skill"])
written = json.loads(output_file.read_text(encoding="utf-8"))
self.assertEqual([skill["id"] for skill in written], ["safe-skill"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,41 @@
import importlib.util
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
def load_module(relative_path: str, module_name: str):
module_path = REPO_ROOT / relative_path
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
class OfficeUnpackSecurityTests(unittest.TestCase):
def test_extract_archive_safely_blocks_zip_slip(self):
module = load_module("skills/docx/ooxml/scripts/unpack.py", "docx_unpack")
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
archive_path = temp_path / "payload.zip"
output_dir = temp_path / "output"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("../escape.txt", "escape")
archive.writestr("word/document.xml", "<w:document/>")
with self.assertRaises(ValueError):
module.extract_archive_safely(archive_path, output_dir)
self.assertFalse((temp_path / "escape.txt").exists())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,33 @@
import sys
import tempfile
import unittest
from pathlib import Path
TOOLS_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(TOOLS_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_SCRIPTS_DIR))
import skills_manager
class SkillsManagerSecurityTests(unittest.TestCase):
def test_rejects_path_traversal_skill_names(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
skills_manager.SKILLS_DIR = root / "skills"
skills_manager.DISABLED_DIR = skills_manager.SKILLS_DIR / ".disabled"
skills_manager.SKILLS_DIR.mkdir(parents=True)
skills_manager.DISABLED_DIR.mkdir(parents=True)
outside = root / "outside"
outside.mkdir()
escaped = skills_manager.DISABLED_DIR.parent / "escaped-skill"
escaped.mkdir()
self.assertFalse(skills_manager.enable_skill("../escaped-skill"))
self.assertTrue(escaped.exists())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,46 @@
import sys
import tempfile
import unittest
from pathlib import Path
TOOLS_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(TOOLS_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_SCRIPTS_DIR))
import sync_microsoft_skills as sms
class SyncMicrosoftSkillsSecurityTests(unittest.TestCase):
def test_sanitize_flat_name_rejects_path_traversal(self):
sanitized = sms.sanitize_flat_name("../../.ssh", "fallback-name")
self.assertEqual(sanitized, "fallback-name")
def test_find_skills_ignores_symlinks_outside_clone(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
skills_dir = root / "skills"
skills_dir.mkdir()
safe_skill = root / ".github" / "skills" / "safe-skill"
safe_skill.mkdir(parents=True)
(safe_skill / "SKILL.md").write_text("---\nname: safe-skill\n---\n", encoding="utf-8")
(skills_dir / "safe-skill").symlink_to(safe_skill, target_is_directory=True)
outside = Path(tempfile.mkdtemp())
try:
(outside / "SKILL.md").write_text("---\nname: leaked\n---\n", encoding="utf-8")
(skills_dir / "escape").symlink_to(outside, target_is_directory=True)
entries = sms.find_skills_in_directory(root)
relative_paths = {str(entry["relative_path"]) for entry in entries}
self.assertEqual(relative_paths, {"safe-skill"})
finally:
for child in outside.iterdir():
child.unlink()
outside.rmdir()
if __name__ == "__main__":
unittest.main()