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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Thumbs.db
/imports/
/notes/
/outputs/
/output/
/list/

# Generated feedback and handoff artifacts
Expand Down
115 changes: 112 additions & 3 deletions public/app-config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,112 @@
// Packaged desktop bootstrap. The local runtime replaces these values after
// discovery; no session token or user configuration is stored in this file.
window.SCHEMA_DOCS_API_BASE_URL = window.SCHEMA_DOCS_API_BASE_URL || "";
// Packaged desktop bootstrap. The real API token is exchanged through a
// one-time token emitted by the child runtime and is kept only inside this
// script's fetch-wrapper closure. It is never placed in a URL or global.
(() => {
const originalFetch = window.fetch.bind(window);
window.__SCHEMA_DOCS_ORIGINAL_FETCH__ = originalFetch;
window.AI_DOC_EXCHANGE_TOKEN = "desktop-bootstrap-pending";
window.SCHEMA_DOCS_API_BASE_URL = "http://127.0.0.1:4177";

const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const decodeBase64Url = (value) => {
const normalized = String(value).replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
return atob(padded);
};
const parseLatestBootstrapMarker = (tail = "") => {
const line = String(tail).split(/\r?\n/).reverse().find((item) => item.startsWith("SCHEMA_DOCS_BOOTSTRAP "));
if (!line) return null;
try {
return JSON.parse(decodeBase64Url(line.slice("SCHEMA_DOCS_BOOTSTRAP ".length).trim()));
} catch {
return null;
}
};
const tauriInvoke = (command, args = {}) => {
const invoke = window.__TAURI__?.core?.invoke || window.__TAURI_INTERNALS__?.invoke;
if (typeof invoke !== "function") throw new Error("Secure desktop runtime bridge is unavailable.");
return invoke(command, args);
};
const readFragmentBootstrapDescriptor = () => {
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const encoded = params.get("bootstrap");
if (!encoded) return null;
const descriptor = parseLatestBootstrapMarker(`SCHEMA_DOCS_BOOTSTRAP ${encoded}`);
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
return descriptor;
};
const readBootstrapDescriptor = async () => {
const fragmentDescriptor = readFragmentBootstrapDescriptor();
if (fragmentDescriptor?.baseUrl && fragmentDescriptor?.bootstrapToken) return fragmentDescriptor;
for (let attempt = 0; attempt < 40; attempt += 1) {
let diagnostics;
try {
diagnostics = await tauriInvoke("get_desktop_runtime_diagnostics");
} catch {
throw new Error("Open Schema Docs using the one-time pairing URL printed by the local server.");
}
const descriptor = parseLatestBootstrapMarker(diagnostics?.logs?.stderr?.tail || "")
|| parseLatestBootstrapMarker(diagnostics?.logs?.stdout?.tail || "");
if (descriptor?.baseUrl && descriptor?.bootstrapToken) return descriptor;
await delay(100);
}
throw new Error("Schema Docs secure runtime did not publish a bootstrap marker.");
};
const bootstrapRuntime = async () => {
const descriptor = await readBootstrapDescriptor();
const response = await originalFetch(`${descriptor.baseUrl}/bootstrap`, {
method: "POST",
headers: { "x-schema-docs-bootstrap-token": descriptor.bootstrapToken },
cache: "no-store"
});
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true || !payload?.data?.token || !payload?.data?.assetToken) {
throw new Error(payload?.error?.message || "Secure desktop bootstrap failed.");
}
const config = payload.data;
window.SCHEMA_DOCS_API_BASE_URL = config.apiBaseUrl;
window.AI_DOC_EXCHANGE_TOKEN = config.assetToken;
return config;
};
const runtimeConfig = bootstrapRuntime();

window.fetch = async (input, init = {}) => {
const rawUrl = input instanceof Request ? input.url : String(input);
let target;
try {
target = new URL(rawUrl, window.location.href);
} catch {
return originalFetch(input, init);
}
if (!["127.0.0.1", "localhost"].includes(target.hostname)) {
return originalFetch(input, init);
}
const config = await runtimeConfig;
const base = new URL(config.apiBaseUrl);
target.protocol = base.protocol;
target.hostname = base.hostname;
target.port = base.port;
if (target.pathname === "/app-config.js") {
const script = `window.SCHEMA_DOCS_API_BASE_URL=${JSON.stringify(config.apiBaseUrl)};window.AI_DOC_EXCHANGE_TOKEN=${JSON.stringify(config.assetToken)};`;
return new Response(script, {
status: 200,
headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }
});
}
const headers = new Headers(input instanceof Request ? input.headers : (init.headers || {}));
if (target.pathname === "/api/workspace-asset") {
target.searchParams.set("token", config.assetToken);
headers.delete("x-ai-doc-exchange-token");
} else if (target.pathname.startsWith("/api/") && target.pathname !== "/api/health") {
target.searchParams.delete("token");
headers.set("x-ai-doc-exchange-token", config.token);
}
if (input instanceof Request) {
const method = init.method || input.method;
let body = init.body;
if (body === undefined && !["GET", "HEAD"].includes(method.toUpperCase())) body = await input.clone().blob();
return originalFetch(target.toString(), { ...init, method, headers, body, signal: init.signal || input.signal });
}
return originalFetch(target.toString(), { ...init, headers });
};
})();
164 changes: 164 additions & 0 deletions security-tests/security-hardening.check.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, access, symlink, writeFile } from "node:fs/promises";
import { Readable } from "node:stream";
import os from "node:os";
import path from "node:path";
import { prepareSafeWritePath } from "../src/core/pathGuard.js";
import { listZipEntries, readZipEntry } from "../src/core/zip.js";
import { listenSecureLocalServer } from "../src/server/secureLocalServer.js";
import { buildZip } from "../test/helpers/zipBuilder.js";
import {
HttpSecurityError,
hasTrustedBrowserContext,
isSafeWorkspaceRelativePath,
isTrustedHostHeader,
parseJsonBody,
readBoundedBody,
trustedCorsOrigin,
validatePublicApiPayload
} from "../src/server/httpSecurity.js";

