From d008dd98c362341db6ec6b2143c4bca8f9a0b830 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Sat, 22 Aug 2026 05:19:36 +0500 Subject: [PATCH 1/2] Fix: stop live-server /source from following symlinks out of the workspace (#618) AI assistance: Cursor Grok 4.6 implemented this change. Co-authored-by: Cursor --- skill/scripts/live-server.mjs | 22 +++++++++++----- tests/live-server.test.mjs | 48 ++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 86b7777be..cba3a222e 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -936,15 +936,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const filePath = url.searchParams.get('path'); if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); - // Confine to the project root. A bare `startsWith(cwd)` string check lets a - // sibling dir whose name extends the root name (projeto -> projeto-backup) - // slip through; compare on the relative path instead (same pattern as - // sessionFileMetadataFromPollReply below). An empty rel means the request - // resolved to the root directory itself, which this file route never serves. - const rel = path.relative(process.cwd(), absPath); + let realRoot, realTarget; + try { + realRoot = fs.realpathSync(process.cwd()); + realTarget = fs.realpathSync(absPath); + } catch { + res.writeHead(404); res.end('File not found'); return; + } + // Confine to the project root after symlink resolution. A bare + // `startsWith(cwd)` string check lets a sibling dir whose name extends the + // root name (projeto -> projeto-backup) slip through; compare on the + // relative path instead (same pattern as sessionFileMetadataFromPollReply + // below). An empty rel means the request resolved to the root directory + // itself, which this file route never serves. + const rel = path.relative(realRoot, realTarget); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; } let content; - try { content = fs.readFileSync(absPath, 'utf-8'); } + try { content = fs.readFileSync(realTarget, 'utf-8'); } catch { res.writeHead(404); res.end('File not found'); return; } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(content); diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index c350c517d..52b559ade 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -5,7 +5,7 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, realpathSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, realpathSync, symlinkSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync, execSync, spawn } from 'node:child_process'; @@ -3319,6 +3319,52 @@ colors: {} } }); + it('/source rejects a symlink that points outside the project root', async () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'impeccable-live-outside-')); + const outsideFile = join(outsideDir, 'secret.txt'); + writeFileSync(outsideFile, 'OUTSIDE SECRET'); + const linkPath = join(serverCwd, 'linked.txt'); + symlinkSync(outsideFile, linkPath); + try { + const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=linked.txt`); + await res.text().catch(() => {}); + assert.equal(res.status, 403); + } finally { + rmSync(linkPath, { force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('/source serves a symlink whose target stays inside the project', async () => { + const nestedDir = join(serverCwd, 'alias'); + mkdirSync(nestedDir, { recursive: true }); + const realFile = join(nestedDir, 'page.html'); + writeFileSync(realFile, '

via alias

\n'); + const linkPath = join(serverCwd, 'alias-link.html'); + symlinkSync(realFile, linkPath); + try { + const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=alias-link.html`); + assert.equal(res.status, 200); + const text = await res.text(); + assert.ok(text.includes('via alias')); + } finally { + rmSync(linkPath, { force: true }); + rmSync(nestedDir, { recursive: true, force: true }); + } + }); + + it('/source returns 404 for a broken symlink', async () => { + const linkPath = join(serverCwd, 'broken-link.txt'); + symlinkSync(join(serverCwd, 'missing-target.txt'), linkPath); + try { + const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=broken-link.txt`); + await res.text().catch(() => {}); + assert.equal(res.status, 404); + } finally { + rmSync(linkPath, { force: true }); + } + }); + it('/modern-screenshot.js serves the vendored UMD build', async () => { const res = await fetch(`http://localhost:${server.port}/modern-screenshot.js`); assert.equal(res.status, 200); From 869c8873729f1e8ecf56a9be4b34455cf1574dab Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Sat, 22 Aug 2026 05:51:12 +0500 Subject: [PATCH 2/2] Test: cover directory, chained, and relative /source symlink escapes (#618) AI assistance: Cursor Grok 4.6 implemented this change. Co-authored-by: Cursor --- tests/live-server.test.mjs | 52 +++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index 52b559ade..44ea718f0 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -6,7 +6,7 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, realpathSync, symlinkSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync, execSync, spawn } from 'node:child_process'; import { @@ -3365,6 +3365,56 @@ colors: {} } }); + it('/source rejects a directory symlink whose nested file is outside the project', async () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'impeccable-live-outside-dir-')); + writeFileSync(join(outsideDir, 'cred.txt'), 'OUTSIDE SECRET'); + const linkPath = join(serverCwd, 'escape-dir'); + symlinkSync(outsideDir, linkPath); + try { + const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=escape-dir/cred.txt`); + await res.text().catch(() => {}); + assert.equal(res.status, 403); + } finally { + rmSync(linkPath, { force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('/source rejects a chained symlink that resolves outside the project', async () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'impeccable-live-outside-chain-')); + const outsideFile = join(outsideDir, 'secret.txt'); + writeFileSync(outsideFile, 'OUTSIDE SECRET'); + const midPath = join(serverCwd, 'mid-link.txt'); + const linkPath = join(serverCwd, 'double-out.txt'); + symlinkSync(outsideFile, midPath); + symlinkSync(midPath, linkPath); + try { + const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=double-out.txt`); + await res.text().catch(() => {}); + assert.equal(res.status, 403); + } finally { + rmSync(linkPath, { force: true }); + rmSync(midPath, { force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('/source rejects a relative symlink that points outside the project', async () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'impeccable-live-outside-rel-')); + const outsideFile = join(outsideDir, 'secret.txt'); + writeFileSync(outsideFile, 'OUTSIDE SECRET'); + const linkPath = join(serverCwd, 'rel-out.txt'); + symlinkSync(relative(serverCwd, outsideFile), linkPath); + try { + const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=rel-out.txt`); + await res.text().catch(() => {}); + assert.equal(res.status, 403); + } finally { + rmSync(linkPath, { force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } + }); + it('/modern-screenshot.js serves the vendored UMD build', async () => { const res = await fetch(`http://localhost:${server.port}/modern-screenshot.js`); assert.equal(res.status, 200);