fix(sketch): robust path validation for token files with symlink protection

This commit is contained in:
RinZ27
2026-05-22 19:21:19 +07:00
parent 2b77e06489
commit ea3e44dd1f
2 changed files with 114 additions and 2 deletions
+9 -2
View File
@@ -65,8 +65,15 @@ function loadTokens(specTokensPath, cliTokensPath, specDir) {
let tokensPath = defaultTokensPath;
const isSafePath = (p, base) => {
const relative = path.relative(base, p);
return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
try {
const realP = fs.existsSync(p) ? fs.realpathSync(p) : path.resolve(p);
const realBase = fs.realpathSync(base);
const relative = path.relative(realBase, realP);
if (path.isAbsolute(relative)) return false;
return !relative.startsWith('..' + path.sep) && relative !== '..';
} catch (e) {
return false;
}
};
if (cliTokensPath) {
+105
View File
@@ -0,0 +1,105 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const CLI = path.join(ROOT, 'src', 'cli.js');
const OUTPUT_DIR = path.join(ROOT, 'output', 'security-test');
beforeAll(() => {
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
});
afterAll(() => {
if (fs.existsSync(OUTPUT_DIR)) {
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
}
});
describe('sketch-cli security', () => {
test('allows tokens in folders starting with dots (..brand)', () => {
const specDir = path.join(OUTPUT_DIR, 'spec');
const brandDir = path.join(specDir, '..brand');
fs.mkdirSync(brandDir, { recursive: true });
const tokensPath = path.join(brandDir, 'tokens.json');
const tokens = { colors: { primary: '#ff0000' } };
fs.writeFileSync(tokensPath, JSON.stringify(tokens));
const inputPath = path.join(specDir, 'design.json');
const design = {
pages: [{ do_objectID: 'page1', layers: [], artboards: [] }],
tokens: './..brand/tokens.json'
};
fs.writeFileSync(inputPath, JSON.stringify(design));
const outputPath = path.join(OUTPUT_DIR, 'out.sketch');
const result = execSync(
`node "${CLI}" build --input "${inputPath}" --output "${outputPath}" 2>&1`,
{ encoding: 'utf-8', cwd: ROOT }
);
expect(result).toContain('Done!');
expect(result).not.toContain('Unsafe spec tokens path ignored');
});
test('blocks tokens outside the spec directory', () => {
const specDir = path.join(OUTPUT_DIR, 'spec-unsafe');
fs.mkdirSync(specDir, { recursive: true });
const unsafeTokensPath = path.join(OUTPUT_DIR, 'unsafe-tokens.json');
const tokens = { colors: { primary: '#00ff00' } };
fs.writeFileSync(unsafeTokensPath, JSON.stringify(tokens));
const inputPath = path.join(specDir, 'design.json');
const design = {
pages: [{ do_objectID: 'page1', layers: [], artboards: [] }],
tokens: '../../unsafe-tokens.json'
};
fs.writeFileSync(inputPath, JSON.stringify(design));
const outputPath = path.join(OUTPUT_DIR, 'out-unsafe.sketch');
const result = execSync(
`node "${CLI}" build --input "${inputPath}" --output "${outputPath}" 2>&1`,
{ encoding: 'utf-8', cwd: ROOT }
);
expect(result).toContain('Unsafe spec tokens path ignored');
});
test('blocks symlinks pointing outside the spec directory', () => {
const specDir = path.join(OUTPUT_DIR, 'spec-symlink');
fs.mkdirSync(specDir, { recursive: true });
const secretPath = path.join(OUTPUT_DIR, 'secret.json');
fs.writeFileSync(secretPath, JSON.stringify({ secret: 'ssh-key' }));
const symlinkPath = path.join(specDir, 'malicious-link.json');
try {
fs.symlinkSync(secretPath, symlinkPath);
} catch (e) {
console.warn('Skipping symlink test: symlinks not supported/permitted');
return;
}
const inputPath = path.join(specDir, 'design.json');
const design = {
pages: [{ do_objectID: 'page1', layers: [], artboards: [] }],
tokens: './malicious-link.json'
};
fs.writeFileSync(inputPath, JSON.stringify(design));
const outputPath = path.join(OUTPUT_DIR, 'out-symlink.sketch');
const result = execSync(
`node "${CLI}" build --input "${inputPath}" --output "${outputPath}" 2>&1`,
{ encoding: 'utf-8', cwd: ROOT }
);
expect(result).toContain('Unsafe spec tokens path ignored');
});
});