async function tempDir(prefix) { return mkdtemp(path.join(os.tmpdir(), prefix)); }

test("safe write preparation rejects traversal before creating directories", async () => {
const root = await tempDir("schema-path-root-");
const outside = path.join(path.dirname(root), `schema-outside-${Date.now()}`);
await assert.rejects(
prepareSafeWritePath(path.join(outside, "nested", "file.md"), root, [".md"]),
(error) => error.code === "write_outside_workspace"
);
await assert.rejects(access(path.join(outside, "nested")));
});

test("safe write preparation permits normal workspace output", async () => {
const root = await tempDir("schema-path-ok-");
const output = await prepareSafeWritePath(path.join(root, "exports", "safe.md"), root, [".md"]);
await writeFile(output, "safe", "utf8");
assert.equal(output, path.join(root, "exports", "safe.md"));
});

test("safe write preparation rejects a symlink parent escaping the workspace", async (t) => {
const root = await tempDir("schema-path-link-");
const outside = await tempDir("schema-path-link-out-");
const link = path.join(root, "linked");
try {
await symlink(outside, link, process.platform === "win32" ? "junction" : "dir");
} catch (error) {
t.skip(`symlinks unavailable: ${error.message}`);
return;
}
await assert.rejects(
prepareSafeWritePath(path.join(link, "escape.md"), root, [".md"]),
(error) => error.code === "write_outside_workspace"
);
});

test("ZIP parser reads a normal bounded entry", () => {
const archive = buildZip([{ name: "word/document.xml", content: "<document>Hello</document>" }]);
assert.equal(listZipEntries(archive).length, 1);
assert.equal(readZipEntry(archive, "word/document.xml").toString("utf8"), "<document>Hello</document>");
});

