Skip to content

Commit b2f5390

Browse files
dorlugasigalCopilot
andcommitted
test: add coverage for file endpoint security features
Add 18 tests for symlink rejection, query param validation, entry limit truncated flag, /file-raw endpoint, and generic error messages. Routes.js coverage: 86.3% → 91.5%. Also fix npm audit vulnerability (npm audit fix). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f55504f commit b2f5390

2 files changed

Lines changed: 306 additions & 3 deletions

File tree

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/server/routes.test.js

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1781,4 +1781,307 @@ describe('Routes', () => {
17811781
assert.ok(Array.isArray(data.dirs), 'dirs should be an array');
17821782
});
17831783
});
1784+
1785+
// === Query param type validation ===
1786+
describe('Query param type validation', () => {
1787+
let inst;
1788+
let tmpDir;
1789+
after(() => {
1790+
inst?.shutdown();
1791+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
1792+
});
1793+
1794+
async function setup() {
1795+
if (inst) return;
1796+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'tb-qparam-'));
1797+
fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'hello');
1798+
inst = await startServer({ cwd: tmpDir });
1799+
}
1800+
1801+
it('should return 400 when /files dir param is an array', async () => {
1802+
await setup();
1803+
const res = await httpRequest({
1804+
hostname: '127.0.0.1',
1805+
port: inst.port,
1806+
path: `/api/sessions/${inst.defaultId}/files?dir=a&dir=b`,
1807+
method: 'GET',
1808+
});
1809+
assert.strictEqual(res.statusCode, 400);
1810+
const data = JSON.parse(res.data);
1811+
assert.strictEqual(data.error, 'Invalid dir parameter');
1812+
});
1813+
1814+
it('should return 400 when /download file param is an array', async () => {
1815+
await setup();
1816+
const res = await httpRequest({
1817+
hostname: '127.0.0.1',
1818+
port: inst.port,
1819+
path: `/api/sessions/${inst.defaultId}/download?file=a&file=b`,
1820+
method: 'GET',
1821+
});
1822+
assert.strictEqual(res.statusCode, 400);
1823+
const data = JSON.parse(res.data);
1824+
assert.strictEqual(data.error, 'Missing file parameter');
1825+
});
1826+
1827+
it('should return 400 when /file-raw file param is an array', async () => {
1828+
await setup();
1829+
const res = await httpRequest({
1830+
hostname: '127.0.0.1',
1831+
port: inst.port,
1832+
path: `/api/sessions/${inst.defaultId}/file-raw?file=a&file=b`,
1833+
method: 'GET',
1834+
});
1835+
assert.strictEqual(res.statusCode, 400);
1836+
const data = JSON.parse(res.data);
1837+
assert.strictEqual(data.error, 'Missing file parameter');
1838+
});
1839+
1840+
it('should return 400 when /file-content file param is an array', async () => {
1841+
await setup();
1842+
const res = await httpRequest({
1843+
hostname: '127.0.0.1',
1844+
port: inst.port,
1845+
path: `/api/sessions/${inst.defaultId}/file-content?file=a&file=b`,
1846+
method: 'GET',
1847+
});
1848+
assert.strictEqual(res.statusCode, 400);
1849+
const data = JSON.parse(res.data);
1850+
assert.strictEqual(data.error, 'Missing file parameter');
1851+
});
1852+
});
1853+
1854+
// === Symlink rejection ===
1855+
describe('Symlink rejection', () => {
1856+
let inst;
1857+
let tmpDir;
1858+
after(() => {
1859+
inst?.shutdown();
1860+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
1861+
});
1862+
1863+
async function setup() {
1864+
if (inst) return;
1865+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'tb-symlink-'));
1866+
fs.writeFileSync(path.join(tmpDir, 'real.txt'), 'real content');
1867+
fs.symlinkSync(path.join(tmpDir, 'real.txt'), path.join(tmpDir, 'link.txt'));
1868+
inst = await startServer({ cwd: tmpDir });
1869+
}
1870+
1871+
it('should reject symlink in /download with 403', async () => {
1872+
await setup();
1873+
const res = await httpRequest({
1874+
hostname: '127.0.0.1',
1875+
port: inst.port,
1876+
path: `/api/sessions/${inst.defaultId}/download?file=link.txt`,
1877+
method: 'GET',
1878+
});
1879+
assert.strictEqual(res.statusCode, 403);
1880+
const data = JSON.parse(res.data);
1881+
assert.strictEqual(data.error, 'Symbolic links are not allowed');
1882+
});
1883+
1884+
it('should reject symlink in /file-raw with 403', async () => {
1885+
await setup();
1886+
const res = await httpRequest({
1887+
hostname: '127.0.0.1',
1888+
port: inst.port,
1889+
path: `/api/sessions/${inst.defaultId}/file-raw?file=link.txt`,
1890+
method: 'GET',
1891+
});
1892+
assert.strictEqual(res.statusCode, 403);
1893+
const data = JSON.parse(res.data);
1894+
assert.strictEqual(data.error, 'Symbolic links are not allowed');
1895+
});
1896+
1897+
it('should reject symlink in /file-content with 403', async () => {
1898+
await setup();
1899+
const res = await httpRequest({
1900+
hostname: '127.0.0.1',
1901+
port: inst.port,
1902+
path: `/api/sessions/${inst.defaultId}/file-content?file=link.txt`,
1903+
method: 'GET',
1904+
});
1905+
assert.strictEqual(res.statusCode, 403);
1906+
const data = JSON.parse(res.data);
1907+
assert.strictEqual(data.error, 'Symbolic links are not allowed');
1908+
});
1909+
1910+
it('should filter symlinks from /files listing', async () => {
1911+
await setup();
1912+
const res = await httpRequest({
1913+
hostname: '127.0.0.1',
1914+
port: inst.port,
1915+
path: `/api/sessions/${inst.defaultId}/files`,
1916+
method: 'GET',
1917+
});
1918+
assert.strictEqual(res.statusCode, 200);
1919+
const data = JSON.parse(res.data);
1920+
const names = data.entries.map((e) => e.name);
1921+
assert.ok(names.includes('real.txt'), 'should include real file');
1922+
assert.ok(!names.includes('link.txt'), 'should not include symlink');
1923+
});
1924+
});
1925+
1926+
// === Entry limit + truncated flag ===
1927+
describe('/files truncated flag', () => {
1928+
let inst;
1929+
let tmpDir;
1930+
after(() => {
1931+
inst?.shutdown();
1932+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
1933+
});
1934+
1935+
async function setup() {
1936+
if (inst) return;
1937+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'tb-trunc-'));
1938+
fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'a');
1939+
fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'b');
1940+
inst = await startServer({ cwd: tmpDir });
1941+
}
1942+
1943+
it('should include truncated: false for small directories', async () => {
1944+
await setup();
1945+
const res = await httpRequest({
1946+
hostname: '127.0.0.1',
1947+
port: inst.port,
1948+
path: `/api/sessions/${inst.defaultId}/files`,
1949+
method: 'GET',
1950+
});
1951+
assert.strictEqual(res.statusCode, 200);
1952+
const data = JSON.parse(res.data);
1953+
assert.ok('truncated' in data, 'Response should have a truncated field');
1954+
assert.strictEqual(data.truncated, false);
1955+
});
1956+
1957+
it('should include base and rootDir in /files response', async () => {
1958+
await setup();
1959+
const res = await httpRequest({
1960+
hostname: '127.0.0.1',
1961+
port: inst.port,
1962+
path: `/api/sessions/${inst.defaultId}/files`,
1963+
method: 'GET',
1964+
});
1965+
assert.strictEqual(res.statusCode, 200);
1966+
const data = JSON.parse(res.data);
1967+
assert.ok('base' in data, 'Response should have a base field');
1968+
assert.ok('rootDir' in data, 'Response should have a rootDir field');
1969+
assert.ok('entries' in data, 'Response should have entries field');
1970+
assert.ok('truncated' in data, 'Response should have truncated field');
1971+
});
1972+
});
1973+
1974+
// === /api/sessions/:id/file-raw endpoint ===
1975+
describe('GET /api/sessions/:id/file-raw', () => {
1976+
let inst;
1977+
let tmpDir;
1978+
after(() => {
1979+
inst?.shutdown();
1980+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
1981+
});
1982+
1983+
async function setup() {
1984+
if (inst) return;
1985+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'tb-fileraw-'));
1986+
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello raw');
1987+
fs.mkdirSync(path.join(tmpDir, 'adir'));
1988+
inst = await startServer({ cwd: tmpDir });
1989+
}
1990+
1991+
it('should return file content inline (no Content-Disposition: attachment)', async () => {
1992+
await setup();
1993+
const res = await httpRequest({
1994+
hostname: '127.0.0.1',
1995+
port: inst.port,
1996+
path: `/api/sessions/${inst.defaultId}/file-raw?file=hello.txt`,
1997+
method: 'GET',
1998+
});
1999+
assert.strictEqual(res.statusCode, 200);
2000+
assert.strictEqual(res.data, 'hello raw');
2001+
const cd = res.headers['content-disposition'];
2002+
assert.ok(!cd || !cd.includes('attachment'), 'should not have attachment disposition');
2003+
});
2004+
2005+
it('should return 404 for non-existent file', async () => {
2006+
await setup();
2007+
const res = await httpRequest({
2008+
hostname: '127.0.0.1',
2009+
port: inst.port,
2010+
path: `/api/sessions/${inst.defaultId}/file-raw?file=no-such-file.txt`,
2011+
method: 'GET',
2012+
});
2013+
assert.strictEqual(res.statusCode, 404);
2014+
const data = JSON.parse(res.data);
2015+
assert.strictEqual(data.error, 'File not found');
2016+
});
2017+
2018+
it('should return 400 for missing file parameter', async () => {
2019+
await setup();
2020+
const res = await httpRequest({
2021+
hostname: '127.0.0.1',
2022+
port: inst.port,
2023+
path: `/api/sessions/${inst.defaultId}/file-raw`,
2024+
method: 'GET',
2025+
});
2026+
assert.strictEqual(res.statusCode, 400);
2027+
const data = JSON.parse(res.data);
2028+
assert.strictEqual(data.error, 'Missing file parameter');
2029+
});
2030+
2031+
it('should return 400 for directory (not a file)', async () => {
2032+
await setup();
2033+
const res = await httpRequest({
2034+
hostname: '127.0.0.1',
2035+
port: inst.port,
2036+
path: `/api/sessions/${inst.defaultId}/file-raw?file=adir`,
2037+
method: 'GET',
2038+
});
2039+
assert.strictEqual(res.statusCode, 400);
2040+
const data = JSON.parse(res.data);
2041+
assert.strictEqual(data.error, 'Not a regular file');
2042+
});
2043+
2044+
it('should return 404 for invalid session id', async () => {
2045+
await setup();
2046+
const res = await httpRequest({
2047+
hostname: '127.0.0.1',
2048+
port: inst.port,
2049+
path: '/api/sessions/nonexistent/file-raw?file=hello.txt',
2050+
method: 'GET',
2051+
});
2052+
assert.strictEqual(res.statusCode, 404);
2053+
const data = JSON.parse(res.data);
2054+
assert.strictEqual(data.error, 'Session not found');
2055+
});
2056+
});
2057+
2058+
// === Generic error message for /files ===
2059+
describe('/files generic error message', () => {
2060+
let inst;
2061+
let tmpDir;
2062+
after(() => {
2063+
inst?.shutdown();
2064+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
2065+
});
2066+
2067+
async function setup() {
2068+
if (inst) return;
2069+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'tb-generr-'));
2070+
inst = await startServer({ cwd: tmpDir });
2071+
}
2072+
2073+
it('should return generic error for non-existent directory', async () => {
2074+
await setup();
2075+
const badDir = path.join(tmpDir, 'does-not-exist');
2076+
const res = await httpRequest({
2077+
hostname: '127.0.0.1',
2078+
port: inst.port,
2079+
path: `/api/sessions/${inst.defaultId}/files?dir=${encodeURIComponent(badDir)}`,
2080+
method: 'GET',
2081+
});
2082+
assert.strictEqual(res.statusCode, 500);
2083+
const data = JSON.parse(res.data);
2084+
assert.strictEqual(data.error, 'Failed to read directory');
2085+
});
2086+
});
17842087
});

0 commit comments

Comments
 (0)