Merge pull request #629 from pbakaus/fix/618-live-source-symlink

Fix: stop live-server /source from following symlinks out of the workspace (#618)
This commit is contained in:
Abdul Wahab
2026-08-24 04:29:43 +05:00
committed by GitHub
2 changed files with 113 additions and 9 deletions
+15 -7
View File
@@ -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);
+98 -2
View File
@@ -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, '<h1>via alias</h1>\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('/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);