test("ZIP parser rejects suspicious compression ratios before inflate", () => {
const archive = buildZip([{ name: "word/document.xml", content: Buffer.alloc(2 * 1024 * 1024, 0x41) }]);
assert.throws(() => listZipEntries(archive), (error) => error.code === "zip_compression_ratio_exceeded");
});

test("ZIP parser enforces configurable entry and total expansion limits", () => {
const archive = buildZip([
{ name: "one.bin", content: Buffer.alloc(2048, 1) },
{ name: "two.bin", content: Buffer.alloc(2048, 2) }
]);
assert.throws(
() => listZipEntries(archive, { maxEntryUncompressedBytes: 1024, maxCompressionRatio: 10000 }),
(error) => error.code === "zip_entry_too_large"
);
assert.throws(
() => listZipEntries(archive, { maxEntryUncompressedBytes: 4096, maxTotalUncompressedBytes: 3000, maxCompressionRatio: 10000 }),
(error) => error.code === "zip_total_too_large"
);
});

test("HTTP security trusts only loopback browser contexts", () => {
const allowedOrigins = ["http://localhost:4177", "tauri://localhost"];
assert.equal(isTrustedHostHeader("127.0.0.1:4177"), true);
assert.equal(isTrustedHostHeader("evil.example"), false);
assert.equal(trustedCorsOrigin({ origin: "http://localhost:4177" }, allowedOrigins), "http://localhost:4177");
assert.equal(trustedCorsOrigin({ origin: "http://localhost:4999" }, allowedOrigins), "");
assert.equal(trustedCorsOrigin({ origin: "https://evil.example" }, allowedOrigins), "");
assert.equal(hasTrustedBrowserContext({ referer: "tauri://localhost/" }, allowedOrigins), true);
assert.equal(hasTrustedBrowserContext({ origin: "http://localhost:4999", "sec-fetch-site": "same-site" }, allowedOrigins), false);
});

test("secure local proxy pairs once and rejects a different localhost origin", async () => {
const server = await listenSecureLocalServer({ port: 0 });
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
const rejected = await fetch(`${baseUrl}/api/health`, {
method: "OPTIONS",
headers: { origin: "http://localhost:4999", "access-control-request-method": "GET" }
});
assert.equal(rejected.status, 403);

const accepted = await fetch(`${baseUrl}/api/health`, {
method: "OPTIONS",
headers: { origin: baseUrl, "access-control-request-method": "GET" }
});
assert.equal(accepted.status, 204);
assert.equal(accepted.headers.get("access-control-allow-origin"), baseUrl);

const token = server.bootstrapToken;
const paired = await fetch(`${baseUrl}/bootstrap`, {
method: "POST",
headers: { "x-schema-docs-bootstrap-token": token }
});
assert.equal(paired.status, 200);
const payload = await paired.json();
assert.equal(payload.ok, true);
assert.ok(payload.data.token);
assert.notEqual(server.bootstrapToken, token);

const replay = await fetch(`${baseUrl}/bootstrap`, {
method: "POST",
headers: { "x-schema-docs-bootstrap-token": token }
});
assert.equal(replay.status, 403);
} finally {
await new Promise((resolve) => server.close(resolve));
}
});

test("public API payload validation rejects absolute and traversal export paths", () => {
for (const outputRelativePath of ["/tmp/out.md", "C:\\temp\\out.md", "../out.md", "exports/../../out.md"]) {
assert.throws(
() => validatePublicApiPayload("/api/markdown/export", { outputRelativePath }),
(error) => error instanceof HttpSecurityError && error.code === "unsafe_output_path"
);
}
assert.equal(isSafeWorkspaceRelativePath("exports/out.md"), true);
assert.doesNotThrow(() => validatePublicApiPayload("/api/markdown/export", { outputRelativePath: "exports/out.md" }));
});

test("public API disables direct external Markdown routes", () => {
assert.throws(
() => validatePublicApiPayload("/api/markdown/save-external", { sourcePath: "/tmp/a.md" }),
(error) => error.status === 403 && error.code === "external_markdown_route_disabled"
);
});

