Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

Bidirectional schema converter extension for VS Code — convert between SQL DDL, Prisma, Django, TypeORM, SQLAlchemy, Alembic, JSON Schema, GraphQL, EF Core, and Scala case classes.

Part of the [DevForge](https://devforge.revenueholdings.dev) open-source CLI tool suite by [Revenue Holdings](https://revenueholdings.dev).

## Features

- **Live Preview** — side panel showing your active schema file converted to all 11 formats
Expand Down
34 changes: 29 additions & 5 deletions src/panels/previewPanel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import * as vscode from 'vscode';
import { execSchemaForge } from '../cli';

/**
* Generate a random nonce so the webview's Content-Security-Policy can
* whitelist only our own inline <script>, blocking any injected markup
* from executing.
*/
function getNonce(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let text = '';
for (let i = 0; i < 32; i++) {
text += chars.charAt(Math.floor(Math.random() * chars.length));
}
return text;
}

/**
* WebView panel for live schema preview.
* Shows conversions of the active schema file to all other formats.
Expand Down Expand Up @@ -117,6 +131,7 @@ export class SchemaPreviewPanel {
detectDetails: string
): string {
const fileName = filePath.split(/[/\\]/).pop();
const nonce = getNonce();

const conversionTabs = conversions.map((c, i) => {
const active = i === 0 ? 'active' : '';
Expand All @@ -141,6 +156,7 @@ export class SchemaPreviewPanel {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${this.cspMeta(nonce)}
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 0; margin: 0; color: var(--vscode-editor-foreground); background: var(--vscode-editor-background); }
.header { padding: 8px 16px; background: var(--vscode-sideBar-background); border-bottom: 1px solid var(--vscode-panel-border); display: flex; align-items: center; gap: 12px; }
Expand All @@ -164,13 +180,13 @@ export class SchemaPreviewPanel {
<body>
<div class="header">
<h3>Schema Preview</h3>
<span class="source-badge">${sourceFormat}</span>
<span class="source-badge">${this.escapeHtml(sourceFormat)}</span>
<span class="file-path">${this.escapeHtml(fileName || '')}</span>
</div>
<div class="tabs">${tabButtons}</div>
<div class="tab-content">${conversionTabs}</div>
<div class="details">${this.escapeHtml(detectDetails.split('\\n').slice(0, 3).join('\\n'))}</div>
<script>
<div class="details">${this.escapeHtml(detectDetails.split('\n').slice(0, 3).join('\n'))}</div>
<script nonce="${nonce}">
(function() {
document.querySelectorAll('.tab-button').forEach(btn => {
btn.addEventListener('click', () => {
Expand All @@ -195,20 +211,28 @@ export class SchemaPreviewPanel {

private getErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html><body style="padding: 16px;">
<html><head>${this.cspMeta()}</head><body style="padding: 16px;">
<div class="error" style="color: var(--vscode-errorForeground);">
<p><strong>Error:</strong></p>
<pre>${this.escapeHtml(message)}</pre>
</div>
</body></html>`;
}

/** Build a strict Content-Security-Policy <meta> for this panel's webview. */
private cspMeta(nonce?: string): string {
const csp = this.panel?.webview.cspSource ?? '';
const script = nonce ? ` script-src 'nonce-${nonce}';` : '';
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${csp} 'unsafe-inline';${script}">`;
}

private escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

dispose() {
Expand Down
68 changes: 50 additions & 18 deletions src/providers/schemaEditorProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import * as vscode from 'vscode';
import { execSchemaForge } from '../cli';

/**
* Generate a random nonce so the webview's Content-Security-Policy can
* whitelist only our own inline <script>, blocking any injected markup
* from executing.
*/
function getNonce(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let text = '';
for (let i = 0; i < 32; i++) {
text += chars.charAt(Math.floor(Math.random() * chars.length));
}
return text;
}

/**
* Custom editor provider for .schemaforge files.
* Shows a rich preview of the schema and its conversions.
Expand All @@ -18,12 +32,12 @@ export class SchemaPreviewProvider implements vscode.CustomTextEditorProvider {
localResourceRoots: [this.extensionUri],
};

webviewPanel.webview.html = this.getLoadingHtml();
webviewPanel.webview.html = this.getLoadingHtml(webviewPanel.webview);

const render = async () => {
const content = document.getText();
if (!content.trim()) {
webviewPanel.webview.html = this.getEmptyHtml();
webviewPanel.webview.html = this.getEmptyHtml(webviewPanel.webview);
return;
}

Expand All @@ -47,9 +61,9 @@ export class SchemaPreviewProvider implements vscode.CustomTextEditorProvider {
}
}

webviewPanel.webview.html = this.getPreviewHtml(sourceFormat, conversions, document.fileName);
webviewPanel.webview.html = this.getPreviewHtml(sourceFormat, conversions, document.fileName, webviewPanel.webview);
} catch (e) {
webviewPanel.webview.html = this.getErrorHtml(e instanceof Error ? e.message : String(e));
webviewPanel.webview.html = this.getErrorHtml(e instanceof Error ? e.message : String(e), webviewPanel.webview);
}
};

Expand Down Expand Up @@ -80,31 +94,48 @@ export class SchemaPreviewProvider implements vscode.CustomTextEditorProvider {
return tmpFile;
}

private getLoadingHtml(): string {
/** Escape text for safe interpolation into webview HTML. */
private escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

/** Build a strict Content-Security-Policy <meta> for a preview webview. */
private cspMeta(webview: vscode.Webview, nonce?: string): string {
const script = nonce ? ` script-src 'nonce-${nonce}';` : '';
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline';${script}">`;
}

private getLoadingHtml(webview: vscode.Webview): string {
return `<!DOCTYPE html>
<html><body style="padding: 32px; text-align: center;"><p>Loading SchemaForge preview...</p></body></html>`;
<html><head>${this.cspMeta(webview)}</head><body style="padding: 32px; text-align: center;"><p>Loading SchemaForge preview...</p></body></html>`;
}

private getEmptyHtml(): string {
private getEmptyHtml(webview: vscode.Webview): string {
return `<!DOCTYPE html>
<html><body style="padding: 32px; text-align: center; color: var(--vscode-descriptionForeground);">
<html><head>${this.cspMeta(webview)}</head><body style="padding: 32px; text-align: center; color: var(--vscode-descriptionForeground);">
<p>Empty schema file. Add content to see format conversions.</p>
</body></html>`;
}

private getErrorHtml(message: string): string {
const escaped = message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
private getErrorHtml(message: string, webview: vscode.Webview): string {
return `<!DOCTYPE html>
<html><body style="padding: 16px;">
<div style="color: var(--vscode-errorForeground);"><strong>Error:</strong><pre>${escaped}</pre></div>
<html><head>${this.cspMeta(webview)}</head><body style="padding: 16px;">
<div style="color: var(--vscode-errorForeground);"><strong>Error:</strong><pre>${this.escapeHtml(message)}</pre></div>
</body></html>`;
}

private getPreviewHtml(
sourceFormat: string,
conversions: Array<{ format: string; result: string; error?: string }>,
fileName: string
fileName: string,
webview: vscode.Webview
): string {
const nonce = getNonce();
const tabButtons = conversions.map((c, i) => {
const active = i === 0 ? 'active' : '';
return `<button class="tab-btn ${active}" data-tab="fmt-${c.format}">${c.format}</button>`;
Expand All @@ -113,15 +144,16 @@ export class SchemaPreviewProvider implements vscode.CustomTextEditorProvider {
const tabPanes = conversions.map((c, i) => {
const active = i === 0 ? 'active' : '';
const content = c.error
? `<div class="err-block">${c.error.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>`
: `<pre><code>${c.result.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre>`;
? `<div class="err-block">${this.escapeHtml(c.error)}</div>`
: `<pre><code>${this.escapeHtml(c.result)}</code></pre>`;
return `<div class="tab-pane ${active}" id="fmt-${c.format}">${content}</div>`;
}).join('\n');

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
${this.cspMeta(webview, nonce)}
<style>
body { font-family: -apple-system, sans-serif; margin: 0; padding: 0; color: var(--vscode-editor-foreground); background: var(--vscode-editor-background); }
.header { padding: 8px 16px; background: var(--vscode-sideBar-background); border-bottom: 1px solid var(--vscode-panel-border); display: flex; align-items: center; gap: 12px; }
Expand All @@ -141,12 +173,12 @@ code { font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace; }
<body>
<div class="header">
<h2>SchemaForge</h2>
<span class="badge">${sourceFormat}</span>
<span class="fname">${fileName.replace(/&/g, '&amp;')}</span>
<span class="badge">${this.escapeHtml(sourceFormat)}</span>
<span class="fname">${this.escapeHtml(fileName)}</span>
</div>
<div class="tabs">${tabButtons}</div>
<div class="content">${tabPanes}</div>
<script>
<script nonce="${nonce}">
(function() {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
Expand Down
47 changes: 47 additions & 0 deletions tests/smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,50 @@ test("smoke: required repo files present", () => {
assert.ok(fs.existsSync(path.join(root, f)), `${f} must exist`);
}
});

// --- Webview hardening regression guards (network-free, source-level) --------
// These lock in the CSP + escaping + newline fixes made to the two schema
// preview webviews so a future edit cannot silently reopen the injection hole
// or reintroduce the split('\\n') truncation no-op.
const WEBVIEW_FILES = [
"src/panels/previewPanel.ts",
"src/providers/schemaEditorProvider.ts",
];

test("security: script-enabled webviews declare a CSP + nonce", () => {
const root = path.join(__dirname, "..");
for (const rel of WEBVIEW_FILES) {
const src = fs.readFileSync(path.join(root, rel), "utf-8");
if (!/enableScripts:\s*true/.test(src)) continue;
assert.match(src, /Content-Security-Policy/, `${rel} must set a CSP`);
assert.match(src, /getNonce\(\)/, `${rel} must generate a script nonce`);
assert.match(
src,
/<script nonce="\$\{nonce\}"/,
`${rel} inline script must carry the nonce`
);
}
});

test("security: detected source format is escaped in webviews", () => {
const root = path.join(__dirname, "..");
for (const rel of WEBVIEW_FILES) {
const src = fs.readFileSync(path.join(root, rel), "utf-8");
assert.doesNotMatch(
src,
/badge[^>]*>\$\{sourceFormat\}</,
`${rel} must escape sourceFormat before interpolating it`
);
}
});

test("correctness: preview detail truncation splits on real newlines", () => {
const src = fs.readFileSync(
path.join(__dirname, "..", "src/panels/previewPanel.ts"),
"utf-8"
);
assert.ok(
!src.includes("split('\\\\n')"),
"detectDetails must split on a real newline, not the literal '\\\\n'"
);
});
Loading