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..44ea718f0 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -5,8 +5,8 @@ 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 { join } from 'node:path'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, realpathSync, symlinkSync } from 'node:fs'; +import { join, relative } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync, execSync, spawn } from 'node:child_process'; import { @@ -3319,6 +3319,102 @@ 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, '