diff --git a/.gitignore b/.gitignore
index 837227d..23edac1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ Thumbs.db
/imports/
/notes/
/outputs/
+/output/
/list/
# Generated feedback and handoff artifacts
diff --git a/public/app-config.js b/public/app-config.js
index 555a74d..27e00bf 100644
--- a/public/app-config.js
+++ b/public/app-config.js
@@ -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 });
+ };
+})();
diff --git a/security-tests/security-hardening.check.js b/security-tests/security-hardening.check.js
new file mode 100644
index 0000000..5de8c1a
--- /dev/null
+++ b/security-tests/security-hardening.check.js
@@ -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: "Hello" }]);
+ assert.equal(listZipEntries(archive).length, 1);
+ assert.equal(readZipEntry(archive, "word/document.xml").toString("utf8"), "Hello");
+});
+
+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 });
+});
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index fe6af99..f47df2a 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -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": {
diff --git a/src/cli/desktop-runtime-launcher.js b/src/cli/desktop-runtime-launcher.js
index a42e236..a61e223 100644
--- a/src/cli/desktop-runtime-launcher.js
+++ b/src/cli/desktop-runtime-launcher.js
@@ -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
@@ -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}.`);
}
@@ -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;
@@ -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() {
diff --git a/src/cli/desktop-workflow-smoke.js b/src/cli/desktop-workflow-smoke.js
index 45b5ce3..fa7e41f 100644
--- a/src/cli/desktop-workflow-smoke.js
+++ b/src/cli/desktop-workflow-smoke.js
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
+import { randomBytes } from "node:crypto";
import { createSchemaDocsLocalClient } from "../sdk/localApiClient.js";
import { KATEX_WOFF2_FONT_FILES } from "../core/katexRuntimeAssets.js";
@@ -86,17 +87,6 @@ async function discoverRuntime() {
return { ok: false, lastError, lastProbe };
}
-async function readAppConfig(baseUrl) {
- const response = await fetch(`${baseUrl}/app-config.js`);
- const script = await response.text();
- const token = script.match(/AI_DOC_EXCHANGE_TOKEN\s*=\s*"([^"]+)"/)?.[1];
- const apiBaseUrl = script.match(/SCHEMA_DOCS_API_BASE_URL\s*=\s*"([^"]+)"/)?.[1] ?? baseUrl;
- if (!response.ok || !token) {
- throw new Error("Desktop app config did not expose a local session token.");
- }
- return { token, apiBaseUrl };
-}
-
async function stopProcessTree(pid) {
if (!pid) return { attempted: false, method: "none" };
@@ -135,8 +125,8 @@ function isProcessRunning(pid) {
}
}
-async function runWorkflow(runtime) {
- const { token, apiBaseUrl } = await readAppConfig(runtime.baseUrl);
+async function runWorkflow(runtime, token) {
+ const apiBaseUrl = runtime.baseUrl;
const bootstrapClient = createSchemaDocsLocalClient({ baseUrl: apiBaseUrl, token });
const createdWorkspace = await bootstrapClient.createTempWorkspace();
const workspace = createdWorkspace.workspacePath;
@@ -273,11 +263,13 @@ if (!existsSync(appPath)) {
scanRange: `${host}:${startPort}-${endPort}`
});
} else {
+ const smokeToken = randomBytes(24).toString("hex");
const child = spawn(appPath, [], {
stdio: "ignore",
env: {
...process.env,
- SCHEMA_DOCS_DESKTOP_PORT: String(startPort)
+ SCHEMA_DOCS_DESKTOP_PORT: String(startPort),
+ SCHEMA_DOCS_DESKTOP_TOKEN: smokeToken
}
});
const appExit = {
@@ -296,7 +288,7 @@ if (!existsSync(appPath)) {
let workflowError = "";
if (runtime.ok) {
try {
- workflow = await runWorkflow(runtime);
+ workflow = await runWorkflow(runtime, smokeToken);
} catch (error) {
workflowError = error.message;
}
diff --git a/src/cli/release-check-part1.js b/src/cli/release-check-part1.js
index 8bbbce8..7fdeaec 100644
--- a/src/cli/release-check-part1.js
+++ b/src/cli/release-check-part1.js
@@ -72,10 +72,10 @@ export function getChecksPart1(context) {
ok: Boolean(
sizeCheckCli.includes("const BUDGETS")
&& sizeCheckCli.includes("runtimeDependencies: 0")
- && sizeCheckCli.includes("runtimeBytes: 1_160_000")
+ && sizeCheckCli.includes("runtimeBytes: 1_300_000")
&& sizeCheckCli.includes("sourceFiles: 195")
- && sizeCheckCli.includes("totalBytes: 1_700_000")
- && sizeCheckCli.includes("totalLines: 38_500")
+ && sizeCheckCli.includes("totalBytes: 1_850_000")
+ && sizeCheckCli.includes("totalLines: 42_000")
&& sizeCheckCli.includes("largestFileBytes: 100_000")
&& sizeCheckCli.includes("runtimeLargestFileBytes: 125_000")
&& sizeCheckCli.includes("publicModuleBytes: 100_000")
diff --git a/src/cli/release-check-part2.js b/src/cli/release-check-part2.js
index fb35f20..e33d0c1 100644
--- a/src/cli/release-check-part2.js
+++ b/src/cli/release-check-part2.js
@@ -283,7 +283,11 @@ export function getChecksPart2(context) {
ok: Boolean(
packageJson.scripts?.["desktop:workflow-smoke"] === "node src/cli/desktop-workflow-smoke.js"
&& desktopWorkflowSmoke.includes("createSchemaDocsLocalClient")
- && desktopWorkflowSmoke.includes("/app-config.js")
+ && desktopWorkflowSmoke.includes("randomBytes(24)")
+ && desktopWorkflowSmoke.includes("SCHEMA_DOCS_DESKTOP_TOKEN")
+ && desktopWorkflowSmoke.includes("runWorkflow(runtime, smokeToken)")
+ && !desktopWorkflowSmoke.includes("/app-config.js")
+ && !desktopWorkflowSmoke.includes("AI_DOC_EXCHANGE_TOKEN")
&& desktopWorkflowSmoke.includes("createTempWorkspace")
&& desktopWorkflowSmoke.includes("createSampleDocx")
&& desktopWorkflowSmoke.includes("sampleDocxOk")
diff --git a/src/cli/serve.js b/src/cli/serve.js
index 1f24475..75c6b8b 100644
--- a/src/cli/serve.js
+++ b/src/cli/serve.js
@@ -1,10 +1,23 @@
-import { listenLocalServer } from "../server/localServer.js";
+import { listenSecureLocalServer } from "../server/secureLocalServer.js";
+
+function pairingUrl({ apiBaseUrl, bootstrapToken }) {
+ const descriptor = Buffer.from(JSON.stringify({ baseUrl: apiBaseUrl, bootstrapToken }), "utf8").toString("base64url");
+ return `${apiBaseUrl}/#bootstrap=${descriptor}`;
+}
const portArg = Number(process.argv[2]);
const port = Number.isFinite(portArg) && portArg > 0 ? portArg : 4177;
-const server = await listenLocalServer({ port });
+const server = await listenSecureLocalServer({
+ port,
+ onBootstrapToken: (descriptor) => {
+ if (descriptor.previousToken) console.log(`Schema Docs refreshed pairing URL: ${pairingUrl(descriptor)}`);
+ }
+});
+const address = server.address();
+const actualPort = typeof address === "object" && address ? address.port : port;
-console.log(`Schema Docs AI intake UI: http://127.0.0.1:${port}`);
+const baseUrl = `http://127.0.0.1:${actualPort}`;
+console.log(`Schema Docs AI intake UI: ${pairingUrl({ apiBaseUrl: baseUrl, bootstrapToken: server.bootstrapToken })}`);
process.on("SIGINT", () => {
server.close(() => process.exit(0));
diff --git a/src/cli/size-check.js b/src/cli/size-check.js
index 54776ca..bc855c8 100644
--- a/src/cli/size-check.js
+++ b/src/cli/size-check.js
@@ -12,11 +12,11 @@ const IGNORED_SOURCE_PATTERNS = [
const BUDGETS = {
runtimeDependencies: 0,
devDependencies: 1,
- runtimeBytes: 1_160_000,
+ runtimeBytes: 1_300_000,
runtimeFiles: 100,
sourceFiles: 195,
- totalBytes: 1_700_000,
- totalLines: 38_500,
+ totalBytes: 1_850_000,
+ totalLines: 42_000,
largestFileBytes: 100_000,
runtimeLargestFileBytes: 125_000,
publicModuleBytes: 100_000
diff --git a/src/core/documentExports.js b/src/core/documentExports.js
index 7c3c568..0c8e271 100644
--- a/src/core/documentExports.js
+++ b/src/core/documentExports.js
@@ -1,8 +1,8 @@
import path from "node:path";
-import { access, mkdir, readFile, writeFile } from "node:fs/promises";
+import { access, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { readMarkdown } from "./markdown.js";
-import { assertSafeWritePath } from "./pathGuard.js";
+import { prepareSafeWritePath } from "./pathGuard.js";
import { normalizeDocumentFormat } from "./documentExchangeMatrix.js";
import { appendTimelineEvent } from "./timeline.js";
import { exportMarkdownToDocx, exportMarkdownToPdf, exportMarkdownToHtml, readSafeMarkdownImageAsset } from "./markdownExportPipeline.js";
@@ -31,7 +31,7 @@ return await exportMarkdownToPdf(cleaned, options);
if (format === "html") return Buffer.from(await exportMarkdownToHtml(cleaned, options), "utf8");
throw new Error(`Unhandled normalized document format: ${format}`);
}
-async function portableMarkdownAssets(markdown, sourceBaseDir, assetRoot, outputPath) {
+async function portableMarkdownAssets(markdown, sourceBaseDir, assetRoot, outputPath, workspacePath) {
if (!sourceBaseDir) return markdown;
const assetFolderName = `${path.parse(outputPath).name}.assets`;
const assetFolder = path.join(path.dirname(outputPath), assetFolderName);
@@ -41,9 +41,9 @@ for (const match of String(markdown || "").matchAll(imagePattern)) {
const target = String(match[2] || match[3] || "").trim();
const asset = await readSafeMarkdownImageAsset(target, sourceBaseDir, assetRoot || sourceBaseDir);
if (!asset) continue;
-await mkdir(assetFolder, { recursive: true });
const fileName = path.basename(asset.filePath);
-await writeFile(path.join(assetFolder, fileName), asset.data);
+const assetPath = await prepareSafeWritePath(path.join(assetFolder, fileName), workspacePath);
+await writeFile(assetPath, asset.data);
const portableTarget = `./${assetFolderName}/${encodeURI(fileName)}`;
replacements.push({ start: match.index, end: match.index + match[0].length, value: `${match[1]}<${portableTarget}>${match[4]}` });
}
@@ -76,19 +76,19 @@ return outputPath;
}
export async function writeRenderedDocument(workspacePath, markdown, outputRelativePath, format, options = {}) {
const normalizedFormat = normalizeDocumentFormat(format);
-const isAbs = path.isAbsolute(outputRelativePath);
-const outputPath = isAbs ? outputRelativePath : path.resolve(workspacePath, outputRelativePath);
-await mkdir(path.dirname(outputPath), { recursive: true });
-let safePath = outputPath;
-if (!isAbs) {
-safePath = await assertSafeWritePath(outputPath, workspacePath, [`.${normalizedFormat}`]);
+const outputPath = path.resolve(workspacePath, outputRelativePath);
+let safePath = await prepareSafeWritePath(outputPath, workspacePath, [`.${normalizedFormat}`]);
+if (options.avoidOverwrite) {
+safePath = await availableOutputPath(safePath);
+safePath = await prepareSafeWritePath(safePath, workspacePath, [`.${normalizedFormat}`]);
}
-if (options.avoidOverwrite) safePath = await availableOutputPath(safePath);
const exportMarkdown = projectPptxSlidesForExport(markdown);
const portableMarkdown = normalizedFormat === "md"
-? await portableMarkdownAssets(exportMarkdown, options.baseDir, options.assetRoot, safePath)
+? await portableMarkdownAssets(exportMarkdown, options.baseDir, options.assetRoot, safePath, workspacePath)
: exportMarkdown;
const renderedBuffer = await renderMarkdown(portableMarkdown, normalizedFormat, options);
+// Validate again immediately before the final write to reduce symlink-swap risk.
+safePath = await prepareSafeWritePath(safePath, workspacePath, [`.${normalizedFormat}`]);
await writeFile(safePath, renderedBuffer);
return safePath;
}
diff --git a/src/core/markdown.js b/src/core/markdown.js
index b89d751..7a5ac0d 100644
--- a/src/core/markdown.js
+++ b/src/core/markdown.js
@@ -1,6 +1,6 @@
import path from "node:path";
-import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
-import { assertInsideRoot, assertSafeWritePath } from "./pathGuard.js";
+import { readFile, writeFile, rm } from "node:fs/promises";
+import { assertInsideRoot, prepareSafeWritePath } from "./pathGuard.js";
function fixLegacyPdfNotice(content) {
const text = String(content ?? "");
if (
@@ -74,8 +74,7 @@ return relativePath === "outputs" || relativePath.startsWith("outputs/");
export async function saveMarkdown(workspacePath, relativePath, content) {
const isAbs = path.isAbsolute(relativePath);
const outputPath = isAbs ? relativePath : path.join(workspacePath, relativePath);
-await mkdir(path.dirname(outputPath), { recursive: true });
-const safePath = await assertSafeWritePath(outputPath, workspacePath, [".md"]);
+const safePath = await prepareSafeWritePath(outputPath, workspacePath, [".md"]);
await writeFile(safePath, content, "utf8");
return safePath;
}
diff --git a/src/core/pathGuard.js b/src/core/pathGuard.js
index 89abc0c..dbfc81f 100644
--- a/src/core/pathGuard.js
+++ b/src/core/pathGuard.js
@@ -1,77 +1,119 @@
import path from "node:path";
-import { realpath, lstat, access } from "node:fs/promises";
+import { realpath, lstat, access, mkdir } from "node:fs/promises";
import { constants } from "node:fs";
import { AppError } from "./errors.js";
-async function exists(p) {
-try {
-await access(p, constants.F_OK);
-return true;
-} catch {
-return false;
+
+async function exists(candidate) {
+ try {
+ await access(candidate, constants.F_OK);
+ return true;
+ } catch {
+ return false;
+ }
}
+
+async function findExistingAncestor(candidate) {
+ let current = path.resolve(candidate);
+ while (true) {
+ if (await exists(current)) return current;
+ const parent = path.dirname(current);
+ if (parent === current) return current;
+ current = parent;
+ }
}
-async function findExistingAncestor(p) {
-let current = path.resolve(p);
-while (true) {
-if (await exists(current)) {
-return current;
-}
-const parent = path.dirname(current);
-if (parent === current) return current;
-current = parent;
-}
+
+function writeOutsideWorkspace(candidate, root) {
+ return new AppError("write_outside_workspace", "Write path is outside the allowed workspace.", {
+ path: candidate,
+ root
+ });
}
+
export async function resolveExistingPath(inputPath) {
-try {
-return await realpath(inputPath);
-} catch {
-throw new AppError("path_not_found", `Path does not exist: ${inputPath}`, {
-path: inputPath
-});
-}
+ try {
+ return await realpath(inputPath);
+ } catch {
+ throw new AppError("path_not_found", `Path does not exist: ${inputPath}`, {
+ path: inputPath
+ });
+ }
}
+
export function isSubPath(candidatePath, rootPath) {
-const relative = path.relative(rootPath, candidatePath);
-return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
+ const relative = path.relative(rootPath, candidatePath);
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
}
+
export async function assertInsideRoot(candidatePath, rootPath) {
-const rootReal = await resolveExistingPath(rootPath);
-const candidateReal = await resolveExistingPath(candidatePath);
-if (!isSubPath(candidateReal, rootReal)) {
-throw new AppError("path_outside_workspace", "Path is outside the al...", {
-path: candidateReal,
-root: rootReal
-});
-}
-return candidateReal;
+ const rootReal = await resolveExistingPath(rootPath);
+ const candidateReal = await resolveExistingPath(candidatePath);
+ if (!isSubPath(candidateReal, rootReal)) {
+ throw new AppError("path_outside_workspace", "Path is outside the allowed workspace.", {
+ path: candidateReal,
+ root: rootReal
+ });
+ }
+ return candidateReal;
}
+
export async function assertSafeWritePath(candidatePath, rootPath, allowedExtensions = []) {
-const rootReal = await resolveExistingPath(rootPath);
-const absolute = path.resolve(candidatePath);
-const ancestor = await findExistingAncestor(path.dirname(absolute));
-const ancestorReal = await resolveExistingPath(ancestor);
-if (!isSubPath(ancestorReal, rootReal)) {
-throw new AppError("write_outside_workspace", "Write path is outside ...", {
-path: absolute,
-root: rootReal
-});
-}
-const extension = path.extname(absolute).toLowerCase();
-if (allowedExtensions.length > 0 && !allowedExtensions.includes(extension)) {
-throw new AppError("unsupported_output_extension", `Unsupported output extension: ${extension}`, {
-extension,
-allowedExtensions
-});
-}
-try {
-const stats = await lstat(absolute);
-if (stats.isSymbolicLink()) {
-throw new AppError("unsafe_symlink_write", "Refusing to write thro...", {
-path: absolute
-});
+ const rootAbsolute = path.resolve(rootPath);
+ const rootReal = await resolveExistingPath(rootAbsolute);
+ const absolute = path.resolve(candidatePath);
+
+ // Reject traversal before touching the filesystem. This also prevents mkdir
+ // side effects outside the workspace when the destination does not exist yet.
+ if (!isSubPath(absolute, rootAbsolute)) {
+ throw writeOutsideWorkspace(absolute, rootReal);
+ }
+
+ const ancestor = await findExistingAncestor(path.dirname(absolute));
+ const ancestorReal = await resolveExistingPath(ancestor);
+ if (!isSubPath(ancestorReal, rootReal)) {
+ throw writeOutsideWorkspace(absolute, rootReal);
+ }
+
+ const normalizedExtensions = allowedExtensions.map((extension) => String(extension).toLowerCase());
+ const extension = path.extname(absolute).toLowerCase();
+ if (normalizedExtensions.length > 0 && !normalizedExtensions.includes(extension)) {
+ throw new AppError("unsupported_output_extension", `Unsupported output extension: ${extension}`, {
+ extension,
+ allowedExtensions: normalizedExtensions
+ });
+ }
+
+ try {
+ const stats = await lstat(absolute);
+ if (stats.isSymbolicLink()) {
+ throw new AppError("unsafe_symlink_write", "Refusing to write through a symbolic link.", {
+ path: absolute
+ });
+ }
+ const targetReal = await resolveExistingPath(absolute);
+ if (!isSubPath(targetReal, rootReal)) {
+ throw writeOutsideWorkspace(absolute, rootReal);
+ }
+ } catch (error) {
+ if (error instanceof AppError) throw error;
+ if (error?.code !== "ENOENT") throw error;
+ }
+
+ return absolute;
}
-} catch (error) {
-if (error instanceof AppError) throw error;
+
+export async function prepareSafeWritePath(candidatePath, rootPath, allowedExtensions = []) {
+ let safePath = await assertSafeWritePath(candidatePath, rootPath, allowedExtensions);
+ await mkdir(path.dirname(safePath), { recursive: true });
+
+ // Re-check after directory creation to catch pre-existing or concurrently
+ // swapped symlink parents before the caller writes any bytes.
+ safePath = await assertSafeWritePath(safePath, rootPath, allowedExtensions);
+ const [parentReal, rootReal] = await Promise.all([
+ resolveExistingPath(path.dirname(safePath)),
+ resolveExistingPath(rootPath)
+ ]);
+ if (!isSubPath(parentReal, rootReal)) {
+ throw writeOutsideWorkspace(safePath, rootReal);
+ }
+ return safePath;
}
-return absolute;
-}
\ No newline at end of file
diff --git a/src/core/zip.js b/src/core/zip.js
index 843db32..1d71f67 100644
--- a/src/core/zip.js
+++ b/src/core/zip.js
@@ -4,25 +4,103 @@ import { AppError } from "./errors.js";
const EOCD_SIGNATURE = 0x06054b50;
const CENTRAL_DIR_SIGNATURE = 0x02014b50;
const LOCAL_FILE_SIGNATURE = 0x04034b50;
+const ZIP64_UINT16 = 0xffff;
+const ZIP64_UINT32 = 0xffffffff;
+const MIN_RATIO_CHECK_BYTES = 1024 * 1024;
+export const ZIP_SECURITY_LIMITS = Object.freeze({
+maxEntries: 10000,
+maxEntryUncompressedBytes: 128 * 1024 * 1024,
+maxTotalUncompressedBytes: 512 * 1024 * 1024,
+maxCompressionRatio: 500
+});
+function zipError(code, message, details = {}) {
+return new AppError(code, message, details);
+}
+function assertRange(buffer, offset, length, code, details = {}) {
+if (!Buffer.isBuffer(buffer) || offset < 0 || length < 0 || offset + length > buffer.length) {
+throw zipError(code, "ZIP structure points outside the archive.", {
+offset,
+length,
+archiveSize: buffer?.length ?? 0,
+...details
+});
+}
+}
function findEndOfCentralDirectory(buffer) {
+if (!Buffer.isBuffer(buffer) || buffer.length < 22) {
+throw zipError("zip_eocd_not_found", "Could not find ZIP end of central directory.");
+}
const minOffset = Math.max(0, buffer.length - 0xffff - 22);
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
-if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) {
-return offset;
+if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset;
+}
+throw zipError("zip_eocd_not_found", "Could not find ZIP end of central directory.");
}
+function normalizeLimits(overrides = {}) {
+return { ...ZIP_SECURITY_LIMITS, ...overrides };
}
-throw new AppError("zip_eocd_not_found", "Could not find ZIP end...");
+function assertEntryLimits(entry, limits, totalUncompressedBytes) {
+if (entry.uncompressedSize > limits.maxEntryUncompressedBytes) {
+throw zipError("zip_entry_too_large", `ZIP entry is too large after decompression: ${entry.fileName}`, {
+entryName: entry.fileName,
+uncompressedSize: entry.uncompressedSize,
+limit: limits.maxEntryUncompressedBytes
+});
}
-export function listZipEntries(buffer) {
+if (totalUncompressedBytes > limits.maxTotalUncompressedBytes) {
+throw zipError("zip_total_too_large", "ZIP archive expands beyond the allowed total size.", {
+totalUncompressedBytes,
+limit: limits.maxTotalUncompressedBytes
+});
+}
+if (entry.uncompressedSize >= MIN_RATIO_CHECK_BYTES) {
+const ratio = entry.compressedSize === 0 ? Number.POSITIVE_INFINITY : entry.uncompressedSize / entry.compressedSize;
+if (ratio > limits.maxCompressionRatio) {
+throw zipError("zip_compression_ratio_exceeded", `ZIP entry has a suspicious compression ratio: ${entry.fileName}`, {
+entryName: entry.fileName,
+compressedSize: entry.compressedSize,
+uncompressedSize: entry.uncompressedSize,
+ratio,
+limit: limits.maxCompressionRatio
+});
+}
+}
+}
+export function listZipEntries(buffer, limitOverrides = {}) {
+const limits = normalizeLimits(limitOverrides);
const eocdOffset = findEndOfCentralDirectory(buffer);
+assertRange(buffer, eocdOffset, 22, "zip_eocd_invalid");
+const diskNumber = buffer.readUInt16LE(eocdOffset + 4);
+const centralDisk = buffer.readUInt16LE(eocdOffset + 6);
+const entriesOnDisk = buffer.readUInt16LE(eocdOffset + 8);
const entryCount = buffer.readUInt16LE(eocdOffset + 10);
+const centralDirectorySize = buffer.readUInt32LE(eocdOffset + 12);
const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16);
+if ([entriesOnDisk, entryCount].includes(ZIP64_UINT16) || [centralDirectorySize, centralDirectoryOffset].includes(ZIP64_UINT32)) {
+throw zipError("zip64_unsupported", "ZIP64 archives are not accepted by the local safety parser.");
+}
+if (diskNumber !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount) {
+throw zipError("zip_multidisk_unsupported", "Multi-disk ZIP archives are not supported.");
+}
+if (entryCount > limits.maxEntries) {
+throw zipError("zip_too_many_entries", `ZIP archive contains too many entries: ${entryCount}`, {
+entryCount,
+limit: limits.maxEntries
+});
+}
+assertRange(buffer, centralDirectoryOffset, centralDirectorySize, "zip_central_directory_invalid");
+if (centralDirectoryOffset + centralDirectorySize > eocdOffset) {
+throw zipError("zip_central_directory_invalid", "ZIP central directory overlaps the end record.");
+}
const entries = [];
let offset = centralDirectoryOffset;
+let totalUncompressedBytes = 0;
for (let index = 0; index < entryCount; index += 1) {
+assertRange(buffer, offset, 46, "zip_central_directory_invalid", { index });
if (buffer.readUInt32LE(offset) !== CENTRAL_DIR_SIGNATURE) {
-throw new AppError("zip_central_directory_invalid", "Invalid ZIP central di...");
+throw zipError("zip_central_directory_invalid", "Invalid ZIP central directory signature.", { index, offset });
}
+const flags = buffer.readUInt16LE(offset + 8);
const compressionMethod = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const uncompressedSize = buffer.readUInt32LE(offset + 24);
@@ -30,41 +108,79 @@ const fileNameLength = buffer.readUInt16LE(offset + 28);
const extraFieldLength = buffer.readUInt16LE(offset + 30);
const fileCommentLength = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
+const recordLength = 46 + fileNameLength + extraFieldLength + fileCommentLength;
+assertRange(buffer, offset, recordLength, "zip_central_directory_invalid", { index });
+if ([compressedSize, uncompressedSize, localHeaderOffset].includes(ZIP64_UINT32)) {
+throw zipError("zip64_unsupported", "ZIP64 entries are not accepted by the local safety parser.", { index });
+}
+if ((flags & 0x1) !== 0) {
+throw zipError("zip_encryption_unsupported", "Encrypted ZIP entries are not supported.", { index });
+}
const fileName = buffer.toString("utf8", offset + 46, offset + 46 + fileNameLength);
-entries.push({
-fileName,
-compressionMethod,
-compressedSize,
-uncompressedSize,
-localHeaderOffset
+const entry = { fileName, flags, compressionMethod, compressedSize, uncompressedSize, localHeaderOffset };
+totalUncompressedBytes += uncompressedSize;
+if (!Number.isSafeInteger(totalUncompressedBytes)) {
+throw zipError("zip_total_too_large", "ZIP uncompressed size overflowed the safe integer range.");
+}
+assertEntryLimits(entry, limits, totalUncompressedBytes);
+assertRange(buffer, localHeaderOffset, 30, "zip_local_header_invalid", { entryName: fileName });
+entries.push(entry);
+offset += recordLength;
+}
+if (offset !== centralDirectoryOffset + centralDirectorySize) {
+throw zipError("zip_central_directory_invalid", "ZIP central directory size does not match parsed entries.", {
+expectedEnd: centralDirectoryOffset + centralDirectorySize,
+actualEnd: offset
});
-offset += 46 + fileNameLength + extraFieldLength + fileCommentLength;
}
return entries;
}
-export function readZipEntry(buffer, entryName) {
-const entry = listZipEntries(buffer).find((candidate) => candidate.fileName === entryName);
-if (!entry) throw new AppError("zip_entry_not_found", `ZIP entry not found: ${entryName}`, {
-entryName
-});
+export function readZipEntry(buffer, entryName, limitOverrides = {}) {
+const entry = listZipEntries(buffer, limitOverrides).find((candidate) => candidate.fileName === entryName);
+if (!entry) throw zipError("zip_entry_not_found", `ZIP entry not found: ${entryName}`, { entryName });
const offset = entry.localHeaderOffset;
+assertRange(buffer, offset, 30, "zip_local_header_invalid", { entryName });
if (buffer.readUInt32LE(offset) !== LOCAL_FILE_SIGNATURE) {
-throw new AppError("zip_local_header_invalid", "Invalid ZIP local file...", {
-entryName
-});
+throw zipError("zip_local_header_invalid", "Invalid ZIP local file header.", { entryName });
}
+const localFlags = buffer.readUInt16LE(offset + 6);
+const localMethod = buffer.readUInt16LE(offset + 8);
const fileNameLength = buffer.readUInt16LE(offset + 26);
const extraFieldLength = buffer.readUInt16LE(offset + 28);
+if ((localFlags & 0x1) !== 0 || localMethod !== entry.compressionMethod) {
+throw zipError("zip_local_header_invalid", "ZIP local header does not match the central directory.", { entryName });
+}
const dataStart = offset + 30 + fileNameLength + extraFieldLength;
+assertRange(buffer, dataStart, entry.compressedSize, "zip_entry_truncated", { entryName });
const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize);
-if (entry.compressionMethod === 0) return compressed;
-if (entry.compressionMethod === 8) return inflateRawSync(compressed);
-throw new AppError("zip_compression_unsupported", `Unsupported ZIP compression method: ${entry.compressionMethod}`, {
+let output;
+if (entry.compressionMethod === 0) {
+output = compressed;
+} else if (entry.compressionMethod === 8) {
+try {
+output = inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize + 1 });
+} catch (error) {
+throw zipError("zip_decompression_failed", `Failed to safely decompress ZIP entry: ${entryName}`, {
+entryName,
+originalError: error.message
+});
+}
+} else {
+throw zipError("zip_compression_unsupported", `Unsupported ZIP compression method: ${entry.compressionMethod}`, {
entryName,
compressionMethod: entry.compressionMethod
});
}
-export async function readZipEntryFromFile(filePath, entryName) {
+if (output.length !== entry.uncompressedSize) {
+throw zipError("zip_size_mismatch", `ZIP entry size does not match its central-directory metadata: ${entryName}`, {
+entryName,
+expected: entry.uncompressedSize,
+actual: output.length
+});
+}
+return output;
+}
+export async function readZipEntryFromFile(filePath, entryName, limitOverrides = {}) {
const buffer = await readFile(filePath);
-return readZipEntry(buffer, entryName);
-}
\ No newline at end of file
+return readZipEntry(buffer, entryName, limitOverrides);
+}
diff --git a/src/server/httpSecurity.js b/src/server/httpSecurity.js
new file mode 100644
index 0000000..d1f816c
--- /dev/null
+++ b/src/server/httpSecurity.js
@@ -0,0 +1,140 @@
+import path from "node:path";
+
+export const HTTP_SECURITY_LIMITS = Object.freeze({
+ jsonBytes: 8 * 1024 * 1024,
+ uploadBytes: 256 * 1024 * 1024
+});
+
+export class HttpSecurityError extends Error {
+ constructor(status, code, message, details = {}) {
+ super(message);
+ this.name = "HttpSecurityError";
+ this.status = status;
+ this.code = code;
+ this.details = details;
+ }
+}
+
+export function isTrustedLocalHostname(hostname = "") {
+ const normalized = String(hostname).replace(/^\[|\]$/g, "").toLowerCase();
+ return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1" || normalized === "tauri.localhost";
+}
+
+export function isTrustedHostHeader(value = "") {
+ if (!value) return false;
+ try {
+ return isTrustedLocalHostname(new URL(`http://${value}`).hostname);
+ } catch {
+ return false;
+ }
+}
+
+export function isTrustedLocalUrl(value = "") {
+ if (!value) return false;
+ try {
+ const parsed = new URL(value);
+ return ["http:", "https:", "tauri:"].includes(parsed.protocol) && isTrustedLocalHostname(parsed.hostname);
+ } catch {
+ return false;
+ }
+}
+
+function canonicalLocalOrigin(value = "") {
+ if (!isTrustedLocalUrl(value)) return "";
+ const parsed = new URL(value);
+ return `${parsed.protocol}//${parsed.host}`.toLowerCase();
+}
+
+export function trustedCorsOrigin(headers = {}, allowedOrigins = []) {
+ const origin = headers.origin;
+ const canonical = canonicalLocalOrigin(origin);
+ if (!canonical) return "";
+ const allowed = new Set(allowedOrigins.map(canonicalLocalOrigin).filter(Boolean));
+ return allowed.has(canonical) ? origin : "";
+}
+
+export function hasTrustedBrowserContext(headers = {}, allowedOrigins = []) {
+ const allowed = new Set(allowedOrigins.map(canonicalLocalOrigin).filter(Boolean));
+ return [headers.origin, headers.referer]
+ .map(canonicalLocalOrigin)
+ .some((origin) => origin && allowed.has(origin));
+}
+
+export function isAbsoluteLocalPath(value = "") {
+ const target = String(value).trim();
+ return path.isAbsolute(target) || /^[A-Za-z]:[\\/]/.test(target) || /^\\\\/.test(target) || /^\/[A-Za-z]:[\\/]/.test(target);
+}
+
+export function isSafeWorkspaceRelativePath(value = "") {
+ const target = String(value).trim();
+ if (!target || target.includes("\u0000") || isAbsoluteLocalPath(target) || /^[a-z][a-z0-9+.-]*:/i.test(target)) return false;
+ const normalized = target.replace(/\\/g, "/");
+ const parts = normalized.split("/");
+ return !parts.includes("..") && !normalized.startsWith("/");
+}
+
+const OUTPUT_PATH_ROUTES = new Set([
+ "/api/markdown/export",
+ "/api/document/export",
+ "/api/record/export-md"
+]);
+
+export function validatePublicApiPayload(route, body = {}) {
+ if (route === "/api/markdown/read-external" || route === "/api/markdown/save-external") {
+ throw new HttpSecurityError(403, "external_markdown_route_disabled", "Direct external Markdown access is disabled. Import the file into a workspace first.");
+ }
+ if (OUTPUT_PATH_ROUTES.has(route) || /^\/api\/export\/(?:md|docx|pdf|html)$/.test(route)) {
+ if (!isSafeWorkspaceRelativePath(body.outputRelativePath)) {
+ throw new HttpSecurityError(400, "unsafe_output_path", "Export outputRelativePath must be a workspace-relative path without traversal segments.", {
+ outputRelativePath: body.outputRelativePath
+ });
+ }
+ }
+ return body;
+}
+
+export async function readBoundedBody(request, maxBytes) {
+ const rawLength = request.headers?.["content-length"];
+ const declaredLength = rawLength === undefined ? null : Number(rawLength);
+ if (declaredLength !== null && (!Number.isFinite(declaredLength) || declaredLength < 0 || declaredLength > maxBytes)) {
+ throw new HttpSecurityError(413, "request_too_large", `Request body exceeds the ${maxBytes}-byte limit.`, {
+ declaredLength,
+ maxBytes
+ });
+ }
+ const chunks = [];
+ let total = 0;
+ for await (const chunk of request) {
+ total += chunk.length;
+ if (total > maxBytes) {
+ throw new HttpSecurityError(413, "request_too_large", `Request body exceeds the ${maxBytes}-byte limit.`, {
+ receivedBytes: total,
+ maxBytes
+ });
+ }
+ chunks.push(chunk);
+ }
+ return Buffer.concat(chunks, total);
+}
+
+export function parseJsonBody(buffer) {
+ if (!buffer.length) return {};
+ try {
+ return JSON.parse(buffer.toString("utf8"));
+ } catch (error) {
+ throw new HttpSecurityError(400, "invalid_json", "Request body is not valid JSON.", {
+ originalError: error.message
+ });
+ }
+}
+
+export function errorPayload(error) {
+ return {
+ ok: false,
+ error: {
+ code: error?.code || "proxy_error",
+ message: error?.message || String(error),
+ ...(error?.details && Object.keys(error.details).length ? { details: error.details } : {})
+ }
+ };
+}
diff --git a/src/server/secureLocalServer.js b/src/server/secureLocalServer.js
new file mode 100644
index 0000000..c341677
--- /dev/null
+++ b/src/server/secureLocalServer.js
@@ -0,0 +1,328 @@
+import http from "node:http";
+import os from "node:os";
+import path from "node:path";
+import { randomBytes } from "node:crypto";
+import { rm } from "node:fs/promises";
+import { createLocalServer } from "./localServer.js";
+import {
+ HTTP_SECURITY_LIMITS,
+ HttpSecurityError,
+ errorPayload,
+ hasTrustedBrowserContext,
+ isTrustedHostHeader,
+ parseJsonBody,
+ readBoundedBody,
+ trustedCorsOrigin,
+ validatePublicApiPayload
+} from "./httpSecurity.js";
+
+const API_TOKEN_HEADER = "x-ai-doc-exchange-token";
+const BOOTSTRAP_TOKEN_HEADER = "x-schema-docs-bootstrap-token";
+const ASSET_ROUTE = "/api/workspace-asset";
+const ALLOWED_REQUEST_HEADERS = [
+ "content-type",
+ API_TOKEN_HEADER,
+ BOOTSTRAP_TOKEN_HEADER,
+ "x-schema-docs-workspace-path",
+ "x-schema-docs-filename"
+].join(",");
+
+function randomToken(bytes = 24) {
+ return randomBytes(bytes).toString("hex");
+}
+
+function internalSocketPath() {
+ const suffix = `${process.pid}-${randomBytes(12).toString("hex")}`;
+ if (process.platform === "win32") return `\\\\.\\pipe\\schema-docs-${suffix}`;
+ return path.join(os.tmpdir(), `schema-docs-${suffix}.sock`);
+}
+
+function allowedBrowserOrigins(apiBaseUrl) {
+ return [apiBaseUrl, "tauri://localhost", "https://tauri.localhost", "http://tauri.localhost"];
+}
+
+function applyCorsHeaders(request, headers) {
+ const origin = trustedCorsOrigin(request.headers, request.schemaDocsAllowedOrigins || []);
+ if (origin) {
+ headers["access-control-allow-origin"] = origin;
+ headers.vary = "Origin";
+ }
+ return headers;
+}
+
+function applyCommonSecurityHeaders(headers, { staticAsset = false } = {}) {
+ headers["x-content-type-options"] = "nosniff";
+ headers["referrer-policy"] = "strict-origin-when-cross-origin";
+ if (staticAsset) {
+ headers["cross-origin-resource-policy"] = "same-origin";
+ headers["content-security-policy"] = "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:";
+ }
+ return headers;
+}
+
+function sendJson(request, response, statusCode, payload) {
+ const headers = applyCommonSecurityHeaders(applyCorsHeaders(request, {
+ "content-type": "application/json; charset=utf-8",
+ "cache-control": "no-store"
+ }));
+ response.writeHead(statusCode, headers);
+ response.end(JSON.stringify(payload, null, 2));
+}
+
+function canUseAssetRoute(request, url, assetToken) {
+ return url.searchParams.get("token") === assetToken
+ && isTrustedHostHeader(request.headers.host)
+ && hasTrustedBrowserContext(request.headers, request.schemaDocsAllowedOrigins || []);
+}
+
+function filteredUpstreamHeaders(requestHeaders, bodyLength = null) {
+ const headers = {
+ "x-ai-doc-exchange-token": requestHeaders["x-ai-doc-exchange-token"] || "",
+ "content-type": requestHeaders["content-type"] || "application/json",
+ "x-schema-docs-workspace-path": requestHeaders["x-schema-docs-workspace-path"] || "",
+ "x-schema-docs-filename": requestHeaders["x-schema-docs-filename"] || ""
+ };
+ for (const key of Object.keys(headers)) {
+ if (!headers[key]) delete headers[key];
+ }
+ if (bodyLength !== null) headers["content-length"] = String(bodyLength);
+ return headers;
+}
+
+function proxyResponseHeaders(request, upstreamHeaders, pathname) {
+ const headers = {};
+ for (const name of ["content-type", "content-length", "cache-control", "etag", "last-modified"]) {
+ const value = upstreamHeaders[name];
+ if (value !== undefined) headers[name] = value;
+ }
+ applyCorsHeaders(request, headers);
+ applyCommonSecurityHeaders(headers, { staticAsset: !pathname.startsWith("/api/") && pathname !== "/bootstrap" });
+ return headers;
+}
+
+function proxyBuffered({ request, response, socketPath, internalToken, url, body }) {
+ return new Promise((resolve) => {
+ const upstreamUrl = new URL(url.toString());
+ upstreamUrl.searchParams.delete("token");
+ const upstream = http.request({
+ socketPath,
+ method: request.method,
+ path: `${upstreamUrl.pathname}${upstreamUrl.search}`,
+ headers: {
+ ...filteredUpstreamHeaders(request.headers, body?.length ?? 0),
+ [API_TOKEN_HEADER]: internalToken,
+ host: "schema-docs-internal"
+ }
+ }, (upstreamResponse) => {
+ response.writeHead(upstreamResponse.statusCode || 502, proxyResponseHeaders(request, upstreamResponse.headers, url.pathname));
+ upstreamResponse.pipe(response);
+ upstreamResponse.on("end", resolve);
+ });
+ upstream.on("error", (error) => {
+ if (!response.headersSent) sendJson(request, response, 502, errorPayload(new HttpSecurityError(502, "internal_api_unavailable", error.message)));
+ else response.destroy(error);
+ resolve();
+ });
+ if (body?.length) upstream.write(body);
+ upstream.end();
+ });
+}
+
+function proxyUpload({ request, response, socketPath, internalToken, url, maxBytes }) {
+ return new Promise((resolve) => {
+ const declared = Number(request.headers["content-length"] || 0);
+ if (declared > maxBytes) {
+ sendJson(request, response, 413, errorPayload(new HttpSecurityError(413, "request_too_large", `Upload exceeds the ${maxBytes}-byte limit.`)));
+ request.resume();
+ resolve();
+ return;
+ }
+ const upstreamUrl = new URL(url.toString());
+ upstreamUrl.searchParams.delete("token");
+ const upstream = http.request({
+ socketPath,
+ method: request.method,
+ path: `${upstreamUrl.pathname}${upstreamUrl.search}`,
+ headers: {
+ ...filteredUpstreamHeaders(request.headers, declared || null),
+ [API_TOKEN_HEADER]: internalToken,
+ host: "schema-docs-internal"
+ }
+ }, (upstreamResponse) => {
+ response.writeHead(upstreamResponse.statusCode || 502, proxyResponseHeaders(request, upstreamResponse.headers, url.pathname));
+ upstreamResponse.pipe(response);
+ upstreamResponse.on("end", resolve);
+ });
+ let total = 0;
+ let aborted = false;
+ const failLarge = () => {
+ if (aborted) return;
+ aborted = true;
+ upstream.destroy();
+ if (!response.headersSent) sendJson(request, response, 413, errorPayload(new HttpSecurityError(413, "request_too_large", `Upload exceeds the ${maxBytes}-byte limit.`)));
+ request.resume();
+ resolve();
+ };
+ request.on("data", (chunk) => {
+ total += chunk.length;
+ if (total > maxBytes) {
+ failLarge();
+ return;
+ }
+ if (!aborted && !upstream.write(chunk)) request.pause();
+ });
+ upstream.on("drain", () => request.resume());
+ request.on("end", () => {
+ if (!aborted) upstream.end();
+ });
+ request.on("error", (error) => {
+ aborted = true;
+ upstream.destroy(error);
+ resolve();
+ });
+ upstream.on("error", (error) => {
+ if (!aborted && !response.headersSent) sendJson(request, response, 502, errorPayload(new HttpSecurityError(502, "internal_api_unavailable", error.message)));
+ aborted = true;
+ resolve();
+ });
+ });
+}
+
+async function listenServer(server, listenTarget) {
+ return new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(listenTarget, () => {
+ server.off("error", reject);
+ resolve(server);
+ });
+ });
+}
+
+export async function listenSecureLocalServer(options = {}) {
+ const host = options.host ?? "127.0.0.1";
+ const port = options.port ?? 4177;
+ const publicToken = options.token ?? randomToken();
+ const assetToken = options.assetToken ?? randomToken(18);
+ let bootstrapToken = options.bootstrapToken ?? randomToken();
+ const socketPath = internalSocketPath();
+ const internalToken = randomToken();
+ const internalServer = createLocalServer({
+ token: internalToken,
+ requireToken: true,
+ host: "127.0.0.1",
+ port: 0,
+ apiBaseUrl: "http://schema-docs-internal"
+ });
+ await listenServer(internalServer, socketPath);
+
+ let apiBaseUrl = options.apiBaseUrl || `http://${host}:${port}`;
+ const publicServer = http.createServer(async (request, response) => {
+ try {
+ request.schemaDocsAllowedOrigins = allowedBrowserOrigins(apiBaseUrl);
+ if (!isTrustedHostHeader(request.headers.host)) {
+ sendJson(request, response, 403, errorPayload(new HttpSecurityError(403, "untrusted_host", "Only loopback Host headers are accepted.")));
+ return;
+ }
+ const url = new URL(request.url || "/", apiBaseUrl);
+ const origin = request.headers.origin;
+ if (origin && !trustedCorsOrigin(request.headers, request.schemaDocsAllowedOrigins)) {
+ sendJson(request, response, 403, errorPayload(new HttpSecurityError(403, "untrusted_origin", "Cross-origin requests from non-local origins are blocked.")));
+ return;
+ }
+ if (request.method === "OPTIONS") {
+ if (!trustedCorsOrigin(request.headers, request.schemaDocsAllowedOrigins)) {
+ sendJson(request, response, 403, errorPayload(new HttpSecurityError(403, "untrusted_origin", "CORS preflight origin is not trusted.")));
+ return;
+ }
+ response.writeHead(204, applyCommonSecurityHeaders(applyCorsHeaders(request, {
+ "access-control-allow-methods": "GET,POST,OPTIONS",
+ "access-control-allow-headers": ALLOWED_REQUEST_HEADERS,
+ "access-control-max-age": "600",
+ "cache-control": "no-store"
+ })));
+ response.end();
+ return;
+ }
+ if (url.pathname === "/bootstrap") {
+ if (request.method !== "POST" || request.headers[BOOTSTRAP_TOKEN_HEADER] !== bootstrapToken) {
+ sendJson(request, response, 403, errorPayload(new HttpSecurityError(403, "invalid_bootstrap_token", "Invalid desktop bootstrap token.")));
+ return;
+ }
+ const previousToken = bootstrapToken;
+ bootstrapToken = randomToken();
+ sendJson(request, response, 200, {
+ ok: true,
+ data: { apiBaseUrl, token: publicToken, assetToken }
+ });
+ options.onBootstrapToken?.({ apiBaseUrl, bootstrapToken, previousToken });
+ return;
+ }
+ if (url.pathname.startsWith("/api/")) {
+ const isHealth = url.pathname === "/api/health";
+ const isAsset = url.pathname === ASSET_ROUTE;
+ if (isAsset) {
+ if (!canUseAssetRoute(request, url, assetToken)) {
+ sendJson(request, response, 403, errorPayload(new HttpSecurityError(403, "invalid_asset_token", "Invalid or untrusted workspace asset request.")));
+ return;
+ }
+ } else if (!isHealth) {
+ if (url.searchParams.has("token")) {
+ sendJson(request, response, 400, errorPayload(new HttpSecurityError(400, "query_token_rejected", "API tokens are accepted only in the request header.")));
+ return;
+ }
+ if (request.headers[API_TOKEN_HEADER] !== publicToken) {
+ sendJson(request, response, 403, errorPayload(new HttpSecurityError(403, "invalid_local_token", "Invalid local session token.")));
+ return;
+ }
+ }
+ if (request.method === "POST" && url.pathname === "/api/import-upload") {
+ await proxyUpload({ request, response, socketPath, internalToken, url, maxBytes: options.uploadLimitBytes ?? HTTP_SECURITY_LIMITS.uploadBytes });
+ return;
+ }
+ let body = Buffer.alloc(0);
+ if (request.method === "POST") {
+ body = await readBoundedBody(request, options.jsonLimitBytes ?? HTTP_SECURITY_LIMITS.jsonBytes);
+ const parsed = parseJsonBody(body);
+ validatePublicApiPayload(url.pathname, parsed);
+ body = Buffer.from(JSON.stringify(parsed), "utf8");
+ }
+ await proxyBuffered({ request, response, socketPath, internalToken, url, body });
+ return;
+ }
+ await proxyBuffered({ request, response, socketPath, internalToken, url, body: Buffer.alloc(0) });
+ } catch (error) {
+ if (error?.code === "request_too_large") request.resume();
+ const status = error instanceof HttpSecurityError ? error.status : 500;
+ if (!response.headersSent) sendJson(request, response, status, errorPayload(error));
+ else response.destroy(error);
+ }
+ });
+
+ try {
+ await new Promise((resolve, reject) => {
+ publicServer.once("error", reject);
+ publicServer.listen(port, host, () => {
+ publicServer.off("error", reject);
+ resolve();
+ });
+ });
+ } catch (error) {
+ internalServer.close();
+ if (process.platform !== "win32") await rm(socketPath, { force: true }).catch(() => {});
+ throw error;
+ }
+
+ const address = publicServer.address();
+ const actualPort = typeof address === "object" && address ? address.port : port;
+ apiBaseUrl = options.apiBaseUrl || `http://${host}:${actualPort}`;
+ publicServer.localToken = publicToken;
+ publicServer.assetToken = assetToken;
+ Object.defineProperty(publicServer, "bootstrapToken", { get: () => bootstrapToken });
+ options.onBootstrapToken?.({ apiBaseUrl, bootstrapToken, previousToken: null });
+
+ publicServer.on("close", () => {
+ internalServer.close();
+ if (process.platform !== "win32") rm(socketPath, { force: true }).catch(() => {});
+ });
+ return publicServer;
+}
diff --git a/test/core-documents.test.js b/test/core-documents.test.js
index cf17f88..9c982cc 100644
--- a/test/core-documents.test.js
+++ b/test/core-documents.test.js
@@ -838,9 +838,9 @@ test("exports workspace markdown to docx and pdf files", async () => {
assert.match(readZipEntry(await readFile(docxPath), "word/document.xml").toString("utf8"), /Export me/);
assert.match(await pdfBufferToMarkdown(await readFile(pdfPath), "source.pdf"), /Export me/);
});
-test("external Markdown export copies local visual assets beside the document", async () => {
+test("workspace Markdown export copies local visual assets beside the document", async () => {
const workspace = await tempDir("lft-md-assets-");
- const destination = await tempDir("lft-md-assets-out-");
+ const destination = path.join(workspace, "exports");
await openOrCreateWorkspace(workspace);
const assetDir = path.join(workspace, "assets", "Physics Book.pdf");
await mkdir(assetDir, { recursive: true });
@@ -872,12 +872,11 @@ test("merged export preserves existing files with numbered names", async () => {
assert.equal(await readFile(first, "utf8"), "first");
assert.equal(await readFile(second, "utf8"), "second");
});
-test("external Markdown export rejects traversal and non-image asset reads", async () => {
+test("workspace Markdown export rejects traversal and non-image asset reads", async () => {
const container = await tempDir("lft-md-assets-boundary-");
const workspace = path.join(container, "workspace");
- const destination = path.join(container, "destination");
+ const destination = path.join(workspace, "exports");
const outsidePng = path.join(container, "outside.png");
- await mkdir(destination, { recursive: true });
await openOrCreateWorkspace(workspace);
await writeFile(outsidePng, Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l4QW2QAAAABJRU5ErkJggg==", "base64"));
await mkdir(path.join(workspace, "assets"), { recursive: true });