Files
SpineViewer/tests/security-privacy.test.js
T
tianmo bb73cc9227 1.1.1版本审核修复
[spine-utils.js (line 35)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-utils.js:35) 统一拦截绝对路径、..、Windows 盘符、UNC、URI、控制字符和符号链接越界。
跨素材贴图只能使用已枚举、验证且位于 Eagle 素材目录内的文件。
[spine-render.js (line 62)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-render.js:62) 已删除未经验证的路径备用拼接。
自动缩略图只写当前回调提供的 dest,不再调用接口改写同组素材。
已删除后台整组补刷进程及 relatedDataPaths。
[viewer/spine.html (line 482)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/viewer/spine.html:482) 的手动缩略图只针对当前素材,并核对素材 ID、文件名和扩展名。
当前素材已有自定义缩略图时,会提示替换后无法自动恢复并要求确认。
手动生成的中间文件改存系统临时目录,API 失败时不再直接写 Eagle 素材目录。
八种语言文案及 README 已同步。
处理历史日志遗留问题
2026-09-17 13:38:22 +08:00

78 lines
3.8 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const utils = require('../js/spine-utils');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-security-'));
try {
for (const unsafe of [
'../../../secret.png',
'..\\..\\secret.png',
'/tmp/secret.png',
'C:\\secret.png',
'C:secret.png',
'\\\\server\\share\\secret.png',
'file:///tmp/secret.png',
'folder/../secret.png',
'folder\\..\\secret.png',
'bad\0name.png',
]) {
assert.throws(() => utils.validateAtlasPageName(unsafe), (error) => error && error.code === 'UNSAFE_ATLAS_PATH', unsafe);
}
assert.equal(utils.validateAtlasPageName('./textures/page.png'), 'textures/page.png');
const atlasDir = path.join(root, 'atlas');
fs.mkdirSync(path.join(atlasDir, 'textures'), { recursive: true });
const nestedTexture = path.join(atlasDir, 'textures', 'page.png');
fs.writeFileSync(nestedTexture, 'texture');
assert.equal(utils.resolveAtlasTextureFile(atlasDir, 'textures/page.png'), nestedTexture);
const outside = path.join(root, 'outside.png');
const link = path.join(atlasDir, 'linked.png');
fs.writeFileSync(outside, 'private');
try {
fs.symlinkSync(outside, link);
assert.throws(() => utils.resolveAtlasTextureFile(atlasDir, 'linked.png'), (error) => error && error.code === 'UNSAFE_ATLAS_PATH');
} catch (error) {
if (!['EPERM', 'EACCES'].includes(error.code)) throw error;
}
const library = path.join(root, 'safe.library');
const images = path.join(library, 'images');
fs.mkdirSync(images, { recursive: true });
fs.writeFileSync(path.join(library, 'metadata.json'), JSON.stringify({ folders: [{ id: 'spine', name: 'Spine', children: [] }] }));
function item(id, name, contents) {
const dir = path.join(images, id + '.info');
fs.mkdirSync(dir);
fs.writeFileSync(path.join(dir, 'metadata.json'), JSON.stringify({ id, name: path.parse(name).name, ext: path.extname(name).slice(1), folders: ['spine'], isDeleted: false }));
const file = path.join(dir, name);
fs.writeFileSync(file, contents);
return file;
}
const skeleton = item('SKELETON', 'hero.skel', Buffer.from('\0' + '3.8.1\0'));
item('ATLAS', 'hero.atlas', 'textures/hero.png\nsize: 64,64\n');
const texture = item('TEXTURE', 'hero.png', 'texture');
const assets = utils.discoverAssets(skeleton, { strictStem: true, refreshIndex: true });
assert.equal(assets.texturePaths['textures/hero.png'], texture, 'cross-item texture must come from a validated Eagle item');
const maliciousDir = path.join(root, 'malicious');
fs.mkdirSync(maliciousDir);
const maliciousSkeleton = path.join(maliciousDir, 'evil.skel');
fs.writeFileSync(maliciousSkeleton, Buffer.from('\0' + '3.8.1\0'));
fs.writeFileSync(path.join(maliciousDir, 'evil.atlas'), '../../../secret.png\nsize: 64,64\n');
assert.throws(() => utils.discoverAssets(maliciousSkeleton), (error) => error && error.code === 'UNSAFE_ATLAS_PATH');
const thumbnailSource = fs.readFileSync(path.join(__dirname, '../thumbnail/spine.js'), 'utf8');
assert.doesNotMatch(thumbnailSource, /setCustomThumbnail|refreshRelatedThumbnails|spine-reconcile-worker|relatedDataPaths/);
assert.equal(fs.existsSync(path.join(__dirname, '../js/spine-reconcile-worker.js')), false);
const viewerSource = fs.readFileSync(path.join(__dirname, '../viewer/spine.html'), 'utf8');
assert.match(viewerSource, /currentItem\.data\.customThumbnail === true && !window\.confirm/);
assert.match(viewerSource, /path\.join\(os\.tmpdir\(\), 'spineviewer-current-'/);
assert.doesNotMatch(viewerSource, /fs\.writeFileSync\(outPath/);
console.log('PASS: atlas traversal, absolute paths, symlink escape, validated Eagle pairing, and no automatic cross-item thumbnail writes');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}