diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d2270..ae58bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Python package builds now require setuptools 83 or newer and wheel 0.47 or newer. ### Fixed +- Frontend content is now built with safe DOM APIs instead of `innerHTML`, preventing dataset-controlled column names from injecting markup through vector tooltips (#32). - `docker build` no longer fails with "the destination must be a directory and end with a /". `COPY backend/*.py .` needs a trailing slash when it copies more than one file. The classic builder rejected it, the BuildKit builder did not (#62). - CI publishes no image until the test job passes. The build job now depends on the test job, so a failing test stops the release (#66). - CI and release builds preserve the legacy LanceDB 0.3.1, 0.3.4, and 0.5 variants by reusing their last published dependency layers. Their packages are no longer available from PyPI (#92). diff --git a/backend/tests/test_frontend_security.py b/backend/tests/test_frontend_security.py new file mode 100644 index 0000000..434e1b4 --- /dev/null +++ b/backend/tests/test_frontend_security.py @@ -0,0 +1,9 @@ +from pathlib import Path + + +def test_frontend_does_not_use_inner_html(): + """Dataset-controlled values must never be parsed as HTML.""" + app_js = Path(__file__).parents[2] / "web" / "vanilla" / "app.js" + source = app_js.read_text() + + assert ".innerHTML" not in source diff --git a/web/vanilla/app.js b/web/vanilla/app.js index c547258..70edb12 100644 --- a/web/vanilla/app.js +++ b/web/vanilla/app.js @@ -80,6 +80,13 @@ class LanceViewer { }); } + replaceWithMessage(element, message, className) { + const messageElement = document.createElement('div'); + messageElement.className = className; + messageElement.textContent = message; + element.replaceChildren(messageElement); + } + async initializeConnection() { try { const response = await fetch(`${this.apiBase}/config`); @@ -93,8 +100,11 @@ class LanceViewer { this.elements.dataLocationField.style.display = 'none'; await this.loadDatasets(); } else { - this.elements.datasetList.innerHTML = - '
Enter a Lance dataset location above.
'; + this.replaceWithMessage( + this.elements.datasetList, + 'Enter a Lance dataset location above.', + 'loading' + ); this.elements.dataLocation.focus(); } } catch (error) { @@ -156,12 +166,19 @@ class LanceViewer { // Show Lance version prominently along with app version const lanceVersion = data.lancedb_version || 'unknown'; const pyarrowVersion = data.pyarrow_version || 'unknown'; - this.elements.healthStatus.innerHTML = ` -
-
Lance Data Viewer v${data.app_version}
-
LanceDB ${lanceVersion} • PyArrow ${pyarrowVersion}
-
- `; + const versionInfo = document.createElement('div'); + versionInfo.className = 'version-info'; + + const appVersion = document.createElement('div'); + appVersion.className = 'app-version'; + appVersion.textContent = `Lance Data Viewer v${data.app_version}`; + + const lanceVersionInfo = document.createElement('div'); + lanceVersionInfo.className = 'lance-version'; + lanceVersionInfo.textContent = `LanceDB ${lanceVersion} • PyArrow ${pyarrowVersion}`; + + versionInfo.append(appVersion, lanceVersionInfo); + this.elements.healthStatus.replaceChildren(versionInfo); this.elements.healthStatus.className = 'health-status healthy'; } else { throw new Error('Health check failed'); @@ -174,7 +191,7 @@ class LanceViewer { async loadDatasets() { try { - this.elements.datasetList.innerHTML = '
Loading datasets...
'; + this.replaceWithMessage(this.elements.datasetList, 'Loading datasets...', 'loading'); const params = this.contextParams(false); const suffix = params.toString() ? `?${params}` : ''; const response = await fetch(`${this.apiBase}/datasets${suffix}`); @@ -183,10 +200,10 @@ class LanceViewer { } const data = await response.json(); - this.elements.datasetList.innerHTML = ''; + this.elements.datasetList.replaceChildren(); if (data.datasets.length === 0) { - this.elements.datasetList.innerHTML = '
No datasets found
'; + this.replaceWithMessage(this.elements.datasetList, 'No datasets found', 'loading'); return; } @@ -198,7 +215,7 @@ class LanceViewer { this.elements.datasetList.appendChild(item); }); } catch (error) { - this.elements.datasetList.innerHTML = '
Failed to load datasets
'; + this.replaceWithMessage(this.elements.datasetList, 'Failed to load datasets', 'error'); this.showConnectionError(error.message); } } @@ -245,7 +262,7 @@ class LanceViewer { } renderSchema(fields) { - this.elements.schemaDisplay.innerHTML = ''; + this.elements.schemaDisplay.replaceChildren(); fields.forEach(field => { const fieldDiv = document.createElement('div'); const isVector = field.type.includes('list') || field.type.includes('fixed_size_list'); @@ -274,7 +291,7 @@ class LanceViewer { this.allColumns = columns; this.selectedColumns = columns.map(col => col.name); - this.elements.columnSelect.innerHTML = ''; + this.elements.columnSelect.replaceChildren(); columns.forEach(column => { const option = document.createElement('option'); option.value = column.name; @@ -349,13 +366,18 @@ class LanceViewer { renderTable(rows) { if (rows.length === 0) { - this.elements.tableBody.innerHTML = 'No data found'; + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = Math.max(this.selectedColumns.length, 1); + cell.textContent = 'No data found'; + row.appendChild(cell); + this.elements.tableBody.replaceChildren(row); return; } const columns = Object.keys(rows[0]); - this.elements.tableHead.innerHTML = ''; + this.elements.tableHead.replaceChildren(); const headerRow = document.createElement('tr'); columns.forEach(column => { const th = document.createElement('th'); @@ -364,7 +386,7 @@ class LanceViewer { }); this.elements.tableHead.appendChild(headerRow); - this.elements.tableBody.innerHTML = ''; + this.elements.tableBody.replaceChildren(); rows.forEach(row => { const tr = document.createElement('tr'); columns.forEach(column => { @@ -460,11 +482,19 @@ class LanceViewer { // Enhanced info display for CLIP vectors if (vectorData.model === 'likely_clip') { - info.innerHTML = ` - CLIP - dim: ${vectorData.dim} - norm: ${vectorData.norm.toFixed(3)} - `; + const model = document.createElement('span'); + model.className = 'vector-model'; + model.textContent = 'CLIP'; + + const dimension = document.createElement('span'); + dimension.className = 'vector-dim'; + dimension.textContent = `dim: ${vectorData.dim}`; + + const norm = document.createElement('span'); + norm.className = 'vector-norm'; + norm.textContent = `norm: ${vectorData.norm.toFixed(3)}`; + + info.append(model, dimension, norm); if (vectorData.stats && vectorData.stats.normalized) { info.classList.add('normalized'); } @@ -528,31 +558,38 @@ class LanceViewer { const tooltip = this.elements.tooltip; const content = tooltip.querySelector('.tooltip-content'); - let tooltipHtml = `${columnName}
`; + content.replaceChildren(); + + const title = document.createElement('strong'); + title.textContent = columnName; + content.append(title, document.createElement('br')); + + const appendLine = (text = '') => { + content.append(document.createTextNode(text), document.createElement('br')); + }; if (vectorData.model === 'likely_clip') { - tooltipHtml += ` - CLIP Embedding
- ${vectorData.description}

