Compare commits

..
Author SHA1 Message Date
Abdul WahabandCursor 869c887372 Test: cover directory, chained, and relative /source symlink escapes (#618)
AI assistance: Cursor Grok 4.6 implemented this change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 05:51:12 +05:00
Abdul WahabandCursor d008dd98c3 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 <cursoragent@cursor.com>
2026-08-22 05:19:36 +05:00
8 changed files with 124 additions and 111 deletions
+4 -4
View File
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
+5 -5
View File
@@ -3986,7 +3986,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7281,7 +7281,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7343,7 +7343,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7640,7 +7640,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -7770,7 +7770,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
+1 -1
View File
@@ -2752,7 +2752,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
+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);
@@ -221,19 +221,6 @@ describe('detectUrl — browser-only fixtures', () => {
assert.equal(contrast.length, 3, `expected exactly the 3 flag-column cases, got ${contrast.length}:\n${snippets}`);
});
it('ai-color-palette: oklch neon text flags the should-flag column only', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/oklch-neon-text.html`, { visualContrast: false });
const neon = f.filter(r =>
r.antipattern === 'ai-color-palette' && /neon text on dark background/i.test(r.snippet || '')
);
assert.equal(
neon.length,
1,
`expected exactly 1 oklch neon-text finding, got ${neon.length}: ${JSON.stringify(f.map(r => r.snippet))}`,
);
assert.match(neon[0].snippet || '', /Cyan neon text on dark background/i);
});
it('shadowed form.id: a <form> with <input name="id"> does not crash the scan (issue #407)', async () => {
// HTMLFormElement named-property shadowing makes form.id / form.className
// return the child input element, whose .startsWith throws. Every Shopify
-77
View File
@@ -1,77 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OKLCH Neon Text Fixture</title>
<style>
:root {
--neon: oklch(0.85 0.2 195);
--muted: oklch(0.85 0.04 195);
--paper: oklch(0.9 0 0);
--ground: #050505;
--light: #f5f5f5;
}
body {
margin: 0;
padding: 32px;
background: var(--ground);
font-family: system-ui, sans-serif;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
max-width: 980px;
margin: 0 auto;
}
.column {
display: grid;
gap: 14px;
}
.column > h2 {
margin: 0 0 2px;
color: var(--paper);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.4;
text-transform: uppercase;
}
p {
margin: 0;
font-size: 18px;
}
.neon-cyan { color: var(--neon); }
.muted-cyan { color: var(--muted); }
.oklch-paper { color: var(--paper); }
.light-shell {
background: var(--light);
padding: 12px;
}
</style>
</head>
<body>
<main class="grid">
<section class="column" data-col="flag">
<h2>Should flag</h2>
<p class="neon-cyan">Cyan neon token</p>
</section>
<section class="column" data-col="pass">
<h2>Should pass</h2>
<p class="oklch-paper">Achromatic oklch on dark should pass</p>
<p class="muted-cyan">Muted cyan oklch on dark should pass</p>
<div class="light-shell">
<p class="neon-cyan">Cyan oklch on light ground should pass</p>
</div>
</section>
</main>
</body>
</html>
+1 -2
View File
@@ -9,7 +9,6 @@
--paper: #f7f3ee;
--ink: #171717;
--muted: #566174;
--flag-white: oklch(1 0 0);
}
body {
@@ -111,7 +110,7 @@
<h2>Should flag after pixel sampling</h2>
<article class="image-card light-image">
<p style="color: var(--flag-white);">White text on light image should be sampled by pixel contrast.</p>
<p style="color: rgb(255, 255, 255);">White text on light image should be sampled by pixel contrast.</p>
</article>
<article class="image-card dark-image">
+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);