test("bounded request reader rejects declared and streamed oversized bodies", async () => {
const declared = Readable.from([Buffer.from("{}")]);
declared.headers = { "content-length": "100" };
await assert.rejects(readBoundedBody(declared, 10), (error) => error.status === 413);

const streamed = Readable.from([Buffer.alloc(6), Buffer.alloc(6)]);
streamed.headers = {};
await assert.rejects(readBoundedBody(streamed, 10), (error) => error.status === 413);

const valid = Readable.from([Buffer.from('{"ok":true}')]);
valid.headers = {};
const parsed = parseJsonBody(await readBoundedBody(valid, 100));
assert.deepEqual(parsed, { ok: true });
});
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; media-src 'self' data: blob:"
"csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; media-src 'self' data: blob:"
}
},
"bundle": {
Expand Down
44 changes: 21 additions & 23 deletions src/cli/desktop-runtime-launcher.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { randomBytes } from "node:crypto";
import path from "node:path";
import { listenLocalServer } from "../server/localServer.js";
import { listenSecureLocalServer } from "../server/secureLocalServer.js";

const root = path.resolve(import.meta.dirname, "../..");
const sessionDir = process.env.SCHEMA_DOCS_RUNTIME_SESSION_DIR
Expand All @@ -18,30 +18,29 @@ const fetchBlockedPorts = new Set([
5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080
]);

function emitBootstrapMarker({ apiBaseUrl, bootstrapToken }) {
const encoded = Buffer.from(JSON.stringify({ baseUrl: apiBaseUrl, bootstrapToken }), "utf8").toString("base64url");
// Keep stdout machine-readable for existing runtime diagnostics and smoke
// checks. The desktop bridge reads the one-time bootstrap marker from the
// separately captured stderr tail.
console.error(`SCHEMA_DOCS_BOOTSTRAP ${encoded}`);
}

async function tryListen(port) {
try {
const server = await listenLocalServer({ port, host, token });
return {
port,
server
};
const server = await listenSecureLocalServer({ port, host, token, onBootstrapToken: emitBootstrapMarker });
return { port, server };
} catch (error) {
if (error.code !== "EADDRINUSE") {
throw error;
}
if (error.code !== "EADDRINUSE") throw error;
return null;
}
}

async function listenWithFallback(startPort) {
for (let port = startPort; port < startPort + 20; port += 1) {
if (fetchBlockedPorts.has(port)) {
continue;
}
if (fetchBlockedPorts.has(port)) continue;
const result = await tryListen(port);
if (result) {
return result;
}
if (result) return result;
}
throw new Error(`No available desktop runtime port from ${startPort} to ${startPort + 19}.`);
}
Expand All @@ -54,18 +53,17 @@ const session = {
baseUrl,
port,
host,
tokenSource: process.env.SCHEMA_DOCS_DESKTOP_TOKEN ? "env" : "generated"
pid: process.pid,
tokenSource: process.env.SCHEMA_DOCS_DESKTOP_TOKEN ? "env" : "generated",
transport: "secured-loopback-proxy+private-pipe"
};

let sessionPath = path.join(sessionDir, "session.json");
let sessionWriteError = "";
try {
await mkdir(sessionDir, { recursive: true });
await writeFile(
sessionPath,
JSON.stringify(session, null, 2) + "\n",
"utf8"
);
await writeFile(sessionPath, JSON.stringify(session, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
if (process.platform !== "win32") await chmod(sessionPath, 0o600);
} catch (error) {
sessionPath = "";
sessionWriteError = error.message;
Expand All @@ -76,7 +74,7 @@ console.log(JSON.stringify({
...session,
sessionPath,
sessionWriteError,
message: "Schema Docs desktop runtime is running. Keep this process alive while using the desktop shell."
message: "Schema Docs desktop runtime is running through a secured loopback proxy."
}, null, 2));

function shutdown() {
Expand Down
Loading
Loading