- Dimension: ${vectorData.dim}
- Norm: ${vectorData.norm.toFixed(4)} ${vectorData.stats.normalized ? '(normalized ✓)' : ''}
- Range: ${vectorData.min.toFixed(4)} to ${vectorData.max.toFixed(4)}
- Mean: ${vectorData.mean.toFixed(4)}
- Sparsity: ${(vectorData.stats.sparsity * 100).toFixed(1)}%
- Positive ratio: ${(vectorData.stats.positive_ratio * 100).toFixed(1)}%

- Preview: [${vectorData.preview.slice(0, 8).map(v => v.toFixed(3)).join(', ')}...] - `; + const badge = document.createElement('span'); + badge.className = 'model-badge'; + badge.textContent = 'CLIP Embedding'; + content.append(badge, document.createElement('br')); + appendLine(vectorData.description); + appendLine(); + appendLine(`Dimension: ${vectorData.dim}`); + appendLine(`Norm: ${vectorData.norm.toFixed(4)} ${vectorData.stats.normalized ? '(normalized ✓)' : ''}`); + appendLine(`Range: ${vectorData.min.toFixed(4)} to ${vectorData.max.toFixed(4)}`); + appendLine(`Mean: ${vectorData.mean.toFixed(4)}`); + appendLine(`Sparsity: ${(vectorData.stats.sparsity * 100).toFixed(1)}%`); + appendLine(`Positive ratio: ${(vectorData.stats.positive_ratio * 100).toFixed(1)}%`); + appendLine(); + appendLine(`Preview: [${vectorData.preview.slice(0, 8).map(v => v.toFixed(3)).join(', ')}...]`); } else { - tooltipHtml += ` - Dimension: ${vectorData.dim}
- Norm: ${vectorData.norm.toFixed(4)}
- Min: ${vectorData.min.toFixed(4)}
- Max: ${vectorData.max.toFixed(4)}
- Preview: [${vectorData.preview.slice(0, 8).map(v => v.toFixed(2)).join(', ')}...] - `; + appendLine(`Dimension: ${vectorData.dim}`); + appendLine(`Norm: ${vectorData.norm.toFixed(4)}`); + appendLine(`Min: ${vectorData.min.toFixed(4)}`); + appendLine(`Max: ${vectorData.max.toFixed(4)}`); + appendLine(`Preview: [${vectorData.preview.slice(0, 8).map(v => v.toFixed(2)).join(', ')}...]`); } - - content.innerHTML = tooltipHtml; tooltip.style.display = 'block'; this.updateTooltipPosition(event); } @@ -793,4 +830,4 @@ class LanceViewer { document.addEventListener('DOMContentLoaded', () => { new LanceViewer(); -}); \ No newline at end of file +});