mirror of
https://github.com/obra/superpowers.git
synced 2026-06-16 15:49:05 +08:00
Harden root screen containment
This commit is contained in:
@@ -186,8 +186,10 @@ function getNewestScreen() {
|
|||||||
.filter(f => !f.startsWith('.') && f.endsWith('.html'))
|
.filter(f => !f.startsWith('.') && f.endsWith('.html'))
|
||||||
.map(f => {
|
.map(f => {
|
||||||
const fp = path.join(CONTENT_DIR, f);
|
const fp = path.join(CONTENT_DIR, f);
|
||||||
|
if (!isRegularFileInsideContentDir(fp)) return null;
|
||||||
return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
|
return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
|
||||||
})
|
})
|
||||||
|
.filter(Boolean)
|
||||||
.sort((a, b) => b.mtime - a.mtime);
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
return files.length > 0 ? files[0].path : null;
|
return files.length > 0 ? files[0].path : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,43 @@ async function waitForServer(server) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SkipTest extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.skip = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip(message) {
|
||||||
|
throw new SkipTest(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverStartedMessage(out) {
|
||||||
|
const line = out.trim().split('\n').find(l => l.includes('server-started'));
|
||||||
|
assert(line, 'server-started JSON should be present');
|
||||||
|
return JSON.parse(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStartedOnExpectedPort(out) {
|
||||||
|
const msg = serverStartedMessage(out);
|
||||||
|
assert.strictEqual(
|
||||||
|
msg.port,
|
||||||
|
TEST_PORT,
|
||||||
|
`server.test.js expected fixed port ${TEST_PORT}, got ${msg.port}; fixed-port tests must not run through fallback`
|
||||||
|
);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSymlinkWorks(target, link) {
|
||||||
|
try {
|
||||||
|
fs.symlinkSync(target, link);
|
||||||
|
fs.unlinkSync(link);
|
||||||
|
} catch (e) {
|
||||||
|
try { fs.unlinkSync(link); } catch (ignore) {}
|
||||||
|
skip(`symlink creation unavailable on this host: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runTests() {
|
async function runTests() {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
@@ -81,14 +118,22 @@ async function runTests() {
|
|||||||
server.stdout.on('data', (data) => { stdoutAccum += data.toString(); });
|
server.stdout.on('data', (data) => { stdoutAccum += data.toString(); });
|
||||||
|
|
||||||
const { stdout: initialStdout } = await waitForServer(server);
|
const { stdout: initialStdout } = await waitForServer(server);
|
||||||
|
assertStartedOnExpectedPort(initialStdout);
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
function test(name, fn) {
|
function test(name, fn) {
|
||||||
return fn().then(() => {
|
return fn().then(() => {
|
||||||
console.log(` PASS: ${name}`);
|
console.log(` PASS: ${name}`);
|
||||||
passed++;
|
passed++;
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
|
if (e.skip) {
|
||||||
|
console.log(` SKIP: ${name}`);
|
||||||
|
console.log(` ${e.message}`);
|
||||||
|
skipped++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log(` FAIL: ${name}`);
|
console.log(` FAIL: ${name}`);
|
||||||
console.log(` ${e.message}`);
|
console.log(` ${e.message}`);
|
||||||
failed++;
|
failed++;
|
||||||
@@ -100,7 +145,7 @@ async function runTests() {
|
|||||||
console.log('\n--- Server Startup ---');
|
console.log('\n--- Server Startup ---');
|
||||||
|
|
||||||
await test('outputs server-started JSON on startup', () => {
|
await test('outputs server-started JSON on startup', () => {
|
||||||
const msg = JSON.parse(initialStdout.trim());
|
const msg = serverStartedMessage(initialStdout);
|
||||||
assert.strictEqual(msg.type, 'server-started');
|
assert.strictEqual(msg.type, 'server-started');
|
||||||
assert.strictEqual(msg.port, TEST_PORT);
|
assert.strictEqual(msg.port, TEST_PORT);
|
||||||
assert(msg.url, 'Should include URL');
|
assert(msg.url, 'Should include URL');
|
||||||
@@ -214,6 +259,7 @@ async function runTests() {
|
|||||||
const target = path.join(STATE_DIR, 'server-info');
|
const target = path.join(STATE_DIR, 'server-info');
|
||||||
const link = path.join(CONTENT_DIR, 'linked-server-info.txt');
|
const link = path.join(CONTENT_DIR, 'linked-server-info.txt');
|
||||||
try { fs.unlinkSync(link); } catch (e) {}
|
try { fs.unlinkSync(link); } catch (e) {}
|
||||||
|
ensureSymlinkWorks(target, link);
|
||||||
fs.symlinkSync(target, link);
|
fs.symlinkSync(target, link);
|
||||||
|
|
||||||
const res = await fetch(`http://localhost:${TEST_PORT}/files/linked-server-info.txt`);
|
const res = await fetch(`http://localhost:${TEST_PORT}/files/linked-server-info.txt`);
|
||||||
@@ -232,6 +278,45 @@ async function runTests() {
|
|||||||
assert(!res.body.includes('server-started'), 'response must not include server-info body');
|
assert(!res.body.includes('server-started'), 'response must not include server-info body');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await test('does not serve symlinks that escape content dir via root screen selection', async () => {
|
||||||
|
const target = path.join(STATE_DIR, 'server-info');
|
||||||
|
const link = path.join(CONTENT_DIR, 'root-linked-server-info.html');
|
||||||
|
try { fs.unlinkSync(link); } catch (e) {}
|
||||||
|
ensureSymlinkWorks(target, link);
|
||||||
|
fs.symlinkSync(target, link);
|
||||||
|
const future = new Date(Date.now() + 2000);
|
||||||
|
fs.utimesSync(target, future, future);
|
||||||
|
await sleep(300);
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:${TEST_PORT}/`);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert(!res.body.includes('"type":"server-started"'), 'root screen must not serve state/server-info through a symlink');
|
||||||
|
assert(!res.body.includes('"state_dir"'), 'root screen must not include server-info body');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('does not serve hard links that escape content dir via root screen selection', async () => {
|
||||||
|
const target = path.join(STATE_DIR, 'server-info');
|
||||||
|
const link = path.join(CONTENT_DIR, 'root-hard-linked-server-info.html');
|
||||||
|
try { fs.unlinkSync(link); } catch (e) {}
|
||||||
|
try {
|
||||||
|
fs.linkSync(target, link);
|
||||||
|
} catch (e) {
|
||||||
|
skip(`hardlink creation unavailable on this host: ${e.message}`);
|
||||||
|
}
|
||||||
|
const linkStat = fs.lstatSync(link);
|
||||||
|
if (linkStat.nlink <= 1) {
|
||||||
|
skip(`hardlink nlink did not expose multiple links: ${linkStat.nlink}`);
|
||||||
|
}
|
||||||
|
const future = new Date(Date.now() + 3000);
|
||||||
|
fs.utimesSync(target, future, future);
|
||||||
|
await sleep(300);
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:${TEST_PORT}/`);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert(!res.body.includes('"type":"server-started"'), 'root screen must not serve state/server-info through a hardlink');
|
||||||
|
assert(!res.body.includes('"state_dir"'), 'root screen must not include server-info body');
|
||||||
|
});
|
||||||
|
|
||||||
await test('returns 404 for non-root paths', async () => {
|
await test('returns 404 for non-root paths', async () => {
|
||||||
const res = await fetch(`http://localhost:${TEST_PORT}/other`);
|
const res = await fetch(`http://localhost:${TEST_PORT}/other`);
|
||||||
assert.strictEqual(res.status, 404);
|
assert.strictEqual(res.status, 404);
|
||||||
@@ -480,7 +565,7 @@ async function runTests() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ========== Summary ==========
|
// ========== Summary ==========
|
||||||
console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
|
console.log(`\n--- Results: ${passed} passed, ${failed} failed, ${skipped} skipped ---`);
|
||||||
if (failed > 0) process.exit(1);
|
if (failed > 0) process.exit(1);
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user