fix: harden filesystem trust boundaries
This commit is contained in:
53
tools/scripts/tests/copy_security.test.js
Normal file
53
tools/scripts/tests/copy_security.test.js
Normal 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);
|
||||
});
|
||||
37
tools/scripts/tests/skill_utils_security.test.js
Normal file
37
tools/scripts/tests/skill_utils_security.test.js
Normal 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",
|
||||
);
|
||||
});
|
||||
37
tools/scripts/tests/symlink_safety.test.js
Normal file
37
tools/scripts/tests/symlink_safety.test.js
Normal 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);
|
||||
});
|
||||
36
tools/scripts/tests/test_fix_skills_metadata_security.py
Normal file
36
tools/scripts/tests/test_fix_skills_metadata_security.py
Normal 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()
|
||||
53
tools/scripts/tests/test_generate_index_security.py
Normal file
53
tools/scripts/tests/test_generate_index_security.py
Normal 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()
|
||||
41
tools/scripts/tests/test_office_unpack_security.py
Normal file
41
tools/scripts/tests/test_office_unpack_security.py
Normal 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()
|
||||
33
tools/scripts/tests/test_skills_manager_security.py
Normal file
33
tools/scripts/tests/test_skills_manager_security.py
Normal 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()
|
||||
46
tools/scripts/tests/test_sync_microsoft_skills_security.py
Normal file
46
tools/scripts/tests/test_sync_microsoft_skills_security.py
Normal 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()
|
||||
Reference in New Issue
Block a user