From 3833ef26687920f5155bc71dce685145d6a9a180 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Tue, 2 Jun 2026 16:58:01 +0200 Subject: [PATCH 1/5] clipboard reccursion fix --- .../shim/src/electron/remote/clipboard.js | 40 +++++++++++++++---- .../src/electron/remote/native-clipboard.js | 22 ++++++++++ packages/shim/src/electron/remote/window.js | 18 ++++++++- 3 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 packages/shim/src/electron/remote/native-clipboard.js diff --git a/packages/shim/src/electron/remote/clipboard.js b/packages/shim/src/electron/remote/clipboard.js index d19aab9..28713ce 100644 --- a/packages/shim/src/electron/remote/clipboard.js +++ b/packages/shim/src/electron/remote/clipboard.js @@ -1,10 +1,18 @@ +import { getClipboard } from "./native-clipboard.js"; + export const clipboardShim = { readText() { return ""; }, writeText(text) { - navigator.clipboard.writeText(text).catch((e) => { + const clip = getClipboard(); + + if (!clip) { + return; + } + + clip.writeText(text).catch((e) => { console.warn("[shim:clipboard] writeText failed:", e); }); }, @@ -14,7 +22,13 @@ export const clipboardShim = { }, writeHTML(html) { - navigator.clipboard + const clip = getClipboard(); + + if (!clip) { + return; + } + + clip .write([ new ClipboardItem({ "text/html": new Blob([html], { type: "text/html" }), @@ -35,6 +49,12 @@ export const clipboardShim = { return; } + const clip = getClipboard(); + + if (!clip) { + return; + } + const pngData = image.toPNG(); if (!pngData || pngData.length === 0) { @@ -43,11 +63,9 @@ export const clipboardShim = { const blob = new Blob([pngData], { type: "image/png" }); - navigator.clipboard - .write([new ClipboardItem({ "image/png": blob })]) - .catch((e) => { - console.warn("[shim:clipboard] writeImage failed:", e); - }); + clip.write([new ClipboardItem({ "image/png": blob })]).catch((e) => { + console.warn("[shim:clipboard] writeImage failed:", e); + }); }, has(format) { @@ -59,6 +77,12 @@ export const clipboardShim = { }, clear() { - navigator.clipboard.writeText("").catch(() => {}); + const clip = getClipboard(); + + if (!clip) { + return; + } + + clip.writeText("").catch(() => {}); }, }; diff --git a/packages/shim/src/electron/remote/native-clipboard.js b/packages/shim/src/electron/remote/native-clipboard.js new file mode 100644 index 0000000..086936c --- /dev/null +++ b/packages/shim/src/electron/remote/native-clipboard.js @@ -0,0 +1,22 @@ +// Obsidian points navigator.clipboard.writeText at electron.clipboard, which already points at this shim. +// To avoid recursion, use the untouched native prototype methods. +const proto = typeof Clipboard !== "undefined" ? Clipboard.prototype : null; + +// Returns a native-backed clipboard facade, or null in insecure (non-localhost http) contexts. +export function getClipboard() { + const clip = + typeof navigator !== "undefined" ? navigator.clipboard : undefined; + + if (!proto || !clip) { + console.warn( + "[shim:clipboard] clipboard API unavailable (insecure context?)", + ); + return null; + } + + return { + writeText: (text) => proto.writeText.call(clip, text), + write: (items) => proto.write.call(clip, items), + read: () => proto.read.call(clip), + }; +} diff --git a/packages/shim/src/electron/remote/window.js b/packages/shim/src/electron/remote/window.js index 1312d4d..7d62e37 100644 --- a/packages/shim/src/electron/remote/window.js +++ b/packages/shim/src/electron/remote/window.js @@ -1,3 +1,5 @@ +import { getClipboard } from "./native-clipboard.js"; + const currentWindowState = { title: "Obsidian", isMaximized: false, @@ -196,7 +198,13 @@ const currentWebContents = { document.execCommand("copy"); }, paste() { - navigator.clipboard + const clip = getClipboard(); + + if (!clip) { + return; + } + + clip .read() .then(async (items) => { const dt = new DataTransfer(); @@ -233,7 +241,13 @@ const currentWebContents = { }); }, pasteAndMatchStyle() { - navigator.clipboard + const clip = getClipboard(); + + if (!clip) { + return; + } + + clip .read() .then(async (items) => { for (const item of items) { From caaf6b31442d40fc9766e485d9bc39f959e92320 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Tue, 2 Jun 2026 17:09:54 +0200 Subject: [PATCH 2/5] improve url origin checking in shim --- packages/shim/src/fs/virtual-files.js | 8 +++++++- packages/shim/src/globals.js | 2 +- packages/shim/src/request-url.js | 10 ++++------ packages/shim/src/virtual-plugin-loader.js | 10 ++++++++++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/shim/src/fs/virtual-files.js b/packages/shim/src/fs/virtual-files.js index 65078f0..121e6e7 100644 --- a/packages/shim/src/fs/virtual-files.js +++ b/packages/shim/src/fs/virtual-files.js @@ -7,7 +7,13 @@ function normalize(p) { const virtualFiles = new Map(); export function setVirtualFile(path, content) { - virtualFiles.set(normalize(path), content); + const normalized = normalize(path); + + if (normalized.split("/").includes("..")) { + throw new Error(`virtual file path may not contain '..': ${path}`); + } + + virtualFiles.set(normalized, content); } export function removeVirtualFile(path) { diff --git a/packages/shim/src/globals.js b/packages/shim/src/globals.js index fac08e8..c829d7e 100644 --- a/packages/shim/src/globals.js +++ b/packages/shim/src/globals.js @@ -138,7 +138,7 @@ function base64ToArrayBuffer(base64) { return bytes.buffer; } -function isSameOrigin(url) { +export function isSameOrigin(url) { if ( !url || url.startsWith("/") || diff --git a/packages/shim/src/request-url.js b/packages/shim/src/request-url.js index b67b025..8a43b6e 100644 --- a/packages/shim/src/request-url.js +++ b/packages/shim/src/request-url.js @@ -1,6 +1,8 @@ // Override window.requestUrl to proxy external requests through our server, bypassing CORS. // Obsidian sets window.requestUrl in app.js, so we override it after app.js loads. +import { isSameOrigin } from "./globals.js"; + function base64ToArrayBuffer(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); @@ -29,12 +31,8 @@ async function proxyRequestUrl(request) { request = { url: request }; } - const isSameOrigin = - request.url.startsWith(window.location.origin) || - request.url.startsWith("/"); - - // Same-origin requests don't need the proxy - if (isSameOrigin) { + // Same-origin requests don't need the proxy. + if (isSameOrigin(request.url)) { const res = await fetch(request.url, { method: request.method || "GET", headers: request.headers || {}, diff --git a/packages/shim/src/virtual-plugin-loader.js b/packages/shim/src/virtual-plugin-loader.js index f04d09d..884fcca 100644 --- a/packages/shim/src/virtual-plugin-loader.js +++ b/packages/shim/src/virtual-plugin-loader.js @@ -104,6 +104,12 @@ export async function extractObsidianModule() { return captured; } +function assertSameOrigin(url) { + if (new URL(url, location.origin).origin !== location.origin) { + throw new Error(`refusing cross-origin plugin URL: ${url}`); + } +} + // Serialize per-id load/unload so rapid toggles can't race. const inFlight = new Map(); @@ -128,7 +134,11 @@ export function loadVirtualPlugin(entry) { return; } + assertSameOrigin(entry.scriptUrl); + if (entry.cssUrl) { + assertSameOrigin(entry.cssUrl); + const link = document.createElement("link"); link.rel = "stylesheet"; link.href = entry.cssUrl; From b90752e0ad96a4e7d9b239dfb7f92329294979b3 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Tue, 2 Jun 2026 17:42:47 +0200 Subject: [PATCH 3/5] headless-sync minor security improvements --- .../server/plugins/headless-sync/auth.js | 16 ++++++++++++---- .../server/plugins/headless-sync/sync-manager.js | 12 ++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/ignis-server/server/plugins/headless-sync/auth.js b/apps/ignis-server/server/plugins/headless-sync/auth.js index 88e66cc..3a96343 100644 --- a/apps/ignis-server/server/plugins/headless-sync/auth.js +++ b/apps/ignis-server/server/plugins/headless-sync/auth.js @@ -83,15 +83,23 @@ function isAuthenticated(dataDir) { return false; } +function writeSecret(file, contents) { + fs.writeFileSync(file, contents, { encoding: "utf-8", mode: 0o600 }); + + try { + fs.chmodSync(file, 0o600); + } catch {} +} + function saveInternal(dataDir, tokenData) { const internalFile = getInternalTokenFile(dataDir); const dir = path.dirname(internalFile); if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } - fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8"); + writeSecret(internalFile, JSON.stringify(tokenData, null, 2)); } function syncToObCli(dataDir, token) { @@ -101,10 +109,10 @@ function syncToObCli(dataDir, token) { const dir = path.dirname(obAuthFile); if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } - fs.writeFileSync(obAuthFile, token, "utf-8"); + writeSecret(obAuthFile, token); } catch {} } diff --git a/apps/ignis-server/server/plugins/headless-sync/sync-manager.js b/apps/ignis-server/server/plugins/headless-sync/sync-manager.js index a89adeb..06e6b21 100644 --- a/apps/ignis-server/server/plugins/headless-sync/sync-manager.js +++ b/apps/ignis-server/server/plugins/headless-sync/sync-manager.js @@ -4,6 +4,7 @@ const { spawn } = require("child_process"); const { spawnOb, runCommand } = require("./ob-cli"); const MAX_LOG_ENTRIES = 200; +const MAX_LOG_LINE = 4096; function killProcess(proc) { if (!proc) { @@ -151,10 +152,13 @@ class SyncManager { const lines = data.toString().split("\n"); for (const line of lines) { - if (line.trim()) { - this.addLog(state, line.trim()); + const trimmed = line.trim(); + + if (trimmed) { + const capped = trimmed.slice(0, MAX_LOG_LINE); + this.addLog(state, capped); state.lastActivity = new Date().toISOString(); - this.broadcaster.broadcastLog(vaultId, line.trim()); + this.broadcaster.broadcastLog(vaultId, capped); } } }); @@ -302,7 +306,7 @@ class SyncManager { addLog(state, line) { state.logs.push({ timestamp: new Date().toISOString(), - line, + line: line.slice(0, MAX_LOG_LINE), }); if (state.logs.length > MAX_LOG_ENTRIES) { From 05a3908a7ad1efefdad51d5a6c8a5f5d7bf53232 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Wed, 3 Jun 2026 01:15:27 +0200 Subject: [PATCH 4/5] refactor utility functions --- packages/shim/src/electron/ipc-renderer.js | 24 +---------- packages/shim/src/fs/echo-guard.js | 11 +---- packages/shim/src/fs/input-cache.js | 9 +---- packages/shim/src/fs/transforms.js | 4 +- packages/shim/src/fs/virtual-files.js | 4 +- packages/shim/src/globals.js | 47 +--------------------- packages/shim/src/request-url.js | 26 +----------- packages/shim/src/util/base64.js | 26 ++++++++++++ packages/shim/src/util/path.js | 7 ++++ packages/shim/src/util/url.js | 24 +++++++++++ 10 files changed, 68 insertions(+), 114 deletions(-) create mode 100644 packages/shim/src/util/base64.js create mode 100644 packages/shim/src/util/path.js create mode 100644 packages/shim/src/util/url.js diff --git a/packages/shim/src/electron/ipc-renderer.js b/packages/shim/src/electron/ipc-renderer.js index 6aa2668..57b198c 100644 --- a/packages/shim/src/electron/ipc-renderer.js +++ b/packages/shim/src/electron/ipc-renderer.js @@ -1,5 +1,6 @@ import { showVaultManager } from "../ui-registry.js"; import { vaultService } from "@ignis/services"; +import { arrayBufferToBase64, base64ToArrayBuffer } from "../util/base64.js"; const listeners = new Map(); @@ -85,29 +86,6 @@ const syncHandlers = { resources: () => "", }; -function arrayBufferToBase64(buf) { - const bytes = new Uint8Array(buf); - let binary = ""; - const chunk = 8192; - - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); - } - - return btoa(binary); -} - -function base64ToArrayBuffer(base64) { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - - return bytes.buffer; -} - async function handleRequestUrl(requestId, request) { try { let body = request.body; diff --git a/packages/shim/src/fs/echo-guard.js b/packages/shim/src/fs/echo-guard.js index 82f3d60..79f369a 100644 --- a/packages/shim/src/fs/echo-guard.js +++ b/packages/shim/src/fs/echo-guard.js @@ -1,17 +1,10 @@ // Shared echo suppression for file watcher. -// fs operations mark paths as "locally modified" so the watcher client -// can skip events that originated from this client. +// fs operations mark paths as "locally modified" so the watcher client can skip events that originated from this client. +import { normalize } from "../util/path.js"; const ECHO_SUPPRESS_MS = 1500; const recentOps = new Map(); // normalized path -> timestamp -function normalize(p) { - return (p || "") - .replace(/\\/g, "/") - .replace(/^\/+/, "") - .replace(/\/+$/, ""); -} - export function markLocalOp(path) { recentOps.set(normalize(path), Date.now()); } diff --git a/packages/shim/src/fs/input-cache.js b/packages/shim/src/fs/input-cache.js index 1645f9f..1ed24ab 100644 --- a/packages/shim/src/fs/input-cache.js +++ b/packages/shim/src/fs/input-cache.js @@ -5,19 +5,14 @@ // - 5-minute TTL per entry // - Entries kept until TTL expires (plugins may read the same file multiple times) +import { normalize } from "../util/path.js"; + const MAX_SIZE = 200 * 1024 * 1024; const TTL_MS = 5 * 60 * 1000; const cache = new Map(); // path -> { data, size, createdAt } let currentSize = 0; -function normalize(p) { - return (p || "") - .replace(/\\/g, "/") - .replace(/^\/+/, "") - .replace(/\/+$/, ""); -} - function evictExpired() { const now = Date.now(); diff --git a/packages/shim/src/fs/transforms.js b/packages/shim/src/fs/transforms.js index 9a04365..46dca2c 100644 --- a/packages/shim/src/fs/transforms.js +++ b/packages/shim/src/fs/transforms.js @@ -2,9 +2,7 @@ // Path resolvers map logical paths to physical paths; read transforms post-process bytes after a read; write transforms pre-process bytes before a write. // All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes. -function normalize(p) { - return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); -} +import { normalize } from "../util/path.js"; // --- Path resolvers --- diff --git a/packages/shim/src/fs/virtual-files.js b/packages/shim/src/fs/virtual-files.js index 121e6e7..4952ba1 100644 --- a/packages/shim/src/fs/virtual-files.js +++ b/packages/shim/src/fs/virtual-files.js @@ -1,8 +1,6 @@ // Virtual plugin source served from memory; the fs shim's read path checks here before disk. -function normalize(p) { - return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); -} +import { normalize } from "../util/path.js"; const virtualFiles = new Map(); diff --git a/packages/shim/src/globals.js b/packages/shim/src/globals.js index c829d7e..ffc04f7 100644 --- a/packages/shim/src/globals.js +++ b/packages/shim/src/globals.js @@ -4,6 +4,8 @@ import { unregisterPopupWindow, } from "./electron/remote/window.js"; import { showVaultManager } from "./ui-registry.js"; +import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js"; +import { isSameOrigin } from "./util/url.js"; function installProcess() { window.process = processShim; @@ -115,51 +117,6 @@ function installWindowOpen() { }; } -function arrayBufferToBase64(buf) { - const bytes = new Uint8Array(buf); - let binary = ""; - const chunk = 8192; - - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); - } - - return btoa(binary); -} - -function base64ToArrayBuffer(base64) { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - - return bytes.buffer; -} - -export function isSameOrigin(url) { - if ( - !url || - url.startsWith("/") || - url.startsWith("./") || - url.startsWith("../") - ) { - return true; - } - - if (url.startsWith("data:") || url.startsWith("blob:")) { - return true; - } - - try { - const parsed = new URL(url, window.location.origin); - return parsed.origin === window.location.origin; - } catch { - return true; - } -} - function installFetchShim() { const originalFetch = window.fetch.bind(window); window.__originalFetch = originalFetch; diff --git a/packages/shim/src/request-url.js b/packages/shim/src/request-url.js index 8a43b6e..39f304e 100644 --- a/packages/shim/src/request-url.js +++ b/packages/shim/src/request-url.js @@ -1,30 +1,8 @@ // Override window.requestUrl to proxy external requests through our server, bypassing CORS. // Obsidian sets window.requestUrl in app.js, so we override it after app.js loads. -import { isSameOrigin } from "./globals.js"; - -function base64ToArrayBuffer(base64) { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - - return bytes.buffer; -} - -function arrayBufferToBase64(buf) { - const bytes = new Uint8Array(buf); - let binary = ""; - const chunk = 8192; - - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); - } - - return btoa(binary); -} +import { isSameOrigin } from "./util/url.js"; +import { arrayBufferToBase64, base64ToArrayBuffer } from "./util/base64.js"; async function proxyRequestUrl(request) { if (typeof request === "string") { diff --git a/packages/shim/src/util/base64.js b/packages/shim/src/util/base64.js new file mode 100644 index 0000000..01b6772 --- /dev/null +++ b/packages/shim/src/util/base64.js @@ -0,0 +1,26 @@ +// Base64 codec for the binary bodies exchanged with the server proxy. + +function arrayBufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ""; + const chunk = 8192; + + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + } + + return btoa(binary); +} + +function base64ToArrayBuffer(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes.buffer; +} + +export { arrayBufferToBase64, base64ToArrayBuffer }; diff --git a/packages/shim/src/util/path.js b/packages/shim/src/util/path.js new file mode 100644 index 0000000..bbc514f --- /dev/null +++ b/packages/shim/src/util/path.js @@ -0,0 +1,7 @@ +// Canonical key form for fs paths: backslashes to forward slashes, no leading or trailing slash. +// Used by caches and registries that key on path. +function normalize(p) { + return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); +} + +export { normalize }; diff --git a/packages/shim/src/util/url.js b/packages/shim/src/util/url.js new file mode 100644 index 0000000..7c6071a --- /dev/null +++ b/packages/shim/src/util/url.js @@ -0,0 +1,24 @@ +// True when a request URL targets the page's own origin (so it can skip the cross-origin proxy). +function isSameOrigin(url) { + if ( + !url || + url.startsWith("/") || + url.startsWith("./") || + url.startsWith("../") + ) { + return true; + } + + if (url.startsWith("data:") || url.startsWith("blob:")) { + return true; + } + + try { + const parsed = new URL(url, window.location.origin); + return parsed.origin === window.location.origin; + } catch { + return true; + } +} + +export { isSameOrigin }; From f0b7f65a360357c33c82c257191a234d71501aab Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Wed, 3 Jun 2026 13:32:58 +0200 Subject: [PATCH 5/5] update changelog, bump version --- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66fdd61..326fd5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [0.8.4] - Karm (2026-06-03) + +### Fixed + +- Codeblocks calling clipboard APIs no longer causes reccursion error. + +### Security + +- Hardened same-origin checks, virtual-plugin URL validation, token file permissions, and log line bounds. + ## [0.8.3] - Karm (2026-06-01) ### Added diff --git a/package.json b/package.json index ebad16b..b59e2ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ignis-monorepo", - "version": "0.8.3", + "version": "0.8.4", "private": true, "description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.", "workspaces": [