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
+});