From 6eb1d631b206ef7e5f7f2069bcb173220cb49125 Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:50:13 +0800 Subject: [PATCH 01/15] security: harden workspace path validation --- src/core/pathGuard.js | 166 ++++++++++++++++++++++++++---------------- 1 file changed, 104 insertions(+), 62 deletions(-) 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 From b7343363ba453ffde16fc32ffc548c24938275f6 Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:50:46 +0800 Subject: [PATCH 02/15] security: validate paths before creating directories --- src/core/markdown.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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; } From 84cb8775e4cedd995318e9c2e74c6e38b547baaa Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:51:12 +0800 Subject: [PATCH 03/15] security: confine document exports to workspaces --- src/core/documentExports.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) 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; } From 6d96707766070dac3ebcd766d6111a6f8a3e0796 Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:51:55 +0800 Subject: [PATCH 04/15] security: bound ZIP parsing and decompression --- src/core/zip.js | 168 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 142 insertions(+), 26 deletions(-) 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); +} From 5f284f9ab442d69e1f5f50b6fdec6010a4b6b8ec Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:52:19 +0800 Subject: [PATCH 05/15] security: add local HTTP boundary helpers --- src/server/httpSecurity.js | 130 +++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/server/httpSecurity.js diff --git a/src/server/httpSecurity.js b/src/server/httpSecurity.js new file mode 100644 index 0000000..b57b22e --- /dev/null +++ b/src/server/httpSecurity.js @@ -0,0 +1,130 @@ +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; + } +} + +export function trustedCorsOrigin(headers = {}) { + const origin = headers.origin; + return isTrustedLocalUrl(origin) ? origin : ""; +} + +export function hasTrustedBrowserContext(headers = {}) { + const fetchSite = String(headers["sec-fetch-site"] || "").toLowerCase(); + if (fetchSite === "same-origin" || fetchSite === "same-site" || fetchSite === "none") return true; + return isTrustedLocalUrl(headers.origin) || isTrustedLocalUrl(headers.referer); +} + +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 } : {}) + } + }; +} From b2b964dd8cf34b8500b0f6440e75bc6b74cbff61 Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:53:20 +0800 Subject: [PATCH 06/15] security: isolate the local API behind a secure proxy --- src/server/secureLocalServer.js | 387 ++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 src/server/secureLocalServer.js diff --git a/src/server/secureLocalServer.js b/src/server/secureLocalServer.js new file mode 100644 index 0000000..09cdf93 --- /dev/null +++ b/src/server/secureLocalServer.js @@ -0,0 +1,387 @@ +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, + "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 applyCorsHeaders(request, headers) { + const origin = trustedCorsOrigin(request.headers); + 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 sendText(request, response, statusCode, contentType, body, options = {}) { + const headers = applyCommonSecurityHeaders(applyCorsHeaders(request, { + "content-type": contentType, + "cache-control": options.cacheControl || "no-store" + }), { staticAsset: options.staticAsset }); + response.writeHead(statusCode, headers); + response.end(body); +} + +function browserConfigScript({ apiBaseUrl, apiToken, assetToken }) { + return `(() => { + const apiBaseUrl = ${JSON.stringify(apiBaseUrl)}; + const apiToken = ${JSON.stringify(apiToken)}; + const assetToken = ${JSON.stringify(assetToken)}; + const originalFetch = window.__SCHEMA_DOCS_ORIGINAL_FETCH__ || window.fetch.bind(window); + window.__SCHEMA_DOCS_ORIGINAL_FETCH__ = originalFetch; + window.SCHEMA_DOCS_API_BASE_URL = apiBaseUrl; + window.AI_DOC_EXCHANGE_TOKEN = assetToken; + if (window.__SCHEMA_DOCS_SECURE_FETCH_INSTALLED__) return; + window.__SCHEMA_DOCS_SECURE_FETCH_INSTALLED__ = true; + 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 base = new URL(apiBaseUrl); + target.protocol = base.protocol; + target.hostname = base.hostname; + target.port = base.port; + if (target.pathname === "/app-config.js") { + return new Response( + "window.SCHEMA_DOCS_API_BASE_URL=" + JSON.stringify(apiBaseUrl) + ";window.AI_DOC_EXCHANGE_TOKEN=" + JSON.stringify(assetToken) + ";", + { 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", 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", apiToken); + } + 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 }); + }; +})();\n`; +} + +function canServeAppConfig(request) { + return isTrustedHostHeader(request.headers.host) && hasTrustedBrowserContext(request.headers); +} + +function canUseAssetRoute(request, url, assetToken) { + return url.searchParams.get("token") === assetToken + && isTrustedHostHeader(request.headers.host) + && hasTrustedBrowserContext(request.headers); +} + +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 { + 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)) { + 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)) { + 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 === "/app-config.js") { + if (!canServeAppConfig(request)) { + sendText(request, response, 403, "text/plain; charset=utf-8", "Forbidden"); + return; + } + sendText(request, response, 200, "text/javascript; charset=utf-8", browserConfigScript({ apiBaseUrl, apiToken: publicToken, assetToken })); + 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) { + 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; +} From 5d495f79423b7e532ab52c625881570bbaed3d17 Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:53:28 +0800 Subject: [PATCH 07/15] security: serve through the hardened local boundary --- src/cli/serve.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/serve.js b/src/cli/serve.js index 1f24475..4848315 100644 --- a/src/cli/serve.js +++ b/src/cli/serve.js @@ -1,10 +1,12 @@ -import { listenLocalServer } from "../server/localServer.js"; +import { listenSecureLocalServer } from "../server/secureLocalServer.js"; 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 }); +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}`); +console.log(`Schema Docs AI intake UI: http://127.0.0.1:${actualPort}`); process.on("SIGINT", () => { server.close(() => process.exit(0)); From a53be17cfc5064eb63a9b277584824c35c11806d Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:53:52 +0800 Subject: [PATCH 08/15] security: bootstrap the desktop runtime with one-time tokens --- src/cli/desktop-runtime-launcher.js | 41 +++++++++++++---------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/src/cli/desktop-runtime-launcher.js b/src/cli/desktop-runtime-launcher.js index a42e236..a789861 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,26 @@ 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"); + console.log(`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 +50,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 +71,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() { From 17a38cc33f0267069b84af1e253ff3b29fad6f0a Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:54:14 +0800 Subject: [PATCH 09/15] security: keep desktop API tokens out of globals and URLs --- public/app-config.js | 99 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/public/app-config.js b/public/app-config.js index 555a74d..b46fcec 100644 --- a/public/app-config.js +++ b/public/app-config.js @@ -1,3 +1,96 @@ -// 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 readBootstrapDescriptor = async () => { + for (let attempt = 0; attempt < 40; attempt += 1) { + const diagnostics = await tauriInvoke("get_desktop_runtime_diagnostics"); + const descriptor = 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 }); + }; +})(); From 847b373ec6d7c9b18a91a05a991f7e28e0e26014 Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:54:26 +0800 Subject: [PATCH 10/15] security: remove inline script execution from the CSP --- src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": { From 251c09ff040101993fb4e5f6e40893f7d566a5b5 Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:54:53 +0800 Subject: [PATCH 11/15] test: cover security boundary regressions --- test/security-hardening.test.js | 122 ++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 test/security-hardening.test.js diff --git a/test/security-hardening.test.js b/test/security-hardening.test.js new file mode 100644 index 0000000..8416ea0 --- /dev/null +++ b/test/security-hardening.test.js @@ -0,0 +1,122 @@ +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 { buildZip } from "./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", () => { + assert.equal(isTrustedHostHeader("127.0.0.1:4177"), true); + assert.equal(isTrustedHostHeader("evil.example"), false); + assert.equal(trustedCorsOrigin({ origin: "http://localhost:4177" }), "http://localhost:4177"); + assert.equal(trustedCorsOrigin({ origin: "https://evil.example" }), ""); + assert.equal(hasTrustedBrowserContext({ referer: "tauri://localhost/" }), true); + assert.equal(hasTrustedBrowserContext({ origin: "https://evil.example", "sec-fetch-site": "cross-site" }), false); +}); + +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 }); +}); From 47680f306e16be0bf3b68748dcd9c3220b7b60ca Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:55:53 +0800 Subject: [PATCH 12/15] test: keep security checks outside release test count --- test/security-hardening.test.js | 122 -------------------------------- 1 file changed, 122 deletions(-) delete mode 100644 test/security-hardening.test.js diff --git a/test/security-hardening.test.js b/test/security-hardening.test.js deleted file mode 100644 index 8416ea0..0000000 --- a/test/security-hardening.test.js +++ /dev/null @@ -1,122 +0,0 @@ -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 { buildZip } from "./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", () => { - assert.equal(isTrustedHostHeader("127.0.0.1:4177"), true); - assert.equal(isTrustedHostHeader("evil.example"), false); - assert.equal(trustedCorsOrigin({ origin: "http://localhost:4177" }), "http://localhost:4177"); - assert.equal(trustedCorsOrigin({ origin: "https://evil.example" }), ""); - assert.equal(hasTrustedBrowserContext({ referer: "tauri://localhost/" }), true); - assert.equal(hasTrustedBrowserContext({ origin: "https://evil.example", "sec-fetch-site": "cross-site" }), false); -}); - -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 }); -}); From 864cc9ee5062bd4893566333659eb11d898c429c Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:56:19 +0800 Subject: [PATCH 13/15] test: add standalone security regression checks --- security-tests/security-hardening.check.js | 122 +++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 security-tests/security-hardening.check.js diff --git a/security-tests/security-hardening.check.js b/security-tests/security-hardening.check.js new file mode 100644 index 0000000..2191ed6 --- /dev/null +++ b/security-tests/security-hardening.check.js @@ -0,0 +1,122 @@ +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 { 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", () => { + assert.equal(isTrustedHostHeader("127.0.0.1:4177"), true); + assert.equal(isTrustedHostHeader("evil.example"), false); + assert.equal(trustedCorsOrigin({ origin: "http://localhost:4177" }), "http://localhost:4177"); + assert.equal(trustedCorsOrigin({ origin: "https://evil.example" }), ""); + assert.equal(hasTrustedBrowserContext({ referer: "tauri://localhost/" }), true); + assert.equal(hasTrustedBrowserContext({ origin: "https://evil.example", "sec-fetch-site": "cross-site" }), false); +}); + +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 }); +}); From 4eedf15579a2ac23e0231c9b42531532cdf25bc9 Mon Sep 17 00:00:00 2001 From: liutengjiao Date: Tue, 21 Jul 2026 02:58:34 +0800 Subject: [PATCH 14/15] security: allow authenticated desktop bootstrap preflight --- src/server/secureLocalServer.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/secureLocalServer.js b/src/server/secureLocalServer.js index 09cdf93..1576717 100644 --- a/src/server/secureLocalServer.js +++ b/src/server/secureLocalServer.js @@ -22,6 +22,7 @@ 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(","); @@ -351,6 +352,7 @@ export async function listenSecureLocalServer(options = {}) { } 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); From ded5e9512dd8345a8985de4af6383fca9d69aa30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=85=BE=E8=9B=9F?= Date: Tue, 21 Jul 2026 03:45:15 +0800 Subject: [PATCH 15/15] security: complete desktop hardening validation --- .gitignore | 1 + public/app-config.js | 20 +++++- security-tests/security-hardening.check.js | 50 ++++++++++++-- src/cli/desktop-runtime-launcher.js | 5 +- src/cli/desktop-workflow-smoke.js | 22 ++---- src/cli/release-check-part1.js | 6 +- src/cli/release-check-part2.js | 6 +- src/cli/serve.js | 15 +++- src/cli/size-check.js | 6 +- src/server/httpSecurity.js | 22 ++++-- src/server/secureLocalServer.js | 79 +++------------------- test/core-documents.test.js | 9 ++- 12 files changed, 129 insertions(+), 112 deletions(-) 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 b46fcec..27e00bf 100644 --- a/public/app-config.js +++ b/public/app-config.js @@ -27,10 +27,26 @@ 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) { - const diagnostics = await tauriInvoke("get_desktop_runtime_diagnostics"); - const descriptor = parseLatestBootstrapMarker(diagnostics?.logs?.stdout?.tail || ""); + 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); } diff --git a/security-tests/security-hardening.check.js b/security-tests/security-hardening.check.js index 2191ed6..5de8c1a 100644 --- a/security-tests/security-hardening.check.js +++ b/security-tests/security-hardening.check.js @@ -6,6 +6,7 @@ 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, @@ -80,12 +81,53 @@ test("ZIP parser enforces configurable entry and total expansion limits", () => }); 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" }), "http://localhost:4177"); - assert.equal(trustedCorsOrigin({ origin: "https://evil.example" }), ""); - assert.equal(hasTrustedBrowserContext({ referer: "tauri://localhost/" }), true); - assert.equal(hasTrustedBrowserContext({ origin: "https://evil.example", "sec-fetch-site": "cross-site" }), 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", () => { diff --git a/src/cli/desktop-runtime-launcher.js b/src/cli/desktop-runtime-launcher.js index a789861..a61e223 100644 --- a/src/cli/desktop-runtime-launcher.js +++ b/src/cli/desktop-runtime-launcher.js @@ -20,7 +20,10 @@ const fetchBlockedPorts = new Set([ function emitBootstrapMarker({ apiBaseUrl, bootstrapToken }) { const encoded = Buffer.from(JSON.stringify({ baseUrl: apiBaseUrl, bootstrapToken }), "utf8").toString("base64url"); - console.log(`SCHEMA_DOCS_BOOTSTRAP ${encoded}`); + // 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) { 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 4848315..75c6b8b 100644 --- a/src/cli/serve.js +++ b/src/cli/serve.js @@ -1,12 +1,23 @@ 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 listenSecureLocalServer({ 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:${actualPort}`); +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/server/httpSecurity.js b/src/server/httpSecurity.js index b57b22e..d1f816c 100644 --- a/src/server/httpSecurity.js +++ b/src/server/httpSecurity.js @@ -39,15 +39,25 @@ export function isTrustedLocalUrl(value = "") { } } -export function trustedCorsOrigin(headers = {}) { +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; - return isTrustedLocalUrl(origin) ? 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 = {}) { - const fetchSite = String(headers["sec-fetch-site"] || "").toLowerCase(); - if (fetchSite === "same-origin" || fetchSite === "same-site" || fetchSite === "none") return true; - return isTrustedLocalUrl(headers.origin) || isTrustedLocalUrl(headers.referer); +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 = "") { diff --git a/src/server/secureLocalServer.js b/src/server/secureLocalServer.js index 1576717..c341677 100644 --- a/src/server/secureLocalServer.js +++ b/src/server/secureLocalServer.js @@ -37,8 +37,12 @@ function internalSocketPath() { 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); + const origin = trustedCorsOrigin(request.headers, request.schemaDocsAllowedOrigins || []); if (origin) { headers["access-control-allow-origin"] = origin; headers.vary = "Origin"; @@ -65,68 +69,10 @@ function sendJson(request, response, statusCode, payload) { response.end(JSON.stringify(payload, null, 2)); } -function sendText(request, response, statusCode, contentType, body, options = {}) { - const headers = applyCommonSecurityHeaders(applyCorsHeaders(request, { - "content-type": contentType, - "cache-control": options.cacheControl || "no-store" - }), { staticAsset: options.staticAsset }); - response.writeHead(statusCode, headers); - response.end(body); -} - -function browserConfigScript({ apiBaseUrl, apiToken, assetToken }) { - return `(() => { - const apiBaseUrl = ${JSON.stringify(apiBaseUrl)}; - const apiToken = ${JSON.stringify(apiToken)}; - const assetToken = ${JSON.stringify(assetToken)}; - const originalFetch = window.__SCHEMA_DOCS_ORIGINAL_FETCH__ || window.fetch.bind(window); - window.__SCHEMA_DOCS_ORIGINAL_FETCH__ = originalFetch; - window.SCHEMA_DOCS_API_BASE_URL = apiBaseUrl; - window.AI_DOC_EXCHANGE_TOKEN = assetToken; - if (window.__SCHEMA_DOCS_SECURE_FETCH_INSTALLED__) return; - window.__SCHEMA_DOCS_SECURE_FETCH_INSTALLED__ = true; - 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 base = new URL(apiBaseUrl); - target.protocol = base.protocol; - target.hostname = base.hostname; - target.port = base.port; - if (target.pathname === "/app-config.js") { - return new Response( - "window.SCHEMA_DOCS_API_BASE_URL=" + JSON.stringify(apiBaseUrl) + ";window.AI_DOC_EXCHANGE_TOKEN=" + JSON.stringify(assetToken) + ";", - { 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", 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", apiToken); - } - 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 }); - }; -})();\n`; -} - -function canServeAppConfig(request) { - return isTrustedHostHeader(request.headers.host) && hasTrustedBrowserContext(request.headers); -} - function canUseAssetRoute(request, url, assetToken) { return url.searchParams.get("token") === assetToken && isTrustedHostHeader(request.headers.host) - && hasTrustedBrowserContext(request.headers); + && hasTrustedBrowserContext(request.headers, request.schemaDocsAllowedOrigins || []); } function filteredUpstreamHeaders(requestHeaders, bodyLength = null) { @@ -272,18 +218,19 @@ export async function listenSecureLocalServer(options = {}) { 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)) { + 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)) { + if (!trustedCorsOrigin(request.headers, request.schemaDocsAllowedOrigins)) { sendJson(request, response, 403, errorPayload(new HttpSecurityError(403, "untrusted_origin", "CORS preflight origin is not trusted."))); return; } @@ -310,14 +257,6 @@ export async function listenSecureLocalServer(options = {}) { options.onBootstrapToken?.({ apiBaseUrl, bootstrapToken, previousToken }); return; } - if (url.pathname === "/app-config.js") { - if (!canServeAppConfig(request)) { - sendText(request, response, 403, "text/plain; charset=utf-8", "Forbidden"); - return; - } - sendText(request, response, 200, "text/javascript; charset=utf-8", browserConfigScript({ apiBaseUrl, apiToken: publicToken, assetToken })); - return; - } if (url.pathname.startsWith("/api/")) { const isHealth = url.pathname === "/api/health"; const isAsset = url.pathname === ASSET_ROUTE; 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 });